Compare commits

...

20 Commits

Author SHA1 Message Date
Fedor
4b9665b27f config: N8N_PROJECT_FORM_PODROBNEE_WEBHOOK для деталей дела/проекта из CRM
- config.py: n8n_project_form_podrobnee_webhook (из .env)
- CHANGELOG_MINIAPP.md: описание переменной, эндпоинт пока не добавлен
2026-03-02 15:04:46 +03:00
Fedor
e630d03e67 Support chat mobile UX: fix keyboard overlap and improve composer.
Hide bottom navigation while typing and in support chat mode, adapt chat layout to visual viewport/keyboard insets, and enlarge the message composer so input remains visible and comfortable in TG/MAX mobile webviews.
2026-03-02 08:22:26 +03:00
Fedor
66a0065df8 Consultations, CRM dashboard, Back button in support and consultations
- Consultations: list from DraftsContext, ticket-detail webhook, response card
- Back button in bar on consultations and in support chat (miniapp:goBack)
- BottomBar: back enabled on /support; Support: goBack subscription
- n8n: CRM normalize (n8n_CODE_CRM_NORMALIZE), flatten data (n8n_CODE_FLATTEN_DATA)
- Dashboard: filter by category for CRM items, draft card width
- Backend: consultations.py, ticket-detail, n8n_ticket_form_podrobnee_webhook
- CHANGELOG_MINIAPP.md: section 2026-02-25
2026-03-01 10:49:38 +03:00
Fedor
c39b12630e Профиль: валидация, календарь, ИНН 12 цифр, email, DaData адреса, банки из BANK_IP, подсказка ИНН (ФНС)
- Backend: N8N_AUTH_WEBHOOK из env (fallback), банки из BANK_IP, эндпоинт
  /api/v1/profile/dadata/address для подсказок адресов (FORMA_DADATA_*).
- Config: bank_ip, bank_api_url, forma_dadata_api_key, forma_dadata_secret.
- Frontend Profile: DatePicker для даты рождения, ИНН 12 цифр + ссылка на ФНС,
  валидация email, чекбокс «Совпадает с адресом регистрации», AutoComplete
  адресов через DaData, Select банков из /api/v1/banks/nspk (bankId/bankName).

Подробности в CHANGELOG_PROFILE_VALIDATION.md.
2026-02-27 18:32:06 +03:00
Fedor
b5c31b43dd Banner: system banners zone carousel mobile layout remove Home from Profile 2026-02-27 15:56:40 +03:00
Fedor
f2e144e9ca Session: дублировать сессии во внешний Redis для доступа из n8n
- backend/app/api/session.py: при записи сессии в локальный Redis (6383) теперь также дублируем те же ключи
  в внешний Redis (REDIS_HOST/REDIS_PORT) через redis_service.client.
- Дублируются оба вида ключей:
  - session:{channel}:{channel_user_id}
  - session:{session_token}
- Ошибки внешнего Redis не ломают авторизацию: при недоступности — warning в логах.
2026-02-27 10:33:07 +03:00
Fedor
06b89d20e7 Support: маршрут /support на страницу чата поддержки
- App.tsx: добавлен импорт страницы Support и роутинг pathname === '/support' на компонент Support.
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».
2026-02-27 10:13:19 +03:00
Fedor
9c65b6a4ea Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK
- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.
2026-02-27 08:34:27 +03:00
Fedor
62fc57f108 Auth: multibot TG MAX logging fix 500 2026-02-27 07:48:16 +03:00
Fedor
b3a7396d32 Support: chat, tickets list, SSE Postgres NOTIFY, read/unread 2026-02-25 23:18:45 +03:00
Fedor
d8fe0b605b Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h 2026-02-24 16:17:59 +03:00
Fedor
6350f9015b Mini-app updates: UI TG MAX session nav logs 2026-02-23 11:31:52 +03:00
Fedor
4536210284 Draft detail and Back button 2026-02-21 22:08:30 +03:00
root
1887336aba docs: describe auth2 hello flow 2026-02-20 09:57:11 +03:00
root
8c3e993eb7 feat: add soft ui auth page 2026-02-20 09:31:13 +03:00
AI Assistant
a4cc4f9de6 docs: актуальное описание проекта AiForm 2026-02-19 10:36:41 +03:00
AI Assistant
2e45786e46 feat: Telegram Mini App integration and UX improvements
- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK)
- Отдельный компактный дизайн для Telegram Mini App
- Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации)
- Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию
- Telegram Mini App: кнопка "Выход" просто закрывает приложение
- Telegram Mini App: заявки "В работе" скрыты из списка
- Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot)
- Telegram Mini App: кнопки действий в черновиках расположены вертикально
- Веб-версия: убрано отображение номера телефона в приветствии
- Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации)
- Заблокировано удаление и редактирование заявок со статусом "В работе"
- Добавлена документация по Telegram Mini App интеграции
2026-01-29 16:12:48 +03:00
Fedor
73524465fd feat: Обновления после последнего коммита
Изменения в backend:
- Обновления в n8n_proxy.py
- Изменения в SMS API
- Обновления конфигурации
- Улучшения SMS сервиса

Изменения в frontend:
- Обновления Step1Phone компонента
- Изменения в Step3Payment
- Улучшения generateConfirmationFormHTML
- Обновления ClaimForm страницы
- Изменения в vite.config.ts

Статистика: +242 строки, -81 строка
2026-01-02 17:37:37 +03:00
Fedor
f7d27388a0 feat: Add SMS debug code modal for dev environment 2025-12-29 10:59:21 +03:00
Fedor
56516fdd7d Add docker-compose.dev.yml for dev environment (ports 5177, 8201) 2025-12-29 10:55:48 +03:00
118 changed files with 16351 additions and 1173 deletions

60
CHANGELOG_MINIAPP.md Normal file
View File

@@ -0,0 +1,60 @@
# Доработки мини-приложения Clientright (TG/MAX и веб)
## Консультации, CRM, кнопка «Назад» (2026-02-25)
### Консультации
- **Страница «Консультации»**: список тикетов из тех же данных, что и «Мои обращения» (общий контекст `DraftsContext`), без отдельного эндпоинта списка.
- По клику на тикет — запрос `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука «подробнее» (`n8n_ticket_form_podrobnee_webhook`), ответ показывается карточкой с полями: заголовок, статус, категория, описание, решение, приоритет (русские подписи).
- Убраны подпись «Тикеты из CRM» и кнопка «Назад к списку» — возврат только через кнопку «Назад» в баре.
- **Кнопка «Назад» в баре на консультациях**: подписка на `miniapp:goBack` в `Consultations.tsx` — в детали тикета возврат к списку, со списка переход на «Мои обращения» (`onNavigate('/')`).
### Поддержка
- **Кнопка «Назад» в баре в чате поддержки**: на маршруте `/support` кнопка «Назад» больше не отключается (`BottomBar.tsx` — убран `isSupport` из условия отключения).
- В `Support.tsx` добавлена подписка на `miniapp:goBack`: в режиме чата — возврат к списку обращений, в списке — переход на «Мои обращения» (`onNavigate('/')`).
### CRM и дашборд
- **n8n**: Code-нода нормализации ответа CRM — из `projects_json` и `tickets_json` формируется массив элементов с полями для фронта (`type_code`, `payload`, `status_code`). Файл `docs/n8n_CODE_CRM_NORMALIZE.js`, на выходе объект с полем `crm_items`.
- **n8n**: разворот ответа в плоский список — `docs/n8n_CODE_FLATTEN_DATA.js` разворачивает элементы вида `{ crm_items: [...] }` в плоский массив в `data`.
- **Дашборд по категориям**: при клике по плиткам («В работе», «Решены» и т.д.) список фильтруется в том числе для элементов из CRM: добавлены `isFromCrm()`, `getItemCategory()` по `status_code`/payload, расширен `STATUS_CONFIG` для `active`/`completed`/`rejected`.
- **Карточка обращения**: у контейнера и Card заданы `width: '100%'` и `boxSizing: 'border-box'` в `StepDraftSelection.tsx`.
### Бэкенд
- В `config.py` — переменная `n8n_ticket_form_podrobnee_webhook` для вебхука «подробнее» по тикету.
- В `config.py` — переменная **`n8n_project_form_podrobnee_webhook`** (`N8N_PROJECT_FORM_PODROBNEE_WEBHOOK` в .env) для вебхука «подробнее» по делу/проекту из CRM (по project_id). Эндпоинт, вызывающий этот хук, пока не добавлен — зарезервировано под будущий case-detail.
- Модуль `backend/app/api/consultations.py`: эндпоинт `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука, ответ как есть.
---
## Системные баннеры на экране приветствия (2026-02)
- **Баннер «Профиль не заполнен»** вынесен в отдельную зону справа от текста «Теперь ты в системе — можно продолжать» (на десктопе — колонка ~260px), чтобы не занимал полстраницы и не сдвигал контент.
- Реализовано **единое место для системных баннеров**: массив `systemBanners`, при одном баннере показывается один Alert, при нескольких — карусель (Ant Design Carousel). В будущем сюда можно добавлять другие критические уведомления.
- **Мобильная вёрстка**: баннер на всю ширину, нормальный перенос текста (без разбиения по слогам), кнопка «Заполнить профиль» переносится под текст, крестик закрытия остаётся в первой строке справа (через `order` и `flex-wrap`).
- **Профиль**: убрана дублирующая ссылка «Домой» из шапки карточки профиля — навигация остаётся через нижний бар.
## UI и навигация
- **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей».
- Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок.
- Список обращений по категориям в виде карточек с hover; фильтр по выбранной категории.
- Кнопка **«Назад»** перенесена в нижний бар; убраны дублирующие кнопки «Назад» из контента (описание, документы).
## Telegram и MAX
- **Выход**: корректное закрытие приложения — в TG вызывается `Telegram.WebApp.close()`, в MAX — `window.WebApp.close()` / `postEvent('web_app_close')`. Определение платформы по initData/URL.
- Подключение скриптов по платформе: при наличии `tgWebAppData`/`tgWebAppVersion` в URL грузится только `telegram-web-app.js`, иначе — только `max-web-app.js` (устранены ошибки UnsupportedEvent в MAX).
- В TG/MAX **не показывается экран ввода телефона** — шаг «Вход» только для обычного веба; раннее определение платформы (опрос `WebApp.initData`), флаг `platformChecked` чтобы не мелькал телефон до определения.
## Сессия и авторизация
- Сессию не сбрасывать при сетевых/временных ошибках `session/verify` — удалять `session_token` только при явном ответе `valid: false`.
- При нажатии «Назад» с авторизованного пользователя не вести на шаг «Вход» — переход на дашборд «Мои обращения» или на `/hello`.
- Переход на «Подать обращение» через роут `/new` и `pushState` для стабильного флоу без возврата на телефон.
## Исправления
- **TDZ-ошибка** (пустой экран после перехода с /hello): `useEffect` для `miniapp:goBack` перенесён после объявления `prevStep` (useCallback).
- Тостер **«Добро пожаловать!»** показывается только в вебе (не в TG/MAX), проверка по `Telegram.WebApp.initData` и `WebApp.initData`.
## Отладка и логи
- Клиентский логгер `miniappLogger`: сбор событий, ошибок, отправка на `POST /api/v1/utils/client-log`; идентификация бандла (build/moduleUrl); очистка логов при смене сборки.
- Бэкенд: приём логов в `main.py`, запись в `logs/cursor-debug-*.log` (NDJSON), без PII.
## Файлы
- Новые: `StepComplaintsDashboard.tsx/.css`, `StepDraftSelection.css`, `miniappLogger.ts`.
- Правки: `ClaimForm.tsx`, `HelloAuth.tsx`, `BottomBar.tsx`, `StepDescription.tsx`, `StepWizardPlan.tsx`, `StepDraftSelection.tsx`, `App.tsx`, `main.tsx`, `index.html`, `main.py`, `api/claims.py`, `ClaimForm.css`, `BottomBar.css`, `Dockerfile.prod`.

View File

@@ -0,0 +1,28 @@
# Изменения: форма профиля, валидация, DaData, банки
## Backend
### auth_universal.py
- Чтение N8N_AUTH_WEBHOOK: fallback на `os.environ.get("N8N_AUTH_WEBHOOK")`, если в config нет поля `n8n_auth_webhook` (чтобы webhook auth_miniapp вызывался при отсутствии config.py на хосте).
### banks.py
- URL списка банков берётся из .env: `BANK_IP` (в config — `bank_ip`), fallback на `bank_api_url` и запасной URL. Прокси запроса к внешнему API для мини-аппа.
### profile.py
- Новый эндпоинт `GET /api/v1/profile/dadata/address?query=...&count=10` — подсказки адресов через DaData API (ключи FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). Ответ: `{ "suggestions": [ { "value", "unrestricted_value" } ] }`.
### config.py
- Добавлены поля: `bank_ip` (BANK_IP), `bank_api_url`; `forma_dadata_api_key`, `forma_dadata_secret` (FORMA_DADATA_*).
## Frontend (Profile.tsx)
- **Дата рождения:** календарь (DatePicker), формат DD.MM.YYYY, нельзя выбрать будущую дату.
- **ИНН:** строго 12 цифр, валидация и ввод только цифр; подсказка «Узнать свой ИНН вы можете здесь» со ссылкой на сервис ФНС (service.nalog.ru).
- **Email:** валидация формата (type: email).
- **Адрес регистрации / Почтовый адрес:** чекбокс «Совпадает с адресом регистрации» — при включении почтовый подставляется и блокируется; оба поля — AutoComplete с подсказками из DaData (запрос к /api/v1/profile/dadata/address).
- **Банк для возмещения:** выпадающий список (Select) с поиском, данные с /api/v1/banks/nspk (API из BANK_IP); учтён формат ответа с полями bankId, bankName (camelCase).
## .env
- BANK_IP — URL API списка банков (например http://212.193.27.93/api/payouts/dictionaries/nspk-banks).
- FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET — ключи DaData для подсказок адресов.

18
CHANGES_AUTH2_HELLO.md Normal file
View File

@@ -0,0 +1,18 @@
## Что изменили
Добавлен **параллельный** (не ломающий текущий) флоу авторизации и приветственная страница:
- **Новый endpoint**: `POST /api/v1/auth2/login`
- **platform=tg**: проверка `init_data` Telegram WebApp → вызов n8n webhook → создание сессии в Redis.
- **platform=max**: проверка `init_data` MAX WebApp → вызов n8n webhook → создание сессии в Redis.
- **platform=sms**: проверка SMS-кода → создание/поиск пользователя через n8n → создание сессии в Redis.
- Ответ включает `greeting` и (для TG/MAX) `avatar_url`, чтобы можно было показать приветствие и аватар.
- **Новая страница**: `GET /hello`
- После авторизации показывает “привет” и плитки в стиле **Soft UI / Modern SaaS** (Ant Design + Lucide outline icons).
- Текущий основной UI/роуты/эндпоинты не менялись — это отдельная ветка для новой архитектуры.
## Зачем
Чтобы развивать новую архитектуру входа и “кабинет” **параллельно** со старым флоу, без риска что-то сломать.

View File

@@ -0,0 +1,5 @@
Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK
- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.

View File

@@ -0,0 +1,8 @@
Session: дублировать сессии во внешний Redis для доступа из n8n
- backend/app/api/session.py: при записи сессии в локальный Redis (6383) теперь также дублируем те же ключи
в внешний Redis (REDIS_HOST/REDIS_PORT) через redis_service.client.
- Дублируются оба вида ключей:
- session:{channel}:{channel_user_id}
- session:{session_token}
- Ошибки внешнего Redis не ломают авторизацию: при недоступности — warning в логах.

View File

@@ -0,0 +1,4 @@
Support: маршрут /support на страницу чата поддержки
- App.tsx: добавлен импорт страницы Support и роутинг pathname === '/support' на компонент Support.
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».

137
CURRENT_SETUP.md Normal file
View File

@@ -0,0 +1,137 @@
# 📍 Текущая структура запущенных окружений
**Дата проверки:** 2 января 2025
---
## 🟢 DEV окружение (запущено)
**Рабочая папка:**
```
/var/www/fastuser/data/www/crm.clientright.ru/aiform_dev/
```
**Контейнеры:**
- `aiform_frontend_dev` → порт **5177** → http://147.45.146.17:5177/
- `aiform_backend_dev` → порт **8201**
**Docker Compose:**
- Файл: `aiform_dev/docker-compose.dev.yml`
- Запуск: `cd aiform_dev && docker-compose -f docker-compose.dev.yml up -d`
**Монтированные папки:**
- Frontend: `aiform_dev/frontend/src``/app/src` (read-only, для live reload)
- Backend: использует `aiform_dev/backend/.env`
**Git репозиторий:**
- Remote: `aiform_dev` → http://147.45.146.17:3002/negodiy/aiform_dev.git
---
## 🔴 PROD окружение (запущено)
**Рабочая папка:**
```
/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/
```
**Контейнеры:**
- `ticket_form_frontend_prod` → порт **5176** → https://aiform.clientright.ru/
- `ticket_form_backend` → порт **8200** (network_mode: host)
**Docker Compose:**
- Файл: `ticket_form/docker-compose.prod.yml` (новый) или старый `docker-compose.yml`
- Запуск: `cd ticket_form && docker-compose -f docker-compose.prod.yml up -d`
**Git репозиторий:**
- Remote: `aiform_prod` → http://147.45.146.17:3002/negodiy/aiform_prod.git
- Remote: `origin` → http://147.45.146.17:3002/negodiy/erv-platform.git
---
## 📊 Сравнение
| | DEV | PROD |
|---|---|---|
| **Папка** | `/aiform_dev/` | `/ticket_form/` |
| **Frontend порт** | 5177 | 5176 |
| **Backend порт** | 8201 | 8200 |
| **URL** | http://147.45.146.17:5177/ | https://aiform.clientright.ru/ |
| **Docker Compose** | `aiform_dev/docker-compose.dev.yml` | `ticket_form/docker-compose.prod.yml` |
| **Git** | `aiform_dev` | `aiform_prod` |
---
## 🔄 Как переносить изменения
### Из DEV в PROD:
```bash
# 1. Работаете в DEV папке
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
# Вносите изменения, тестируете
# 2. Копируете изменения в PROD папку (или через git)
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
git pull aiform_prod main # или копируете файлы вручную
# 3. Перезапускаете PROD
docker-compose -f docker-compose.prod.yml up -d --build
```
### Или через git (рекомендуется):
```bash
# В DEV папке
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
git add .
git commit -m "feat: Описание"
git push aiform_dev main
# В PROD папке
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
git pull aiform_prod main
docker-compose -f docker-compose.prod.yml up -d --build
```
---
## ⚠️ Важно
1. **DEV и PROD — это разные папки:**
- DEV: `/aiform_dev/`
- PROD: `/ticket_form/`
2. **Изменения в DEV не попадают в PROD автоматически** — нужно копировать/пушить через git
3. **У каждого окружения свой `.env` файл:**
- DEV: `aiform_dev/backend/.env`
- PROD: `ticket_form/.env`
---
## 🛠️ Полезные команды
```bash
# Проверить статус DEV
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
docker-compose -f docker-compose.dev.yml ps
# Проверить статус PROD
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
docker-compose -f docker-compose.prod.yml ps
# Логи DEV
docker logs aiform_frontend_dev -f
docker logs aiform_backend_dev -f
# Логи PROD
docker logs ticket_form_frontend_prod -f
docker logs ticket_form_backend -f
```
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

203
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,203 @@
# 🚀 Руководство по деплою: DEV → PROD
## 📍 Текущая структура
- **DEV:** http://147.45.146.17:5177/ (папка `aiform_dev/`)
- **PROD:** https://aiform.clientright.ru/ (домен продакшна)
---
## 🎯 Быстрый перенос изменений (1 команда)
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
./deploy-to-prod.sh
```
Этот скрипт:
1. ✅ Проверит незакоммиченные изменения
2. ✅ Отправит код в git репозитории (dev и prod)
3. ✅ Пересоберёт PROD контейнеры
4. ✅ Перезапустит PROD окружение
---
## 📝 Пошаговый процесс (вручную)
### Шаг 1: Сохранить изменения в git
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Проверить что изменилось
git status
# Добавить изменения
git add .
# Закоммитить
git commit -m "feat: Описание изменений"
# Отправить в dev репозиторий
git push aiform_dev main # или master
```
### Шаг 2: Отправить в prod репозиторий
```bash
# Отправить в prod
git push aiform_prod main # или master
```
### Шаг 3: Обновить PROD контейнеры
```bash
# Пересобрать
docker-compose -f docker-compose.prod.yml build
# Перезапустить
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
---
## 🔧 Про .env файл
### Почему один .env, а не два?
**✅ Преимущества одного .env:**
- Проще поддерживать (один файл вместо двух)
- Меньше путаницы
- Режим переключается через переменную `APP_ENV` в docker-compose
**Как это работает:**
В `docker-compose.dev.yml`:
```yaml
environment:
- APP_ENV=development # Переопределяет значение из .env
- DEBUG=true
```
В `docker-compose.prod.yml`:
```yaml
environment:
- APP_ENV=production # Переопределяет значение из .env
- DEBUG=false
```
**Ваш `.env` файл остаётся один**, но docker-compose переопределяет нужные переменные для каждого окружения.
---
## 📊 Структура репозиториев
```
Gitea (http://147.45.146.17:3002/negodiy):
├─ aiform_dev → DEV версия (http://147.45.146.17:5177/)
├─ aiform_prod → PROD версия (https://aiform.clientright.ru/)
└─ erv-platform → Основной репозиторий
```
**Локальные папки:**
- `/var/www/.../aiform_dev/` → DEV окружение
- `/var/www/.../ticket_form/` → Основной проект (может быть и DEV и PROD)
---
## 🔄 Типичный workflow
### 1. Разработка в DEV
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
# или
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Вносите изменения
# Тестируете на http://147.45.146.17:5177/
```
### 2. Когда готово → деплой в PROD
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Вариант 1: Автоматический (рекомендуется)
./deploy-to-prod.sh
# Вариант 2: Вручную
git add .
git commit -m "feat: Описание"
git push aiform_prod main
docker-compose -f docker-compose.prod.yml up -d --build
```
### 3. Проверка PROD
```bash
# Проверить статус
docker-compose -f docker-compose.prod.yml ps
# Проверить логи
docker-compose -f docker-compose.prod.yml logs -f
# Открыть в браузере
# https://aiform.clientright.ru/
```
---
## ⚠️ Важные моменты
1. **Всегда тестируйте в DEV перед деплоем в PROD**
2. **Проверяйте `.env` файл** — убедитесь что там правильные настройки
3. **В PROD `APP_ENV=production` и `DEBUG=false`** (устанавливается через docker-compose)
4. **Не коммитьте `.env`** — он в `.gitignore`
5. **После деплоя проверяйте логи**`docker-compose -f docker-compose.prod.yml logs`
---
## 🐛 Откат изменений (если что-то пошло не так)
```bash
# Откатить к предыдущему коммиту
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
git log --oneline -5 # Найти нужный коммит
git checkout <commit-hash>
git push aiform_prod main --force
# Пересобрать
docker-compose -f docker-compose.prod.yml up -d --build
```
---
## 📞 Полезные команды
```bash
# Статус контейнеров
docker ps | grep aiform
# Логи DEV
docker logs aiform_frontend_dev -f
docker logs aiform_backend_dev -f
# Логи PROD
docker logs ticket_form_frontend_prod -f
docker logs ticket_form_backend_prod -f
# Перезапуск PROD
docker-compose -f docker-compose.prod.yml restart
# Полная пересборка PROD
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d --build
```
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

23
DOCKER-COMPOSE-README.md Normal file
View File

@@ -0,0 +1,23 @@
# Docker Compose в этом каталоге
**Для сайта miniapp.clientright.ru используется один compose — в корне репозитория:**
```
/var/www/fastuser/data/www/miniapp.clientright.ru/docker-compose.yml
```
Он поднимает: `miniapp_frontend` (5179), `miniapp_backend` (8205), `miniapp_redis` (6383).
Запуск из корня: `docker compose up -d`.
---
Файлы в **aiform_prod/**:
| Файл | Назначение | Порты |
|------|------------|--------|
| `docker-compose.yml` | Старый стек (ticket_form_*), не для miniapp.clientright.ru | 5175, host |
| `docker-compose.prod.yml` | Другой прод (miniapp_front/back на 4176), не для miniapp.clientright.ru | 4176 |
| `docker-compose.dev.yml` | Дев aiform (aiform_frontend_dev, aiform_backend_dev) | 5177, 8201 |
| `docker-compose.full.yml` | Полный стек ERV (postgres, redis, pgadmin и т.д.) | 8100, 5173, … |
Их можно не поднимать для работы miniapp.clientright.ru. Оставлены для истории/других окружений.

264
ENVIRONMENTS.md Normal file
View File

@@ -0,0 +1,264 @@
# 🚀 Руководство по DEV и PROD окружениям
## 📋 Обзор
Проект поддерживает два отдельных окружения:
- **DEV** (Development) — для разработки и тестирования
- **PROD** (Production) — для продакшна
---
## 🏗️ Структура файлов
```
ticket_form/
├─ docker-compose.dev.yml ← Конфигурация для разработки
├─ docker-compose.prod.yml ← Конфигурация для продакшна
├─ .env.dev ← Переменные окружения для DEV
├─ .env.prod ← Переменные окружения для PROD
├─ .env.example ← Шаблон переменных окружения
├─ start-dev.sh ← Скрипт запуска DEV
├─ start-prod.sh ← Скрипт запуска PROD
└─ ENVIRONMENTS.md ← Эта документация
```
---
## 🛠️ Быстрый старт
### 1. Первоначальная настройка
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Создаём .env файлы из шаблона
cp .env.example .env.dev
cp .env.example .env.prod
# Редактируем .env.dev (для разработки)
nano .env.dev
# Установите: APP_ENV=development, DEBUG=true
# Редактируем .env.prod (для продакшна)
nano .env.prod
# Установите: APP_ENV=production, DEBUG=false
# Проверьте все URL и API ключи
```
### 2. Запуск DEV окружения
```bash
# Вариант 1: Используя скрипт (рекомендуется)
./start-dev.sh
# Вариант 2: Вручную
docker-compose -f docker-compose.dev.yml up -d --build
```
**Доступ:**
- Frontend: http://localhost:5175
- Backend: http://localhost:8200
- API Docs: http://localhost:8200/docs
### 3. Запуск PROD окружения
```bash
# Вариант 1: Используя скрипт (рекомендуется)
./start-prod.sh
# Вариант 2: Вручную
docker-compose -f docker-compose.prod.yml up -d --build
```
**Доступ:**
- Frontend: http://localhost:5176
- Backend: http://localhost:8200
- API Docs: http://localhost:8200/docs
---
## 🔍 Различия между DEV и PROD
| Параметр | DEV | PROD |
|----------|-----|------|
| **Порты** | 5175 (frontend), 8200 (backend) | 5176 (frontend), 8200 (backend) |
| **Контейнеры** | `*_dev` | `*_prod` |
| **PostgreSQL** | Локальный контейнер (порт 5433) | Внешний (147.45.189.234:5432) |
| **Redis** | Локальный контейнер (порт 6380) | Системный (localhost:6379) |
| **Debug** | ✅ Включен | ❌ Выключен |
| **Логи** | DEBUG уровень | INFO уровень |
| **Hot Reload** | ✅ Включен | ❌ Выключен |
| **Build** | Dev режим | Production оптимизация |
| **Healthcheck** | ❌ Нет | ✅ Есть |
---
## 📝 Управление окружениями
### Остановка
```bash
# Остановить DEV
docker-compose -f docker-compose.dev.yml down
# Остановить PROD
docker-compose -f docker-compose.prod.yml down
```
### Просмотр логов
```bash
# Логи DEV
docker-compose -f docker-compose.dev.yml logs -f
# Логи PROD
docker-compose -f docker-compose.prod.yml logs -f
# Логи конкретного сервиса
docker-compose -f docker-compose.dev.yml logs -f ticket_form_backend_dev
```
### Перезапуск
```bash
# Перезапуск DEV
docker-compose -f docker-compose.dev.yml restart
# Перезапуск PROD
docker-compose -f docker-compose.prod.yml restart
```
### Пересборка
```bash
# Пересборка DEV
docker-compose -f docker-compose.dev.yml up -d --build
# Пересборка PROD
docker-compose -f docker-compose.prod.yml up -d --build
```
---
## 🔐 Переменные окружения
### Основные переменные
| Переменная | DEV значение | PROD значение |
|------------|--------------|---------------|
| `APP_ENV` | `development` | `production` |
| `DEBUG` | `true` | `false` |
| `LOG_LEVEL` | `DEBUG` | `INFO` |
| `VITE_API_URL` | `http://localhost:8200` | `https://aiform.clientright.ru/api` |
| `NODE_ENV` | `development` | `production` |
### Базы данных
**DEV:**
- PostgreSQL: `ticket_form_postgres_dev` (контейнер, порт 5433)
- Redis: `ticket_form_redis_dev` (контейнер, порт 6380)
**PROD:**
- PostgreSQL: `147.45.189.234:5432` (внешний)
- Redis: `localhost:6379` (системный)
- MySQL: `localhost:3306` (системный)
---
## 🐛 Отладка
### Проверка статуса
```bash
# Статус DEV контейнеров
docker-compose -f docker-compose.dev.yml ps
# Статус PROD контейнеров
docker-compose -f docker-compose.prod.yml ps
# Все контейнеры проекта
docker ps | grep ticket_form
```
### Проверка подключений
```bash
# Проверка backend health
curl http://localhost:8200/health
# Проверка frontend
curl http://localhost:5175
# Проверка PostgreSQL (DEV)
docker exec -it ticket_form_postgres_dev psql -U erv_user -d erv_db_dev
# Проверка Redis (DEV)
docker exec -it ticket_form_redis_dev redis-cli -a redis_dev_pass ping
```
---
## 📦 Git репозитории
### Структура репозиториев
- **`erv-platform`** (origin) — основной репозиторий
- **`aiform_prod`** — production версия
- **`aiform_dev`** — development версия (в папке `aiform_dev/`)
### Работа с Git
```bash
# Push в основной репозиторий
git push origin main
# Push в prod репозиторий
git push aiform_prod main
# Push в оба
git push origin main && git push aiform_prod main
```
---
## ⚠️ Важные замечания
1. **Никогда не коммитьте `.env.dev` и `.env.prod`** — они в `.gitignore`
2. **Всегда проверяйте `.env.prod`** перед деплоем в продакшн
3. **DEV и PROD могут работать одновременно** на разных портах
4. **В PROD используйте внешние БД** — не создавайте локальные контейнеры
5. **Healthcheck в PROD** — проверяйте статус регулярно
---
## 🔄 Миграция с текущей структуры
Если у вас уже запущены контейнеры со старыми именами:
```bash
# Остановите старые контейнеры
docker stop ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
# Удалите старые контейнеры (опционально)
docker rm ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
# Запустите новые через скрипты
./start-dev.sh
./start-prod.sh
```
---
## 📞 Поддержка
При проблемах:
1. Проверьте логи: `docker-compose -f docker-compose.*.yml logs`
2. Проверьте статус: `docker-compose -f docker-compose.*.yml ps`
3. Проверьте `.env` файлы на корректность
4. Убедитесь, что порты не заняты: `netstat -tulpn | grep -E "5175|5176|8200"`
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

99
GIT_STATUS.md Normal file
View File

@@ -0,0 +1,99 @@
# 📊 Статус Git репозитория DEV
**Дата проверки:** 2 января 2025
---
## 📅 Последний коммит
**Дата:** 29 декабря 2025, 10:59:21
**Автор:** Fedor (fedor@clientright.ru)
**Сообщение:** `feat: Add SMS debug code modal for dev environment`
**Хеш:** `f7d27388a0b62380e4f1bdeba3c997f50ff10587`
---
## ⚠️ Незакоммиченные изменения
**Всего изменено файлов:** 9
### Backend (4 файла):
- `backend/app/api/n8n_proxy.py` - изменён
- `backend/app/api/sms.py` - изменён
- `backend/app/config.py` - изменён
- `backend/app/services/sms_service.py` - изменён
### Frontend (5 файлов):
- `frontend/src/components/form/Step1Phone.tsx` - изменён
- `frontend/src/components/form/Step3Payment.tsx` - изменён
- `frontend/src/components/form/generateConfirmationFormHTML.ts` - изменён
- `frontend/src/pages/ClaimForm.tsx` - изменён
- `frontend/vite.config.ts` - изменён
**Статистика изменений:**
- Добавлено: ~242 строки
- Удалено: ~81 строка
- Чистое изменение: +161 строка
---
## 📤 Статус с remote
**Ветка:** `main`
**Remote:** `origin/main`
**Статус:** Есть локальные изменения, которые не запушены в remote
**Не запушенные изменения:**
- `backend/app/api/n8n_proxy.py`
- `backend/app/api/sms.py`
- `backend/app/config.py`
- `backend/app/services/sms_service.py`
---
## 🔄 История коммитов (последние 5)
1. **2025-12-29** - `feat: Add SMS debug code modal for dev environment`
2. **2025-12-29** - `Add docker-compose.dev.yml for dev environment (ports 5177, 8201)`
3. **2025-12-29** - `docs: Move session log to root`
4. **2025-12-29** - `Add session log 2025-12-29`
5. **2025-12-29** - `Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons`
---
## 💡 Рекомендации
### 1. Закоммитить изменения
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
# Посмотреть что изменилось
git diff
# Добавить все изменения
git add .
# Закоммитить
git commit -m "feat: Описание изменений"
```
### 2. Запушить в remote
```bash
# Отправить в dev репозиторий
git push origin main
# Или если remote называется aiform_dev
git push aiform_dev main
```
### 3. Перенести в PROD (если нужно)
После коммита и пуша, можно перенести изменения в PROD папку.
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

224
README.md
View File

@@ -1,173 +1,163 @@
# 🚀 Ticket Form Intake Platform
# AiForm — платформа приёма обращений о защите прав потребителя
**Платформа цифровой приёмки обращений для other.clientright.ru**
- **Backend**: Python FastAPI (async)
- **Frontend**: React 18 + TypeScript
- **Database**: PostgreSQL + MySQL + Redis
- **Queue**: RabbitMQ
- **Storage**: S3 Timeweb Cloud
Веб-форма и Telegram Mini App для подачи обращений о защите прав потребителя. Интеграция с CRM (vTiger), n8n, SMS-верификация и авторизация через Telegram.
---
## 🎯 Быстрый старт
## Назначение
### 📍 **Визуальный доступ:**
После запуска доступны по адресам:
```
Frontend (форма):
http://147.45.146.17:5175/
Backend API:
http://147.45.146.17:8200/
API Документация (Swagger UI):
http://147.45.146.17:8200/docs ← Интерактивная!
Gitea (Git репозиторий):
http://147.45.146.17:3002/
```
- **Веб:** форма на https://aiform.clientright.ru — описание проблемы, загрузка документов, подтверждение заявления.
- **Telegram Mini App:** тот же функционал внутри бота (@klientprav_bot) с авторизацией по Telegram (без SMS при первом заходе).
- Черновики, восстановление сессии, статусы заявок (черновик, в работе, готово).
---
## 🔧 Установка и запуск
## Стек
### **Backend (FastAPI):**
| Компонент | Технология |
|-----------|------------|
| **Backend** | Python 3.11, FastAPI (async) |
| **Frontend** | React 18, TypeScript, Vite, Ant Design |
| **БД** | PostgreSQL, MySQL (полисы/CRM), Redis (сессии) |
| **Очереди** | RabbitMQ |
| **Хранилище** | S3 (Timeweb Cloud) |
| **Автоматизация** | n8n (воркфлоу, вебхуки) |
| **Интеграции** | Telegram Mini App SDK, SMS (сервис провайдера) |
---
## Быстрый старт
### Локальная разработка
**Backend:**
```bash
cd backend
# Создаём виртуальное окружение
python3 -m venv venv
source venv/bin/activate
# Устанавливаем зависимости
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
# Запускаем сервер
uvicorn app.main:app --reload --host 0.0.0.0 --port 8200
```
### **Frontend (React):**
**Frontend:**
```bash
cd frontend
# Устанавливаем зависимости
npm install
# Запускаем dev сервер
npm run dev -- --host 0.0.0.0 --port 5175
```
---
Форма: http://localhost:5175
API: http://localhost:8200
Swagger: http://localhost:8200/docs
## 📊 Архитектура
### Продакшн (Docker)
### **Поток данных:**
```bash
# Сборка и запуск
docker-compose -f docker-compose.prod.yml up -d --build
```
React (5175) → FastAPI (8200) → [Redis, RabbitMQ, PostgreSQL]
OCR Service (8001)
OpenRouter AI
FlightAware API
PHP Bridge → Vtiger CRM
# Или скрипт деплоя (git push + пересборка)
./deploy-to-prod.sh
```
### **Что НЕ трогаем:**
- **Frontend (prod):** порт 5176 → внутри 3000
- **Backend (prod):** network_mode: host, порт 8200
✅ CRM Vtiger (работает как работала)
✅ MySQL полисы (только READ)
✅ Существующий PHP код
Подробнее: [DEPLOYMENT.md](DEPLOYMENT.md).
---
## 🗄️ Базы данных
## Telegram Mini App
| База | Назначение | Хост |
|------|------------|------|
| PostgreSQL | Логи, метрики, новые данные | 147.45.189.234:5432 |
| MySQL | Проверка полисов (READ) | localhost:3306 |
| Redis | Кеш, Rate Limiting | localhost:6379 |
- Открытие формы из бота → загрузка aiform.clientright.ru в WebView.
- Фронт определяет контекст (URL/referrer/user-agent) и подгружает `telegram-web-app.js` только в Mini App.
- При наличии `initData` вызывается `POST /api/v1/tg/auth`; бэкенд проверяет подпись, дергает n8n, создаёт сессию в Redis и возвращает `session_token`, `unified_id`, `has_drafts`.
- В Mini App: компактный скин, заявки «В работе» скрыты, кнопка «Выход» закрывает приложение. В вебе для заявок «В работе» — кнопка «Просмотреть в Telegram» (ссылка на @klientprav_bot).
Подробнее: [docs/TELEGRAM_MINIAPP_FLOW.md](docs/TELEGRAM_MINIAPP_FLOW.md).
---
## 📁 Структура проекта
## Основные API (v1)
| Метод | Путь | Назначение |
|-------|------|------------|
| POST | `/api/v1/tg/auth` | Авторизация по Telegram initData |
| POST | `/api/v1/session/verify` | Проверка сессии по session_token |
| POST | `/api/v1/session/logout` | Выход, удаление сессии из Redis |
| POST | `/api/v1/sms/send` | Отправка SMS-кода |
| POST | `/api/v1/sms/verify` | Проверка SMS-кода |
| GET | `/api/v1/claims/drafts/list` | Список черновиков (по unified_id / phone / session_id) |
| GET | `/api/v1/claims/drafts/{claim_id}` | Данные черновика |
| DELETE | `/api/v1/claims/drafts/{claim_id}` | Удаление черновика (не для in_work) |
| POST | `/api/v1/claims/wizard` | Получение плана (wizard) по описанию |
| POST | `/api/v1/claims/approve` | Подтверждение заявления (отправка в обработку) |
| POST | `/api/v1/documents/upload` | Загрузка документа |
| GET | `/api/v1/events/claim-plan/{session_token}` | SSE: данные заявления (claim:plan) |
Полный список — в Swagger: `/docs`.
---
## Структура проекта
```
ticket_form/
├─ backend/ ← Python FastAPI
│ ├─ app/
│ │ ├─ main.py
│ │ ├─ api/
│ │ ├─ services/
│ │ └─ models/
─ requirements.txt
├─ frontend/ ← React TypeScript
│ ├─ src/
│ │ ├─ components/
│ │ ├─ pages/
│ │ └─ api/
─ package.json
└─ .env ← Конфигурация
├─ backend/ # FastAPI
│ ├─ app/
│ │ ├─ main.py
│ │ ├── config.py
│ │ ├── api/ # Роутеры (claims, sms, session, telegram_auth, n8n_proxy, documents, …)
│ │ └── services/
├── requirements.txt
└── Dockerfile
├─ frontend/ # React + Vite
│ ├─ src/
│ │ ├── pages/ # ClaimForm.tsx — основная форма
│ │ ├── components/form/ # Step1Phone, StepDraftSelection, StepWizardPlan, …
│ │ └── ...
├── package.json
└── Dockerfile.prod
├── docker-compose.prod.yml
├── deploy-to-prod.sh
├── .env # Не в git: TELEGRAM_BOT_TOKEN, N8N_*, БД, Redis, S3
├── README.md # Этот файл
├── DEPLOYMENT.md # Деплой DEV → PROD
├── ENVIRONMENTS.md # Переменные окружения
└── docs/
├── TELEGRAM_MINIAPP_FLOW.md
└── ... # Доп. документация по n8n, OCR, CRM
```
---
## 🔌 API Endpoints
## Окружения и конфиг
### **Документы:**
- `POST /api/v1/documents/upload` - Загрузка в S3
- `POST /api/v1/documents/scan` - OCR + Vision
- **DEV:** форма на 5177, бэкенд на 8201 (см. docker-compose.dev.yml при наличии).
- **PROD:** https://aiform.clientright.ru (frontend за nginx, backend на 8200).
### **Рейсы:**
- `GET /api/v1/flights/check` - Проверка статуса
### **Обращения:**
- `POST /api/v1/claims/submit` - Создание обращения
### **Полисы:**
- `GET /api/v1/policies/verify` - Проверка полиса
Переменные окружения: [ENVIRONMENTS.md](ENVIRONMENTS.md). Критично: `TELEGRAM_BOT_TOKEN`, `N8N_TG_AUTH_WEBHOOK`, БД, Redis, S3.
---
## 🐛 Отладка
## Git
### **Логи:**
```bash
# FastAPI
tail -f backend/logs/app.log
# PostgreSQL логи
SELECT * FROM logs ORDER BY created_at DESC LIMIT 50;
```
- **Репозиторий (prod):** http://147.45.146.17:3002/negodiy/aiform_prod.git
- Клонирование: `git clone http://147.45.146.17:3002/negodiy/aiform_prod.git`
- После изменений: `git add . && git commit -m "feat: описание" && git push`
- Деплой на сервер: `./deploy-to-prod.sh` (при необходимости).
---
## 📝 Git
## Документация
```bash
# Репозиторий
http://147.45.146.17:3002/negodiy/erv-platform
# Клонирование
git clone http://147.45.146.17:3002/negodiy/erv-platform.git
# Push изменений
git add .
git commit -m "Your message"
git push origin main
```
| Файл | Содержание |
|------|------------|
| [DEPLOYMENT.md](DEPLOYMENT.md) | Деплой, скрипты, .env |
| [ENVIRONMENTS.md](ENVIRONMENTS.md) | Переменные окружения |
| [docs/TELEGRAM_MINIAPP_FLOW.md](docs/TELEGRAM_MINIAPP_FLOW.md) | Поток Telegram Mini App и tg/auth |
---
**Автор**: AI Assistant + Фёдор
**Дата**: 24.10.2025
**Автор:** Фёдор + AI Assistant
**Обновлено:** январь 2026

94
README_ENVIRONMENTS.md Normal file
View File

@@ -0,0 +1,94 @@
# 🚀 Быстрый старт: DEV и PROD окружения
## 📦 Что создано
`docker-compose.dev.yml` - конфигурация для разработки
`docker-compose.prod.yml` - конфигурация для продакшна
`start-dev.sh` - скрипт запуска DEV
`start-prod.sh` - скрипт запуска PROD
`.env.example` - шаблон переменных окружения
`ENVIRONMENTS.md` - полная документация
---
## 🎯 Быстрый старт (3 шага)
### Шаг 1: Создайте .env файлы
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Создаём из шаблона
cp .env.example .env.dev
cp .env.example .env.prod
# Редактируем DEV
nano .env.dev
# Установите: APP_ENV=development, DEBUG=true
# Редактируем PROD
nano .env.prod
# Установите: APP_ENV=production, DEBUG=false
# Проверьте все URL и ключи!
```
### Шаг 2: Запустите DEV
```bash
./start-dev.sh
```
**Доступ:** http://localhost:5175
### Шаг 3: Запустите PROD (когда готово)
```bash
./start-prod.sh
```
**Доступ:** http://localhost:5176
---
## 📊 Основные команды
```bash
# Остановить DEV
docker-compose -f docker-compose.dev.yml down
# Остановить PROD
docker-compose -f docker-compose.prod.yml down
# Логи DEV
docker-compose -f docker-compose.dev.yml logs -f
# Логи PROD
docker-compose -f docker-compose.prod.yml logs -f
# Статус
docker-compose -f docker-compose.dev.yml ps
docker-compose -f docker-compose.prod.yml ps
```
---
## 🔍 Различия
| | DEV | PROD |
|---|---|---|
| **Порты** | 5175, 8200 | 5176, 8200 |
| **PostgreSQL** | Локальный контейнер | Внешний (147.45.189.234) |
| **Redis** | Локальный контейнер | Системный (localhost) |
| **Debug** | ✅ Включен | ❌ Выключен |
| **Hot Reload** | ✅ Да | ❌ Нет |
---
## 📖 Полная документация
Смотрите `ENVIRONMENTS.md` для детальной информации.
---
**Всё готово к работе!** 🎉

View File

@@ -14,8 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Открываем порт
EXPOSE 8200
EXPOSE 4200
# Запускаем приложение
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8200"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4200"]

256
backend/app/api/auth2.py Normal file
View File

@@ -0,0 +1,256 @@
"""
Alternative auth endpoint (tg/max/sms) without touching existing flow.
/api/v1/auth2/login:
- platform=tg|max|sms
- Validates init_data for TG/MAX and calls n8n webhook
- For SMS: verifies code, calls n8n contact webhook, creates session
- Returns greeting message
"""
import logging
from typing import Optional, Literal, Any, Dict
from fastapi import APIRouter, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from ..services.sms_service import sms_service
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
from ..services.max_auth import extract_max_user, MaxAuthError
from ..config import settings
from . import n8n_proxy
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth2", tags=["auth2"])
class Auth2LoginRequest(BaseModel):
platform: Literal["tg", "max", "sms"]
init_data: Optional[str] = None
phone: Optional[str] = None
code: Optional[str] = None
session_token: Optional[str] = None
form_id: str = "ticket_form"
class Auth2LoginResponse(BaseModel):
success: bool
greeting: str
session_token: Optional[str] = None
unified_id: Optional[str] = None
contact_id: Optional[str] = None
phone: Optional[str] = None
has_drafts: Optional[bool] = None
need_contact: Optional[bool] = None
avatar_url: Optional[str] = None
def _generate_session_token() -> str:
import uuid
return f"sess-{uuid.uuid4()}"
@router.post("/login", response_model=Auth2LoginResponse)
async def login(request: Auth2LoginRequest):
platform = request.platform
logger.info("[AUTH2] login: platform=%s", platform)
if platform == "tg":
if not request.init_data:
raise HTTPException(status_code=400, detail="init_data обязателен для tg")
try:
tg_user = extract_telegram_user(request.init_data)
except TelegramAuthError as e:
raise HTTPException(status_code=400, detail=str(e))
session_token = request.session_token or _generate_session_token()
n8n_payload = {
"telegram_user_id": tg_user["telegram_user_id"],
"username": tg_user.get("username"),
"first_name": tg_user.get("first_name"),
"last_name": tg_user.get("last_name"),
"session_token": session_token,
"form_id": request.form_id,
"init_data": request.init_data,
}
class _DummyRequest:
def __init__(self, payload: Dict[str, Any]):
self._payload = payload
async def json(self):
return self._payload
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
_result = n8n_data.get("result")
_result_dict = _result if isinstance(_result, dict) else {}
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
if need_contact:
logger.info("[AUTH2] TG: n8n need_contact — возвращаем need_contact=true")
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
phone = n8n_data.get("phone") or _result_dict.get("phone")
has_drafts = n8n_data.get("has_drafts")
if not unified_id:
logger.info("[AUTH2] TG: n8n не вернул unified_id — возвращаем need_contact=true")
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
await session_api.create_session(session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
chat_id=str(tg_user["telegram_user_id"]) if tg_user.get("telegram_user_id") is not None else None,
))
first_name = tg_user.get("first_name") or ""
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
return Auth2LoginResponse(
success=True,
greeting=greeting,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone,
has_drafts=has_drafts,
avatar_url=tg_user.get("photo_url") or None,
)
if platform == "max":
if not request.init_data:
raise HTTPException(status_code=400, detail="init_data обязателен для max")
try:
max_user = extract_max_user(request.init_data)
except MaxAuthError as e:
raise HTTPException(status_code=400, detail=str(e))
session_token = request.session_token or _generate_session_token()
n8n_payload = {
"max_user_id": max_user["max_user_id"],
"username": max_user.get("username"),
"first_name": max_user.get("first_name"),
"last_name": max_user.get("last_name"),
"session_token": session_token,
"form_id": request.form_id,
"init_data": request.init_data,
}
class _DummyRequest:
def __init__(self, payload: Dict[str, Any]):
self._payload = payload
async def json(self):
return self._payload
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
_result = n8n_data.get("result")
_result_dict = _result if isinstance(_result, dict) else {}
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
if need_contact:
logger.info("[AUTH2] MAX: n8n need_contact — возвращаем need_contact=true")
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
phone = n8n_data.get("phone") or _result_dict.get("phone")
has_drafts = n8n_data.get("has_drafts")
if not unified_id:
logger.info("[AUTH2] MAX: n8n не вернул unified_id — возвращаем need_contact=true")
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
await session_api.create_session(session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
chat_id=str(max_user["max_user_id"]) if max_user.get("max_user_id") is not None else None,
))
first_name = max_user.get("first_name") or ""
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
return Auth2LoginResponse(
success=True,
greeting=greeting,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone,
has_drafts=has_drafts,
avatar_url=max_user.get("photo_url") or None,
)
if platform == "sms":
phone = (request.phone or "").strip()
code = (request.code or "").strip()
if not phone or not code:
raise HTTPException(status_code=400, detail="phone и code обязательны для sms")
is_valid = await sms_service.verify_code(phone, code)
if not is_valid:
raise HTTPException(status_code=400, detail="Неверный код или код истек")
class _DummyRequest:
def __init__(self, payload: Dict[str, Any]):
self._payload = payload
async def json(self):
return self._payload
n8n_payload = {
"phone": phone,
"session_id": request.session_token or "",
"form_id": request.form_id,
}
n8n_response = await n8n_proxy.proxy_create_contact(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
if isinstance(n8n_data, list) and n8n_data:
n8n_data = n8n_data[0]
if not n8n_data or not isinstance(n8n_data, dict) or not n8n_data.get("success"):
raise HTTPException(status_code=500, detail="Ошибка создания контакта в n8n")
result = n8n_data.get("result") or n8n_data
unified_id = result.get("unified_id")
contact_id = result.get("contact_id")
phone_res = result.get("phone") or phone
has_drafts = result.get("has_drafts")
session_token = result.get("session") or request.session_token or _generate_session_token()
if not unified_id:
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
await session_api.create_session(session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone_res or "",
contact_id=contact_id or "",
ttl_hours=24,
))
return Auth2LoginResponse(
success=True,
greeting="Привет!",
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone_res,
has_drafts=has_drafts,
avatar_url=None,
)
raise HTTPException(status_code=400, detail="Неподдерживаемая платформа")

View File

@@ -0,0 +1,264 @@
"""
Универсальный auth: один endpoint для TG и MAX.
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
"""
import logging
import os
import uuid
from typing import Optional, Any, Dict, Union
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..config import settings
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
from ..services.max_auth import extract_max_user, MaxAuthError
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth", tags=["auth-universal"])
class AuthUniversalRequest(BaseModel):
channel: str # tg | max
init_data: str
class AuthUniversalResponse(BaseModel):
success: bool
need_contact: Optional[bool] = None
message: Optional[str] = None
session_token: Optional[str] = None
unified_id: Optional[str] = None
phone: Optional[str] = None
contact_id: Optional[str] = None
has_drafts: Optional[bool] = None
need_profile_confirm: Optional[bool] = None
profile_needs_attention: Optional[bool] = None
def _to_bool(v: Any) -> Optional[bool]:
if v is None:
return None
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
if v == 1:
return True
if v == 0:
return False
if isinstance(v, str):
s = v.strip().lower()
if s in ("1", "true", "yes", "y", "да"):
return True
if s in ("0", "false", "no", "n", "нет", ""):
return False
return None
@router.post("", response_model=AuthUniversalResponse)
async def auth_universal(request: AuthUniversalRequest):
"""
Универсальная авторизация: channel (tg|max) + init_data.
Валидируем init_data, получаем channel_user_id, вызываем N8N_AUTH_WEBHOOK,
при успехе пишем сессию в Redis по session:{channel}:{channel_user_id}.
"""
logger.info("[AUTH] POST /api/v1/auth вызван: channel=%s", request.channel)
channel = (request.channel or "").strip().lower()
if channel not in ("tg", "telegram", "max"):
channel = "telegram" if channel.startswith("tg") else "max"
# В n8n и Redis всегда передаём telegram, не tg
if channel == "tg":
channel = "telegram"
init_data = (request.init_data or "").strip()
if not init_data:
raise HTTPException(status_code=400, detail="init_data обязателен")
logger.debug("[AUTH] init_data length=%s", len(init_data))
# 1) Извлечь channel_user_id из init_data
channel_user_id: Optional[str] = None
if channel == "telegram":
try:
user = extract_telegram_user(init_data)
channel_user_id = user.get("telegram_user_id")
except TelegramAuthError as e:
logger.warning("[TG] Ошибка валидации init_data: %s", e)
raise HTTPException(status_code=400, detail=str(e))
else:
try:
user = extract_max_user(init_data)
channel_user_id = user.get("max_user_id")
except MaxAuthError as e:
logger.warning("[MAX] Ошибка валидации init_data: %s", e)
raise HTTPException(status_code=400, detail=str(e))
if not channel_user_id:
raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data")
# URL из settings или напрямую из env (если в config нет поля n8n_auth_webhook)
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or os.environ.get("N8N_AUTH_WEBHOOK") or "").strip()
if not webhook_url:
logger.error("N8N_AUTH_WEBHOOK не задан в .env")
raise HTTPException(status_code=503, detail="Сервис авторизации не настроен")
# 2) Вызвать n8n
payload = {
"channel": channel,
"channel_user_id": channel_user_id,
"init_data": init_data,
}
# При мультиботе (Telegram или MAX) передаём bot_id (из extract_telegram_user / extract_max_user)
if user.get("bot_id"):
payload["bot_id"] = user["bot_id"]
logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException:
logger.error("[AUTH] Таймаут N8N_AUTH_WEBHOOK")
raise HTTPException(status_code=504, detail="Таймаут сервиса авторизации")
except Exception as e:
logger.exception("[AUTH] Ошибка вызова N8N_AUTH_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Ошибка сервиса авторизации")
# Лог: что пришло от n8n (сырой ответ)
try:
_body = response.text or ""
logger.info("[AUTH] n8n ответ: status=%s, body_len=%s, body_preview=%s", response.status_code, len(_body), _body[:500] if _body else "")
except Exception:
pass
try:
raw = response.json()
logger.info("[AUTH] raw type=%s, is_list=%s, len=%s", type(raw).__name__, isinstance(raw, list), len(raw) if isinstance(raw, (list, dict)) else 0)
if isinstance(raw, list) and len(raw) > 0:
logger.info("[AUTH] raw[0] keys=%s", list(raw[0].keys()) if isinstance(raw[0], dict) else type(raw[0]).__name__)
# n8n может вернуть: массив [{ json: { ... } }] или массив объектов напрямую [{ success, unified_id, ... }]
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
first = raw[0]
if "json" in first:
data = first["json"]
logger.info("[AUTH] парсинг: взяли first['json'], data keys=%s", list(data.keys()) if isinstance(data, dict) else "?")
elif "success" in first or "unified_id" in first:
data = first
logger.info("[AUTH] парсинг: взяли first как data, keys=%s", list(data.keys()))
else:
data = {}
logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}")
elif isinstance(raw, dict):
# n8n Respond to Webhook может вернуть { "json": { success, phone, ... } }
if "json" in raw and isinstance(raw.get("json"), dict):
data = raw["json"]
logger.info("[AUTH] парсинг: raw — dict с json, data keys=%s", list(data.keys()))
else:
data = raw
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
else:
data = {}
logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}")
except Exception as e:
logger.warning("[AUTH] Ответ n8n не JSON: %s", (response.text or "")[:300])
raise HTTPException(status_code=502, detail="Некорректный ответ сервиса авторизации")
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
# Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт
need_profile_confirm = _to_bool(
data.get("need_profile_confirm")
if "need_profile_confirm" in data
else data.get("needProfileConfirm")
)
profile_needs_attention = _to_bool(
data.get("profile_needs_attention")
if "profile_needs_attention" in data
else data.get("profileNeedsAttention")
)
if profile_needs_attention is None:
profile_needs_attention = need_profile_confirm
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
need_contact = (
data.get("need_contact") is True
or data.get("need_contact") == 1
or (isinstance(data.get("need_contact"), str) and data.get("need_contact", "").strip().lower() in ("true", "1"))
)
if need_contact:
logger.info("[AUTH] ответ: need_contact=true → закрыть приложение")
return AuthUniversalResponse(
success=False,
need_contact=True,
message=(data.get("message") or "Пользователь не найден. Поделитесь контактом в чате с ботом."),
)
if data.get("success") is False:
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
msg = data.get("message") or "Ошибка авторизации."
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку: message=%s", msg)
logger.debug("[AUTH] полный data при success=false: %s", data)
return AuthUniversalResponse(
success=False,
need_contact=False,
message=msg,
)
# 4) Успех: unified_id и т.д.
unified_id = data.get("unified_id")
if not unified_id and isinstance(data.get("result"), dict):
unified_id = (data.get("result") or {}).get("unified_id")
if not unified_id:
logger.warning("[AUTH] n8n не вернул unified_id: %s", data)
logger.info("[AUTH] ответ: нет unified_id → need_contact=true, закрыть приложение")
return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.")
# 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token}
_phone = data.get("phone") or ((data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None)
_contact_id = data.get("contact_id") or ((data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None)
if _phone is not None and not isinstance(_phone, str):
_phone = str(_phone).strip() or None
elif isinstance(_phone, str):
_phone = _phone.strip() or None
session_data = {
"unified_id": unified_id,
"phone": _phone,
"contact_id": _contact_id,
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
"chat_id": channel_user_id,
"need_profile_confirm": need_profile_confirm,
"profile_needs_attention": profile_needs_attention,
}
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
try:
await session_api.set_session_by_channel_user(channel, channel_user_id, session_data)
except HTTPException:
raise
except Exception as e:
logger.exception("[AUTH] Ошибка записи сессии в Redis: %s", e)
raise HTTPException(status_code=500, detail="Ошибка сохранения сессии")
session_token = str(uuid.uuid4())
try:
await session_api.set_session_by_token(session_token, session_data)
except Exception as e:
logger.warning("[AUTH] Двойная запись session_token в Redis: %s", e)
logger.info("[AUTH] ответ: success=true, session_token=%s..., unified_id=%s", session_token[:8] if session_token else "", unified_id)
return AuthUniversalResponse(
success=True,
session_token=session_token,
unified_id=unified_id,
phone=session_data.get("phone"),
contact_id=session_data.get("contact_id"),
has_drafts=session_data.get("has_drafts", False),
need_profile_confirm=need_profile_confirm,
profile_needs_attention=profile_needs_attention,
)

57
backend/app/api/banks.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Banks API - получение списка банков СБП
"""
from fastapi import APIRouter, HTTPException
import httpx
import logging
from ..config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
@router.get("/nspk")
async def get_nspk_banks():
"""
Получить список банков из внешнего API (BANK_IP в .env или nspk_banks_api_url).
"""
external_api_url = (getattr(settings, "bank_ip", None) or getattr(settings, "bank_api_url", None) or "").strip() or "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(external_api_url)
if response.status_code != 200:
logger.error(f"Failed to fetch banks: HTTP {response.status_code}")
raise HTTPException(
status_code=response.status_code,
detail=f"Failed to fetch banks list: {response.status_code}"
)
banks_data = response.json()
logger.info(f"✅ Loaded {len(banks_data)} banks from external API")
return banks_data
except httpx.TimeoutException:
logger.error("Timeout while fetching banks")
raise HTTPException(
status_code=504,
detail="Timeout while fetching banks list"
)
except httpx.RequestError as e:
logger.error(f"Request error while fetching banks: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to connect to banks API: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error while fetching banks: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal error: {str(e)}"
)

View File

@@ -13,16 +13,21 @@ import uuid
from datetime import datetime
import json
import logging
import asyncio
import os
from ..services.redis_service import redis_service
from ..services.database import db
from ..services.crm_mysql_service import crm_mysql_service
from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL
# Убрали импорты из n8n_service - больше не нужны для webhook подхода
from ..config import settings
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
logger = logging.getLogger(__name__)
N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
def _get_ticket_form_webhook() -> str:
"""URL webhook n8n для wizard и create. Менять в .env: N8N_TICKET_FORM_FINAL_WEBHOOK"""
return (getattr(settings, "n8n_ticket_form_final_webhook", None) or "").strip() or "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
@router.post("/wizard")
@@ -58,16 +63,32 @@ async def submit_wizard(request: Request):
},
)
webhook_url = _get_ticket_form_webhook()
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
N8N_TICKET_FORM_FINAL_WEBHOOK,
webhook_url,
data=data,
files=files or None,
)
text = response.text or ""
logger.info(
"n8n wizard response: status=%s, body_length=%s, body_preview=%s",
response.status_code,
len(text),
text[:1500] if len(text) > 1500 else text,
extra={"claim_id": data.get("claim_id"), "session_id": data.get("session_id")},
)
if response.status_code == 200:
try:
parsed = json.loads(text)
logger.info(
"n8n wizard response (parsed): keys=%s",
list(parsed.keys()) if isinstance(parsed, dict) else type(parsed).__name__,
extra={"session_id": data.get("session_id")},
)
except Exception:
pass
logger.info(
"✅ TicketForm wizard webhook OK",
extra={"response_preview": text[:500]},
@@ -120,9 +141,10 @@ async def create_claim(request: Request):
)
# Проксируем запрос к n8n
webhook_url = _get_ticket_form_webhook()
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
N8N_TICKET_FORM_FINAL_WEBHOOK,
webhook_url,
json=body,
headers={"Content-Type": "application/json"},
)
@@ -241,7 +263,7 @@ async def list_drafts(
OR c.payload->>'phone' = $2
OR c.payload->>'phone' = $3
)
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
@@ -268,7 +290,7 @@ async def list_drafts(
c.updated_at
FROM clpr_claims c
WHERE c.session_token = $1
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
@@ -372,8 +394,23 @@ async def list_drafts(
# Категория проблемы
category = ai_analysis.get('category') or wizard_plan.get('category') or None
# Подробное описание (для превью)
problem_text = payload.get('problem_description', '')
# Направление (для иконки плитки)
direction = payload.get('direction') or wizard_plan.get('direction') or category
# facts_short из AI Agent (краткие факты — заголовок плитки)
ai_agent1_facts = payload.get('ai_agent1_facts') or {}
ai_analysis_facts = (payload.get('ai_analysis') or {}).get('facts_short')
facts_short = ai_agent1_facts.get('facts_short') or ai_analysis_facts
if facts_short and len(facts_short) > 200:
facts_short = facts_short[:200].rstrip() + ''
# Подробное описание (для превью); n8n может сохранять в description/chatInput
problem_text = (
payload.get('problem_description')
or payload.get('description')
or payload.get('chatInput')
or ''
)
# Считаем документы
documents_meta = payload.get('documents_meta') or []
@@ -392,10 +429,11 @@ async def list_drafts(
# Формируем список документов со статусами
documents_list = []
for doc_req in documents_required:
doc_name = doc_req.get('name', 'Документ')
# Пробуем разные поля для названия документа (field_label приоритетнее)
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False)
# Проверяем загружен ли (по name или id)
# Проверяем загружен ли (по field_label или name)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({
"name": doc_name,
@@ -416,6 +454,8 @@ async def list_drafts(
# Полное описание
"problem_description": problem_text[:500] if problem_text else None,
"category": category,
"direction": direction,
"facts_short": facts_short,
"wizard_plan": payload.get('wizard_plan') is not None,
"wizard_answers": payload.get('answers') is not None,
"has_documents": documents_uploaded > 0,
@@ -443,11 +483,13 @@ async def list_drafts(
@router.get("/drafts/{claim_id}")
async def get_draft(claim_id: str):
"""
Получить полные данные черновика по claim_id
Возвращает все данные формы для продолжения заполнения
Получить полные данные черновика по claim_id.
Поддерживаются форматы: голый UUID, claim_id_<uuid> (из MAX startapp).
"""
try:
# Формат из MAX диплинка: claim_id_<uuid> — извлекаем UUID
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
@@ -498,10 +540,40 @@ async def get_draft(claim_id: str):
# 🔍 ОТЛАДКА: Логируем наличие documents_required
documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else []
documents_meta = payload.get('documents_meta', []) if isinstance(payload, dict) else []
logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}")
if documents_required:
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
# Подсчет документов (как в списке черновиков)
documents_required_list = documents_required if isinstance(documents_required, list) else []
documents_meta_list = documents_meta if isinstance(documents_meta, list) else []
# Считаем загруженные (уникальные по field_label)
uploaded_labels = set()
for doc in documents_meta_list:
label = doc.get('field_label') or doc.get('field_name')
if label:
uploaded_labels.add(label)
documents_uploaded = len(uploaded_labels)
documents_total = len(documents_required_list) if documents_required_list else 0
# Формируем список документов со статусами
documents_list = []
for doc_req in documents_required_list:
# Пробуем разные поля для названия документа (field_label приоритетнее)
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False)
# Проверяем загружен ли (по field_label или name)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({
"name": doc_name,
"required": is_required,
"uploaded": is_uploaded,
})
# ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624)
# Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*)
# ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL),
@@ -604,7 +676,11 @@ async def get_draft(claim_id: str):
"channel": row.get('channel'),
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
"payload": payload
"payload": payload,
# Информация о документах
"documents_total": documents_total,
"documents_uploaded": documents_uploaded,
"documents_list": documents_list,
},
# ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624)
"contact_data_confirmed": contact_data_confirmed,
@@ -622,11 +698,11 @@ async def get_draft(claim_id: str):
@router.delete("/drafts/{claim_id}")
async def delete_draft(claim_id: str):
"""
Удалить черновик по claim_id
Удаляет черновики с любым статусом (кроме submitted/completed)
Удалить черновик по claim_id. Поддерживается формат claim_id_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
query = """
DELETE FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
@@ -832,15 +908,14 @@ async def get_claim(claim_id: str):
@router.get("/wizard/load/{claim_id}")
async def load_wizard_data(claim_id: str):
"""
Загрузить данные визарда из PostgreSQL по claim_id
Используется после получения claim_id из ocr_events.
Возвращает полные данные для построения формы (wizard_plan, problem_description и т.д.)
Загрузить данные визарда по claim_id. Поддерживается формат claim_id_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
# Ищем заявку по claim_id (UUID или CLM-...)
query = """
SELECT
id,
@@ -908,48 +983,127 @@ async def load_wizard_data(claim_id: str):
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
async def _check_and_restart_workflow_if_needed(channel: str):
# Актуальный webhook для описания проблемы (n8n.clientright.ru). Старый aiform_description на .pro больше не используем.
DESCRIPTION_WEBHOOK_DEFAULT = "https://n8n.clientright.ru/webhook/ticket_form_description"
DEBUG_LOG_PATH = "/app/logs/debug-2a4d38.log"
def _debug_log(hy: str, msg: str, data: dict):
try:
import time
line = json.dumps({
"sessionId": "2a4d38",
"hypothesisId": hy,
"location": "claims.py:publish_ticket_form_description",
"message": msg,
"data": data,
"timestamp": int(time.time() * 1000),
}, ensure_ascii=False) + "\n"
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
def _get_description_webhook_url() -> str:
"""URL webhook для описания проблемы: только env N8N_DESCRIPTION_WEBHOOK или константа (старый .pro не используем)."""
url = (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "").strip()
if url:
return url
return DESCRIPTION_WEBHOOK_DEFAULT
async def _send_buffered_messages_to_webhook():
"""
Проверяет и перезапускает workflow если нужно (в фоне)
Защита от частых перезапусков через Redis lock
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
"""
try:
# Проверяем lock - если недавно перезапускали, пропускаем
lock_key = f"workflow_restart_lock:{channel}"
lock_value = await redis_service.get(lock_key)
if lock_value:
logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
description_webhook_url = _get_description_webhook_url()
if not description_webhook_url:
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
return
# Проверяем статус workflow
workflow_data = await check_workflow_status()
buffer_key = "description"
messages = await redis_service.buffer_get_all(buffer_key)
if workflow_data:
is_active = workflow_data.get("active", False)
if not is_active:
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
# Workflow выключен — нужно его ВКЛЮЧИТЬ
else:
logger.info(
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
if not messages:
logger.info("📭 Буфер пуст, нечего отправлять")
return
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера в n8n webhook...")
sent_count = 0
failed_count = 0
async with httpx.AsyncClient(timeout=10.0) as client:
for buffered_message in messages:
try:
# Восстанавливаем формат для n8n: массив с channel и message
channel = buffered_message.get("channel", f"{settings.redis_prefix}description")
message_data = buffered_message.get("message", buffered_message.get("event", buffered_message))
webhook_payload = [
{
"channel": channel,
"message": message_data
}
]
response = await client.post(
description_webhook_url,
json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
)
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
# Перезапускаем
success = await restart_workflow()
if success:
logger.info("✅ Workflow успешно перезапущен")
if response.status_code == 200:
sent_count += 1
logger.info(
f"✅ Буферированное сообщение отправлено: "
f"session_id={buffered_message.get('session_id', 'unknown')}"
)
# НЕ возвращаем в буфер - успешно отправили
else:
logger.error("Не удалось перезапустить workflow")
else:
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
# HTTP ошибка - возвращаем в буфер
failed_count += 1
logger.warning(
f"⚠️ n8n вернул ошибку {response.status_code}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except httpx.TimeoutException:
failed_count += 1
logger.warning(
f"⏱️ Таймаут при отправке из буфера, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except httpx.RequestError as e:
failed_count += 1
logger.error(
f"🔌 Ошибка подключения к n8n: {e}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except Exception as e:
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}")
failed_count += 1
logger.error(
f"❌ Неожиданная ошибка при отправке из буфера: {e}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}",
exc_info=True
)
await redis_service.buffer_push(buffer_key, buffered_message)
logger.info(
f"📊 Результат отправки буфера: {sent_count} отправлено, "
f"{failed_count} возвращено в буфер"
)
except Exception as e:
logger.exception(f"❌ Ошибка при отправке буфера: {e}")
@router.post("/description")
@@ -958,114 +1112,201 @@ async def publish_ticket_form_description(
background_tasks: BackgroundTasks
):
"""
Публикует свободное описание проблемы в Redis канал ticket_form:description
(слушается воркфлоу в n8n)
Отправляет описание проблемы в n8n через webhook. URL: N8N_DESCRIPTION_WEBHOOK из env или константа (n8n.clientright.ru).
"""
# #region agent log
_debug_log("H1_H4", "POST /description handler entered", {"session_id": getattr(payload, "session_id", None)})
# #endregion
try:
description_webhook_url = _get_description_webhook_url()
# #region agent log
_debug_log("H3_H5", "description webhook URL resolved", {"url": description_webhook_url[:80] if description_webhook_url else "", "env_N8N": (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "")[:80]})
# #endregion
if not description_webhook_url:
raise HTTPException(
status_code=500,
detail="N8N description webhook не настроен"
)
# Если unified_id не передан — подставляем из сессии в Redis (tg/max auth создают сессию с unified_id)
unified_id = payload.unified_id
contact_id = payload.contact_id
phone = payload.phone
if not unified_id and payload.session_id:
try:
session_key = f"session:{payload.session_id}"
session_raw = await redis_service.client.get(session_key)
if session_raw:
session_data = json.loads(session_raw)
unified_id = unified_id or session_data.get("unified_id")
contact_id = contact_id or session_data.get("contact_id")
phone = phone or session_data.get("phone")
if unified_id:
logger.info("📝 unified_id/contact_id/phone подставлены из сессии Redis: session_key=%s", session_key)
except Exception as e:
logger.warning("Не удалось прочитать сессию из Redis для подстановки unified_id: %s", e)
# Формируем данные в формате, который ожидает n8n workflow
channel = payload.channel or f"{settings.redis_prefix}description"
event = {
message = {
"type": "ticket_form_description",
"session_id": payload.session_id,
"claim_id": payload.claim_id, # Опционально - может быть None
"phone": payload.phone,
"phone": phone,
"email": payload.email,
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
"unified_id": unified_id, # из запроса или из сессии Redis
"contact_id": contact_id,
"description": payload.problem_description.strip(),
"source": payload.source,
"entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n
"timestamp": datetime.utcnow().isoformat(),
}
event_json = json.dumps(event, ensure_ascii=False)
# n8n workflow ожидает массив с объектом, содержащим channel и message
webhook_payload = [
{
"channel": channel,
"message": message
}
]
logger.info(
"📝 TicketForm description received",
"📝 TicketForm description received → webhook=%s",
description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""),
extra={
"session_id": payload.session_id,
"claim_id": payload.claim_id or "not_set",
"phone": payload.phone,
"unified_id": payload.unified_id or "not_set",
"contact_id": payload.contact_id or "not_set",
"description_length": len(payload.problem_description),
"channel": channel,
},
)
# Retry-логика: пытаемся отправить в n8n webhook
max_attempts = 3
initial_delay = 1 # секунды
for attempt in range(1, max_attempts + 1):
try:
logger.info(
"📡 Publishing to Redis channel",
extra={
"channel": channel,
"event_type": event["type"],
"event_keys": list(event.keys()),
"json_length": len(event_json),
},
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
extra={"session_id": payload.session_id}
)
subscribers_count = await redis_service.publish(channel, event_json)
# #region agent log
_debug_log("H2_H4", "about to POST to n8n webhook", {"attempt": attempt, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
# #endregion
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
description_webhook_url,
json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
)
# #region agent log
_debug_log("H4", "n8n webhook response", {"status": response.status_code, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
# #endregion
if response.status_code == 200:
response_body = response.text or ""
logger.info(
"TicketForm description published to Redis",
extra={
"channel": channel,
"session_id": payload.session_id,
"subscribers_count": subscribers_count,
"event_json_preview": event_json[:500],
},
"Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s",
attempt,
len(response_body),
response_body[:2000] if len(response_body) > 2000 else response_body,
extra={"session_id": payload.session_id},
)
if subscribers_count == 0:
try:
parsed_n8n = json.loads(response_body)
logger.info(
"n8n description response (parsed): keys=%s",
list(parsed_n8n.keys()) if isinstance(parsed_n8n, dict) else type(parsed_n8n).__name__,
extra={"session_id": payload.session_id},
)
except Exception:
pass
# После описания фронт подписывается на SSE — логируем, на что именно
logger.info(
"📡 После описания в n8n клиент подпишется на: "
"channel_ocr=ocr_events:%s (GET /api/v1/events/%s), "
"channel_plan=claim:plan:%s (GET /api/v1/claim-plan/%s)",
payload.session_id, payload.session_id, payload.session_id, payload.session_id,
extra={"session_id": payload.session_id},
)
# Успешно отправили - возвращаем успех
return {
"success": True,
"event": message,
"attempt": attempt,
}
else:
# HTTP ошибка (не 200)
logger.warning(
f"⚠️ WARNING: No subscribers on channel {channel}! "
f"n8n workflow is not listening to this channel. "
f"Saving message to buffer and restarting workflow..."
f"⚠️ Попытка {attempt}: n8n вернул статус {response.status_code}",
extra={
"session_id": payload.session_id,
"status_code": response.status_code,
"response_preview": response.text[:200],
}
)
except httpx.TimeoutException:
logger.warning(
f"⏱️ Попытка {attempt}: таймаут при отправке в n8n webhook",
extra={"session_id": payload.session_id}
)
except httpx.RequestError as e:
logger.warning(
f"🔌 Попытка {attempt}: ошибка подключения к n8n: {e}",
extra={"session_id": payload.session_id}
)
except Exception as e:
logger.error(
f"❌ Попытка {attempt}: неожиданная ошибка: {e}",
extra={"session_id": payload.session_id},
exc_info=True
)
# Если это не последняя попытка - ждём перед следующей
if attempt < max_attempts:
wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff
logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...")
await asyncio.sleep(wait_time)
# Все попытки исчерпаны - сохраняем в буфер
logger.error(
f"Все {max_attempts} попытки исчерпаны, сохраняю в буфер",
extra={"session_id": payload.session_id}
)
# Сохраняем сообщение в буфер для последующей отправки
buffer_message = {
"session_id": payload.session_id,
"claim_id": payload.claim_id,
"event": event,
"channel": channel,
"message": message, # Сохраняем message для последующей отправки
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.buffer_push("description", buffer_message)
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
# Запускаем проверку и перезапуск workflow в фоне
background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
# Запускаем фоновую задачу для отправки из буфера
background_tasks.add_task(_send_buffered_messages_to_webhook)
# Дополнительная проверка: логируем полный event для отладки
logger.debug(
"🔍 Full event data published",
extra={
"channel": channel,
"event": event,
},
)
# Формируем ответ с информацией о подписчиках
response_data = {
"success": True,
"channel": channel,
"subscribers_count": subscribers_count,
"event": event,
}
# Если подписчиков нет - сообщаем что обработка займёт больше времени
if subscribers_count == 0:
buffer_size = await redis_service.buffer_size("description")
response_data["warning"] = (
return {
"success": True,
"event": message,
"buffered": True,
"warning": (
"Обработка вашего обращения займёт немного больше времени. "
"Идёт автоматическое восстановление системы. "
"Ваше сообщение сохранено и будет обработано в ближайшее время."
)
response_data["workflow_recovering"] = True
response_data["message_buffered"] = True
response_data["buffer_size"] = buffer_size
),
"buffer_size": buffer_size,
}
return response_data
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Failed to publish ticket form description")
logger.exception("❌ Failed to send ticket form description to n8n")
raise HTTPException(
status_code=500,
detail=f"Не удалось опубликовать описание: {e}"
detail=f"Не удалось отправить описание: {e}"
)

View File

@@ -0,0 +1,213 @@
"""
Консультации: тикеты из CRM (MySQL) через N8N_TICKET_FORM_CONSULTATION_WEBHOOK.
GET/POST /api/v1/consultations — верификация сессии, вызов webhook с тем же payload,
что и у других хуков (session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id).
Ответ webhook возвращается клиенту (список тикетов и т.д.).
"""
import logging
from typing import Any, Optional
import httpx
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.config import settings
from app.api.session import SessionVerifyRequest, verify_session
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/consultations", tags=["consultations"])
class ConsultationsPostBody(BaseModel):
"""Тело запроса: session_token обязателен для идентификации."""
session_token: str = Field(..., description="Токен сессии")
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
class TicketDetailBody(BaseModel):
"""Тело запроса «подробнее по тикету»."""
session_token: str = Field(..., description="Токен сессии")
ticket_id: Any = Field(..., description="ID тикета в CRM (ticketid)")
entry_channel: Optional[str] = Field("web", description="Канал входа")
def _get_consultation_webhook_url() -> str:
url = (getattr(settings, "n8n_ticket_form_consultation_webhook", None) or "").strip()
if not url:
raise HTTPException(
status_code=503,
detail="N8N_TICKET_FORM_CONSULTATION_WEBHOOK не настроен",
)
return url
def _get_podrobnee_webhook_url() -> str:
url = (getattr(settings, "n8n_ticket_form_podrobnee_webhook", None) or "").strip()
if not url:
raise HTTPException(
status_code=503,
detail="N8N_TICKET_FORM_PODROBNEE_WEBHOOK не настроен",
)
return url
async def _call_consultation_webhook(
session_token: str,
entry_channel: str = "web",
) -> dict:
"""
Верифицировать сессию, собрать payload как у других хуков, POST в webhook, вернуть ответ.
"""
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if not getattr(verify_res, "valid", False):
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
unified_id = getattr(verify_res, "unified_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
chat_id = getattr(verify_res, "chat_id", None)
payload: dict[str, Any] = {
"form_id": "ticket_form",
"session_token": session_token,
"unified_id": unified_id,
"entry_channel": (entry_channel or "web").strip() or "web",
}
if contact_id is not None:
payload["contact_id"] = contact_id
if phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
webhook_url = _get_consultation_webhook_url()
logger.info("Consultation webhook: POST %s, keys=%s", webhook_url[:60], list(payload.keys()))
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException:
logger.error("Таймаут вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK")
raise HTTPException(status_code=504, detail="Сервис консультаций временно недоступен")
except Exception as e:
logger.exception("Ошибка вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Сервис консультаций временно недоступен")
if response.status_code != 200:
logger.warning(
"Consultation webhook вернул %s: %s",
response.status_code,
response.text[:500],
)
raise HTTPException(
status_code=502,
detail="Сервис консультаций вернул ошибку",
)
try:
return response.json()
except Exception:
return {"raw": response.text or ""}
@router.get("")
async def get_consultations(
session_token: Optional[str] = Query(None, description="Токен сессии"),
entry_channel: Optional[str] = Query("web", description="Канал входа: telegram | max | web"),
):
"""
Получить данные консультаций (тикеты из CRM) через n8n webhook.
Передаётся тот же payload, что и на другие хуки: session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id.
"""
if not session_token or not str(session_token).strip():
raise HTTPException(status_code=400, detail="Укажите session_token")
return await _call_consultation_webhook(
session_token=str(session_token).strip(),
entry_channel=entry_channel or "web",
)
@router.post("")
async def post_consultations(body: ConsultationsPostBody):
"""То же по телу запроса."""
return await _call_consultation_webhook(
session_token=body.session_token.strip(),
entry_channel=body.entry_channel or "web",
)
@router.post("/ticket-detail")
async def get_ticket_detail(body: TicketDetailBody):
"""
Подробнее по тикету: верификация сессии, вызов N8N_TICKET_FORM_PODROBNEE_WEBHOOK
с payload (session_token, unified_id, contact_id, phone, ticket_id, entry_channel, form_id).
Ответ вебхука возвращается клиенту как есть (HTML в поле html/body или весь JSON).
"""
session_token = str(body.session_token or "").strip()
if not session_token:
raise HTTPException(status_code=400, detail="Укажите session_token")
ticket_id = body.ticket_id
if ticket_id is None or (isinstance(ticket_id, str) and not str(ticket_id).strip()):
raise HTTPException(status_code=400, detail="Укажите ticket_id")
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if not getattr(verify_res, "valid", False):
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
unified_id = getattr(verify_res, "unified_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
chat_id = getattr(verify_res, "chat_id", None)
entry_channel = (body.entry_channel or "web").strip() or "web"
payload: dict[str, Any] = {
"form_id": "ticket_form",
"session_token": session_token,
"unified_id": unified_id,
"ticket_id": int(ticket_id) if isinstance(ticket_id, str) and ticket_id.isdigit() else ticket_id,
"entry_channel": entry_channel,
}
if contact_id is not None:
payload["contact_id"] = contact_id
if phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
webhook_url = _get_podrobnee_webhook_url()
logger.info("Podrobnee webhook: POST %s, ticket_id=%s", webhook_url[:60], payload.get("ticket_id"))
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.TimeoutException:
logger.error("Таймаут вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK")
raise HTTPException(status_code=504, detail="Сервис временно недоступен")
except Exception as e:
logger.exception("Ошибка вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Сервис временно недоступен")
if response.status_code != 200:
logger.warning("Podrobnee webhook вернул %s: %s", response.status_code, response.text[:500])
raise HTTPException(status_code=502, detail="Сервис вернул ошибку")
try:
return response.json()
except Exception:
return {"html": response.text or "", "raw": True}

View File

@@ -0,0 +1,89 @@
import base64
import json
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from urllib.parse import quote_plus
WEBHOOK_DEBUG_URL = "https://n8n.clientright.ru/webhook/test"
router = APIRouter(prefix="/api/v1/debug", tags=["debug"])
@router.post("/forward-to-webhook")
async def forward_to_webhook(request: Request):
"""
Прокси: принимает JSON body и пересылает на n8n webhook (обход CORS с debug-webapp).
Сначала POST; если n8n вернёт 404 (webhook только GET) — повторяем GET с ?data=base64(body).
"""
try:
body = await request.json()
except Exception:
body = {}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(WEBHOOK_DEBUG_URL, json=body)
if r.status_code == 404 and "POST" in (r.text or ""):
b64 = base64.urlsafe_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode().rstrip("=")
r = await client.get(f"{WEBHOOK_DEBUG_URL}?data={quote_plus(b64)}")
ct = r.headers.get("content-type", "")
if "application/json" in ct:
try:
content = r.json()
except Exception:
content = {"status": r.status_code, "text": (r.text or "")[:500]}
else:
content = {"status": r.status_code, "text": (r.text or "")[:500]}
return JSONResponse(status_code=r.status_code, content=content)
@router.get("/set_session_redirect", response_class=HTMLResponse)
async def set_session_redirect(request: Request, session_token: str = "", claim_id: str = "", redirect_to: str = "/hello"):
"""
Temporary helper: returns an HTML page that sets localStorage.session_token and redirects to /hello?claim_id=...
Use for manual testing: open this URL in a browser on the target origin.
"""
# Ensure values are safe for embedding
js_session = session_token.replace('"', '\\"')
target_claim = quote_plus(claim_id) if claim_id else ""
# sanitize redirect_to - allow only absolute path starting with '/'
if not redirect_to.startswith('/'):
redirect_to = '/hello'
if target_claim:
# append query param correctly
if '?' in redirect_to:
redirect_url = f"{redirect_to}&claim_id={target_claim}"
else:
redirect_url = f"{redirect_to}?claim_id={target_claim}"
else:
redirect_url = redirect_to
html = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Set session and redirect</title>
</head>
<body>
<script>
try {{
const token = "{js_session}";
if (token && token.length>0) {{
localStorage.setItem('session_token', token);
console.log('Set localStorage.session_token:', token);
}} else {{
console.log('No session_token provided');
}}
// give localStorage a tick then redirect
setTimeout(() => {{
window.location.href = "{redirect_url}";
}}, 200);
}} catch (e) {{
document.body.innerText = 'Error: ' + e;
}}
</script>
<p>Setting session and redirecting...</p>
<p>If you are not redirected, click <a id="go" href="{redirect_url}">here</a>.</p>
</body>
</html>"""
return HTMLResponse(content=html, status_code=200)

View File

@@ -491,6 +491,32 @@ async def skip_document(
},
)
# Сохраняем documents_skipped в БД, чтобы при следующем заходе состояние не обнулялось
claim_id_clean = claim_id.replace("claim_id_", "", 1) if claim_id.startswith("claim_id_") else claim_id
try:
row = await db.fetch_one(
"SELECT id, payload FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) ORDER BY updated_at DESC LIMIT 1",
claim_id_clean,
)
if row:
payload_raw = row.get("payload") or {}
payload = json.loads(payload_raw) if isinstance(payload_raw, str) else (payload_raw if isinstance(payload_raw, dict) else {})
skipped = list(payload.get("documents_skipped") or [])
if document_type not in skipped:
skipped.append(document_type)
await db.execute(
"""
UPDATE clpr_claims
SET payload = jsonb_set(COALESCE(payload, '{}'::jsonb), '{documents_skipped}', $1::jsonb)
WHERE (payload->>'claim_id' = $2 OR id::text = $2)
""",
json.dumps(skipped),
claim_id_clean,
)
logger.info("✅ documents_skipped сохранён в БД для claim_id=%s", claim_id_clean)
except Exception as e:
logger.warning("⚠️ Не удалось сохранить documents_skipped в БД: %s", e)
# Парсим ответ от n8n
try:
n8n_response = json.loads(response_text)

View File

@@ -0,0 +1,132 @@
"""
Documents draft-open endpoint
This file provides a single, isolated endpoint to fetch the documents list
and minimal claim metadata for a given claim_id. It is implemented as a
separate router to avoid touching existing document/claim routes.
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import RedirectResponse
from ..config import settings
import logging
import json
from typing import Any, Dict
from ..services.database import db
router = APIRouter(prefix="/api/v1/documents-draft", tags=["DocumentsDraft"])
logger = logging.getLogger(__name__)
@router.get("/open/{claim_id}")
async def open_documents_draft(claim_id: str):
"""
Return minimal draft info focused on documents for the given claim_id.
Response:
{
"success": True,
"claim_id": "...",
"session_token": "...",
"status_code": "...",
"documents_required": [...],
"documents_meta": [...],
"documents_count": 3,
"created_at": "...",
"updated_at": "..."
}
"""
try:
query = """
SELECT
id,
payload->>'claim_id' AS claim_id,
session_token,
status_code,
payload->'documents_required' AS documents_required,
payload->'documents_meta' AS documents_meta,
created_at,
updated_at
FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
ORDER BY updated_at DESC
LIMIT 1
"""
row = await db.fetch_one(query, claim_id)
if not row:
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
# Normalize JSONB fields which may be strings
def parse_json_field(val: Any):
if val is None:
return []
if isinstance(val, str):
try:
return json.loads(val)
except Exception:
return []
return val if isinstance(val, list) else []
documents_required = parse_json_field(row.get("documents_required"))
documents_meta = parse_json_field(row.get("documents_meta"))
result = {
"success": True,
"claim_id": row.get("claim_id") or str(row.get("id")),
"session_token": row.get("session_token"),
"status_code": row.get("status_code"),
"documents_required": documents_required,
"documents_meta": documents_meta,
"documents_count": len(documents_required),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
}
return result
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to open documents draft")
raise HTTPException(status_code=500, detail=f"Error opening documents draft: {str(e)}")
@router.get("/open/launch/{claim_id}")
async def launch_documents_draft(
claim_id: str,
target: str = Query("miniapp", description="Where to open: 'miniapp' or 'max'"),
bot_name: str | None = Query(None, description="MAX bot name (required if target=max)"),
):
"""
Convenience launcher:
- target=miniapp (default) -> redirects to our miniapp URL with claim_id
https://miniapp.clientright.ru/hello?claim_id=...
- target=max -> redirects to MAX deep link:
https://max.ru/{bot_name}?startapp={claim_id}
This endpoint only redirects; it does not change persisted data.
"""
try:
# ensure claim exists
query = "SELECT 1 FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) LIMIT 1"
row = await db.fetch_one(query, claim_id)
if not row:
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
if target == "max":
bot = bot_name or getattr(settings, "MAX_BOT_NAME", None)
if not bot:
raise HTTPException(status_code=400, detail="bot_name is required when target=max")
# claim_id is UUID with allowed chars (hex + hyphens) - OK for startapp
url = f"https://max.ru/{bot}?startapp={claim_id}"
return RedirectResponse(url)
else:
# default: open miniapp directly (hosted at /hello)
url = f"https://miniapp.clientright.ru/hello?claim_id={claim_id}"
return RedirectResponse(url)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to launch documents draft")
raise HTTPException(status_code=500, detail=f"Error launching documents draft: {str(e)}")

View File

@@ -9,12 +9,108 @@ from pydantic import BaseModel
from typing import Dict, Any
from app.services.redis_service import redis_service
from app.services.database import db
from app.config import settings
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["Events"])
# Типы для единого отображения на фронте: тип + текст (+ data для consumer_complaint)
DISPLAY_EVENT_TYPES = ("trash_message", "out_of_scope", "consumer_consultation", "consumer_complaint")
def _normalize_display_event(actual_event: dict) -> dict:
"""
Приводит событие к формату { event_type, message [, data] } для единого отображения.
event_type — один из: trash_message (красный), out_of_scope (жёлтый),
consumer_consultation (синий), consumer_complaint (зелёный).
"""
raw_type = actual_event.get("event_type") or actual_event.get("type")
payload = actual_event.get("payload") or actual_event.get("data") or {}
if isinstance(payload, str):
try:
payload = json.loads(payload) if payload else {}
except Exception:
payload = {}
if not isinstance(payload, dict):
payload = {}
msg = (actual_event.get("message") or payload.get("message") or "").strip() or "Ответ получен"
# Если n8n уже прислал один из четырёх типов — не перезаписываем, отдаём как есть (синий/зелёный не превращаем в жёлтый)
if raw_type in DISPLAY_EVENT_TYPES:
return {
"event_type": raw_type,
"message": msg or "Ответ получен",
"data": actual_event.get("data", {}),
"suggested_actions": (actual_event.get("suggested_actions") or payload.get("suggested_actions")) if raw_type == "out_of_scope" else None,
}
if raw_type == "trash_message" or payload.get("intent") == "trash":
return {
"event_type": "trash_message",
"message": msg or "К сожалению, это обращение не по тематике.",
"data": actual_event.get("data", {}),
}
if raw_type == "out_of_scope":
return {
"event_type": "out_of_scope",
"message": msg or "К сожалению, мы не можем помочь с этим вопросом.",
"data": actual_event.get("data", {}),
"suggested_actions": actual_event.get("suggested_actions") or payload.get("suggested_actions"),
}
if raw_type == "consumer_intent":
intent = payload.get("intent") or actual_event.get("intent")
if intent == "consultation":
return {
"event_type": "consumer_consultation",
"message": msg or "Понял. Это похоже на консультацию.",
"data": {},
}
return {
"event_type": "consumer_complaint",
"message": msg or "Обращение принято.",
"data": actual_event.get("data", {}),
}
if raw_type == "documents_list_ready":
return {
"event_type": "consumer_complaint",
"message": msg or "Подготовлен список документов.",
"data": {
**actual_event.get("data", {}),
"documents_required": actual_event.get("documents_required"),
"claim_id": actual_event.get("claim_id"),
},
}
if raw_type in ("wizard_ready", "wizard_plan_ready", "claim_plan_ready"):
return {
"event_type": "consumer_complaint",
"message": msg or "План готов.",
"data": actual_event.get("data", actual_event),
}
if raw_type == "ocr_status" and actual_event.get("status") == "ready":
return {
"event_type": "consumer_complaint",
"message": msg or "Данные подтверждены.",
"data": actual_event.get("data", {}),
}
# Если есть текст сообщения, но тип неизвестен — считаем out_of_scope, чтобы фронт точно показал ответ
if msg and msg.strip() and raw_type not in (
"documents_list_ready", "document_uploaded", "document_ocr_completed",
"ocr_status", "claim_ready", "claim_plan_ready", "claim_plan_error",
):
return {
"event_type": "out_of_scope",
"message": msg.strip(),
"data": actual_event.get("data", {}),
"suggested_actions": actual_event.get("suggested_actions"),
}
# Остальные события — прозрачно, только дополняем message
out = dict(actual_event)
if "message" not in out or not out.get("message"):
out["message"] = msg
return out
class EventPublish(BaseModel):
"""Модель для публикации события"""
@@ -84,7 +180,10 @@ async def stream_events(task_id: str):
Returns:
StreamingResponse с событиями
"""
logger.info(f"🚀 SSE connection requested for session_token: {task_id}")
logger.info(
"🚀 SSE connection requested for session_token: %s → channel=ocr_events:%s (Redis %s:%s)",
task_id, task_id, settings.redis_host, settings.redis_port,
)
async def event_generator():
"""Генератор событий из Redis Pub/Sub"""
@@ -95,7 +194,10 @@ async def stream_events(task_id: str):
pubsub = redis_service.client.pubsub()
await pubsub.subscribe(channel)
logger.info(f"📡 Client subscribed to {channel}")
logger.info(
"📡 Subscribed to channel=%s on Redis %s:%s (проверка: redis-cli -h %s PUBSUB NUMSUB %s)",
channel, settings.redis_host, settings.redis_port, settings.redis_host, channel,
)
# Отправляем начальное событие
yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n"
@@ -298,10 +400,14 @@ async def stream_events(task_id: str):
except Exception as e:
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
# Единый формат для фронта: событие с полями event_type и message (и data при необходимости)
raw_event_type = actual_event.get("event_type")
raw_status = actual_event.get("status")
actual_event = _normalize_display_event(actual_event)
# Отправляем событие клиенту (плоский формат)
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
event_type_sent = actual_event.get('event_type', 'unknown')
event_status = actual_event.get('status', 'unknown')
event_type_sent = actual_event.get("event_type", "unknown")
event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown"
# Логируем размер и наличие данных
data_info = actual_event.get('data', {})
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
@@ -310,18 +416,21 @@ async def stream_events(task_id: str):
# Если обработка завершена - закрываем соединение
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
if event_status in ['completed', 'error'] and (raw_event_type or event_type_sent) not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
logger.info(f"✅ Task {task_id} finished, closing SSE")
break
# Закрываем для финальных событий
# Закрываем для финальных событий (raw_event_type до нормализации)
if raw_event_type in ['claim_ready', 'claim_plan_ready', 'wizard_ready', 'wizard_plan_ready']:
logger.info(f"✅ Final event {raw_event_type} sent, closing SSE")
break
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
break
# Закрываем для ocr_status ready (форма заявления готова)
if event_type_sent == 'ocr_status' and event_status == 'ready':
logger.info(f"✅ OCR ready event sent, closing SSE")
if raw_event_type == "ocr_status" and raw_status == "ready":
logger.info("✅ OCR ready event sent, closing SSE")
break
else:
logger.info(f"⏰ Timeout waiting for message on {channel}")
@@ -369,7 +478,10 @@ async def stream_claim_plan(session_token: str):
}
}
"""
logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}")
logger.info(
"🚀 Claim plan SSE: session_token=%s → channel=claim:plan:%s (Redis %s:%s)",
session_token, session_token, settings.redis_host, settings.redis_port,
)
async def claim_plan_generator():
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
@@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str):
pubsub = redis_service.client.pubsub()
await pubsub.subscribe(channel)
logger.info(f"📡 Client subscribed to {channel}")
logger.info(
"📡 Subscribed to channel=%s on Redis %s:%s (PUBSUB NUMSUB %s)",
channel, settings.redis_host, settings.redis_port, channel,
)
# Отправляем начальное событие
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"

156
backend/app/api/max_auth.py Normal file
View File

@@ -0,0 +1,156 @@
"""
MAX Mini App (WebApp) auth endpoint.
/api/v1/max/auth:
- Принимает init_data от MAX Bridge (window.WebApp.initData)
- Валидирует init_data и извлекает данные пользователя MAX
- Проксирует max_user_id в n8n для получения unified_id/контакта
- Создаёт сессию в Redis (аналогично Telegram — без SMS)
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from ..services.max_auth import extract_max_user, MaxAuthError
from ..config import settings
from . import n8n_proxy
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/max", tags=["MAX"])
class MaxAuthRequest(BaseModel):
init_data: str
session_token: Optional[str] = None
phone: Optional[str] = None
class MaxAuthResponse(BaseModel):
success: bool
session_token: Optional[str] = None
unified_id: Optional[str] = None
contact_id: Optional[str] = None
phone: Optional[str] = None
has_drafts: Optional[bool] = None
need_contact: Optional[bool] = None
def _generate_session_token() -> str:
import uuid
return f"sess-{uuid.uuid4()}"
@router.post("/auth", response_model=MaxAuthResponse)
async def max_auth(request: MaxAuthRequest):
"""
Авторизация пользователя через MAX WebApp (Mini App).
"""
init_data = request.init_data or ""
phone = (request.phone or "").strip()
logger.info(
"[MAX] POST /api/v1/max/auth: init_data длина=%s, phone=%s, session_token=%s",
len(init_data),
bool(phone),
bool(request.session_token),
)
if not init_data:
logger.warning("[MAX] init_data пустой")
raise HTTPException(status_code=400, detail="init_data обязателен")
bot_configured = bool((getattr(settings, "max_bot_token", None) or "").strip())
webhook_configured = bool((getattr(settings, "n8n_max_auth_webhook", None) or "").strip())
logger.info("[MAX] Конфиг: MAX_BOT_TOKEN=%s, N8N_MAX_AUTH_WEBHOOK=%s", bot_configured, webhook_configured)
try:
max_user = extract_max_user(request.init_data)
except MaxAuthError as e:
logger.warning("[MAX] Ошибка валидации initData: %s", e)
raise HTTPException(status_code=400, detail=str(e))
max_user_id = max_user["max_user_id"]
logger.info("[MAX] MAX user валиден: id=%s, username=%s", max_user_id, max_user.get("username"))
session_token = request.session_token or _generate_session_token()
n8n_payload = {
"max_user_id": max_user_id,
"username": max_user.get("username"),
"first_name": max_user.get("first_name"),
"last_name": max_user.get("last_name"),
"session_token": session_token,
"form_id": "ticket_form",
"init_data": request.init_data,
}
if phone:
n8n_payload["phone"] = phone
logger.info("[MAX] Валидация OK → вызов n8n webhook (max_user_id=%s)", max_user_id)
try:
class _DummyRequest:
def __init__(self, payload: dict):
self._payload = payload
async def json(self):
return self._payload
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
except HTTPException:
raise
except Exception as e:
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
logger.info("[MAX] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
_result = n8n_data.get("result")
_result_dict = _result if isinstance(_result, dict) else {}
_raw = (
n8n_data.get("need_contact")
or _result_dict.get("need_contact")
or n8n_data.get("needContact")
or _result_dict.get("needContact")
)
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
if need_contact:
logger.info("[MAX] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
return MaxAuthResponse(success=False, need_contact=True)
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
phone_res = n8n_data.get("phone") or _result_dict.get("phone")
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
if not unified_id:
logger.info("[MAX] n8n не вернул unified_id (юзер не в базе) — возвращаем need_contact=true. Ответ: %s", n8n_data)
return MaxAuthResponse(success=False, need_contact=True)
session_request = session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone_res or phone or "",
contact_id=contact_id or "",
ttl_hours=24,
chat_id=str(max_user_id) if max_user_id else None,
)
try:
await session_api.create_session(session_request)
except HTTPException:
raise
except Exception as e:
logger.exception("[MAX] Ошибка создания сессии в Redis")
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
return MaxAuthResponse(
success=True,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone_res or phone,
has_drafts=has_drafts,
)

View File

@@ -75,4 +75,5 @@ class TicketFormDescriptionRequest(BaseModel):
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
source: str = Field("ticket_form", description="Источник события")
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web — для роутинга в n8n")

View File

@@ -15,11 +15,13 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/n8n", tags=["n8n-proxy"])
# URL webhooks из .env (будут добавлены)
N8N_POLICY_CHECK_WEBHOOK = getattr(settings, 'n8n_policy_check_webhook', None)
N8N_FILE_UPLOAD_WEBHOOK = getattr(settings, 'n8n_file_upload_webhook', None)
N8N_CREATE_CONTACT_WEBHOOK = getattr(settings, 'n8n_create_contact_webhook', 'https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27')
N8N_CREATE_CLAIM_WEBHOOK = getattr(settings, 'n8n_create_claim_webhook', 'https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d')
# URL webhooks - берём из settings (defaults в config.py)
N8N_POLICY_CHECK_WEBHOOK = settings.n8n_policy_check_webhook or None
N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
N8N_MAX_AUTH_WEBHOOK = getattr(settings, "n8n_max_auth_webhook", None) or None
@router.post("/policy/check")
@@ -124,7 +126,9 @@ async def proxy_create_contact(request: Request):
logger.error("⏱️ N8N webhook timeout")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
except Exception as e:
import traceback
logger.error(f"❌ Error proxying to n8n: {e}")
logger.error(f"❌ Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Ошибка создания контакта: {str(e)}")
@@ -217,6 +221,120 @@ async def proxy_file_upload(
raise HTTPException(status_code=500, detail=f"Ошибка загрузки файла: {str(e)}")
@router.post("/tg/auth")
async def proxy_telegram_auth(request: Request):
"""
Проксирует авторизацию Telegram WebApp (Mini App) в n8n webhook.
Используется backend-эндпоинтом /api/v1/tg/auth:
- backend валидирует initData
- затем вызывает этот роут для маппинга telegram_user_id → unified_id в n8n
"""
if not N8N_TG_AUTH_WEBHOOK:
logger.error("[TG] N8N_TG_AUTH_WEBHOOK не задан в .env — webhook не вызывается")
raise HTTPException(status_code=500, detail="N8N Telegram auth webhook не настроен")
try:
body = await request.json()
logger.info(
"[TG] Proxy → n8n webhook %s: telegram_user_id=%s, session_token=%s",
N8N_TG_AUTH_WEBHOOK[:50] + "...",
body.get("telegram_user_id", "unknown"),
body.get("session_token", "unknown"),
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
N8N_TG_AUTH_WEBHOOK,
json=body,
headers={"Content-Type": "application/json"},
)
response_text = response.text or ""
logger.info("[TG] n8n webhook ответ: status=%s, body длина=%s", response.status_code, len(response_text))
if response.status_code == 200:
logger.info(
"[TG] n8n webhook success. Response: %s",
response_text[:500],
)
try:
return response.json()
except Exception as e:
logger.error(
"❌ Failed to parse Telegram auth JSON: %s. Response: %s",
e,
response_text[:500],
)
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
logger.error(
"[TG] n8n webhook вернул ошибку %s: %s",
response.status_code,
response_text[:500],
)
raise HTTPException(
status_code=response.status_code,
detail=f"N8N Telegram auth error: {response_text}",
)
except httpx.TimeoutException:
logger.error("[TG] Таймаут при вызове n8n Telegram auth webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (Telegram auth)")
except Exception as e:
logger.exception("[TG] Ошибка при вызове n8n Telegram auth: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
@router.post("/max/auth")
async def proxy_max_auth(request: Request):
"""
Проксирует авторизацию MAX WebApp в n8n webhook.
Используется /api/v1/max/auth: backend валидирует initData, затем вызывает этот роут.
"""
if not N8N_MAX_AUTH_WEBHOOK:
logger.error("[MAX] N8N_MAX_AUTH_WEBHOOK не задан в .env")
raise HTTPException(status_code=500, detail="N8N MAX auth webhook не настроен")
try:
body = await request.json()
logger.info(
"[MAX] Proxy → n8n: max_user_id=%s, session_token=%s",
body.get("max_user_id", "unknown"),
body.get("session_token", "unknown"),
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
N8N_MAX_AUTH_WEBHOOK,
json=body,
headers={"Content-Type": "application/json"},
)
response_text = response.text or ""
logger.info("[MAX] n8n webhook ответ: status=%s, len=%s", response.status_code, len(response_text))
if response.status_code == 200:
try:
return response.json()
except Exception as e:
logger.error("[MAX] Парсинг JSON: %s. Response: %s", e, response_text[:500])
raise HTTPException(status_code=500, detail="Ошибка парсинга ответа n8n")
logger.error("[MAX] n8n вернул %s: %s", response.status_code, response_text[:500])
raise HTTPException(
status_code=response.status_code,
detail=f"N8N MAX auth error: {response_text}",
)
except httpx.TimeoutException:
logger.error("[MAX] Таймаут n8n MAX auth webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (MAX auth)")
except Exception as e:
logger.exception("[MAX] Ошибка вызова n8n MAX auth: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка авторизации MAX: {str(e)}")
@router.post("/claim/create")
async def proxy_create_claim(request: Request):
"""

322
backend/app/api/profile.py Normal file
View File

@@ -0,0 +1,322 @@
"""
Профиль пользователя: контактные данные из CRM через n8n webhook.
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
"""
import logging
from typing import Optional, Tuple
import httpx
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
async def _resolve_profile_identity(
session_token: Optional[str] = None,
unified_id: Optional[str] = None,
channel: Optional[str] = None,
channel_user_id: Optional[str] = None,
entry_channel: Optional[str] = None,
chat_id: Optional[str] = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
"""Возвращает (unified_id, contact_id, phone, chat_id). При ошибке — HTTPException(401/400)."""
contact_id: Optional[str] = None
phone: Optional[str] = None
if not unified_id and channel and channel_user_id:
try:
from app.api.session import get_session_by_channel_user
session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
if session_data:
unified_id = session_data.get("unified_id")
contact_id = session_data.get("contact_id")
phone = session_data.get("phone")
if chat_id is None:
chat_id = session_data.get("chat_id")
except Exception as e:
logger.warning("Ошибка чтения сессии по channel: %s", e)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
if not unified_id and session_token:
try:
from app.api.session import SessionVerifyRequest, verify_session
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
if getattr(verify_res, "valid", False):
unified_id = getattr(verify_res, "unified_id", None)
contact_id = getattr(verify_res, "contact_id", None)
phone = getattr(verify_res, "phone", None)
if chat_id is None:
chat_id = getattr(verify_res, "chat_id", None)
if not unified_id:
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
except HTTPException:
raise
except Exception as e:
logger.warning("Ошибка верификации сессии для профиля: %s", e)
raise HTTPException(status_code=401, detail="Сессия недействительна")
if not unified_id:
raise HTTPException(
status_code=400,
detail="Укажите session_token, (channel + channel_user_id) или unified_id",
)
return unified_id, contact_id, phone, chat_id
class ProfileContactRequest(BaseModel):
"""Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id."""
session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)")
unified_id: Optional[str] = Field(None, description="Unified ID пользователя в CRM")
channel: Optional[str] = Field(None, description="Канал: tg | max (для поиска сессии в Redis)")
channel_user_id: Optional[str] = Field(None, description="ID пользователя в канале (tg/max)")
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web")
chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)")
class ProfileContactUpdateRequest(BaseModel):
"""Обновление контакта: session_token обязателен; остальные поля — редактируемые (все обязательны на фронте, кроме phone)."""
session_token: str = Field(..., description="Токен сессии")
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
last_name: str = Field("", description="Фамилия")
first_name: str = Field("", description="Имя")
middle_name: str = Field("", description="Отчество")
birth_date: str = Field("", description="Дата рождения")
birth_place: str = Field("", description="Место рождения")
inn: str = Field("", description="ИНН")
email: str = Field("", description="Email")
registration_address: str = Field("", description="Адрес регистрации")
mailing_address: str = Field("", description="Почтовый адрес")
bank_for_compensation: str = Field("", description="Банк для возмещения")
phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)")
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
@router.get("/dadata/address")
async def get_dadata_address_suggestions(
query: str = Query(..., min_length=1, description="Строка поиска адреса"),
count: int = Query(10, ge=1, le=20),
):
"""
Подсказки адресов через DaData (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env).
Возвращает список { value, unrestricted_value } для подстановки в форму профиля.
"""
api_key = (getattr(settings, "forma_dadata_api_key", None) or "").strip()
secret = (getattr(settings, "forma_dadata_secret", None) or "").strip()
if not api_key or not secret:
raise HTTPException(status_code=503, detail="DaData не настроен (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET)")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
DADATA_SUGGEST_URL,
json={"query": query.strip(), "count": count},
headers={
"Authorization": f"Token {api_key}",
"X-Secret": secret,
"Content-Type": "application/json",
},
)
if response.status_code != 200:
logger.warning("DaData address suggest вернул %s: %s", response.status_code, response.text[:300])
return {"suggestions": []}
data = response.json()
suggestions = data.get("suggestions") or []
return {"suggestions": [{"value": s.get("value", ""), "unrestricted_value": s.get("unrestricted_value", "")} for s in suggestions]}
except httpx.TimeoutException:
return {"suggestions": []}
except Exception as e:
logger.exception("Ошибка DaData suggest: %s", e)
return {"suggestions": []}
@router.get("/contact")
async def get_profile_contact(
session_token: Optional[str] = Query(None, description="Токен сессии"),
unified_id: Optional[str] = Query(None, description="Unified ID"),
channel: Optional[str] = Query(None, description="Канал: tg | max"),
channel_user_id: Optional[str] = Query(None, description="ID пользователя в канале"),
entry_channel: Optional[str] = Query(None, description="Канал: telegram | max | web"),
chat_id: Optional[str] = Query(None, description="Telegram/Max user id"),
):
"""
Получить контактные данные из CRM через n8n webhook.
Передайте session_token, (channel + channel_user_id) или unified_id.
"""
return await _fetch_contact(
session_token=session_token,
unified_id=unified_id,
channel=channel,
channel_user_id=channel_user_id,
entry_channel=entry_channel,
chat_id=chat_id,
)
@router.post("/contact")
async def post_profile_contact(body: ProfileContactRequest):
"""То же по телу запроса."""
return await _fetch_contact(
session_token=body.session_token,
unified_id=body.unified_id,
channel=body.channel,
channel_user_id=body.channel_user_id,
entry_channel=body.entry_channel,
chat_id=body.chat_id,
)
async def _fetch_contact(
session_token: Optional[str] = None,
unified_id: Optional[str] = None,
channel: Optional[str] = None,
channel_user_id: Optional[str] = None,
entry_channel: Optional[str] = None,
chat_id: Optional[str] = None,
) -> dict:
webhook_url = getattr(settings, "n8n_contact_webhook", None) or ""
if not webhook_url:
raise HTTPException(
status_code=503,
detail="N8N_CONTACT_WEBHOOK не настроен",
)
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
session_token=session_token,
unified_id=unified_id,
channel=channel,
channel_user_id=channel_user_id,
entry_channel=entry_channel,
chat_id=chat_id,
)
payload: dict = {
"unified_id": unified_id,
"entry_channel": (entry_channel or "web").strip() or "web",
}
if session_token:
payload["session_token"] = session_token
if contact_id is not None:
payload["contact_id"] = contact_id
if phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except Exception as e:
logger.exception("Ошибка вызова N8N_CONTACT_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Сервис контактов временно недоступен")
if response.status_code != 200:
logger.warning("N8N contact webhook вернул %s: %s", response.status_code, response.text[:500])
raise HTTPException(
status_code=502,
detail="Сервис контактов вернул ошибку",
)
try:
data = response.json()
except Exception:
data = response.text or ""
if isinstance(data, list):
return {"items": data if data else []}
if isinstance(data, dict):
if "items" in data and isinstance(data["items"], list):
return {"items": data["items"]}
if "contact" in data:
c = data["contact"]
return {"items": c if isinstance(c, list) else [c] if c else []}
if "data" in data and isinstance(data["data"], list):
return {"items": data["data"]}
if data and isinstance(data, dict):
return {"items": [data]}
return {"items": []}
return {"items": []}
@router.post("/contact/update")
async def post_profile_contact_update(body: ProfileContactUpdateRequest):
"""
Обновить контакт в CRM через N8N_PROFILE_UPDATE_WEBHOOK.
Вызывается с фронта при verification="0". Сессия проверяется по session_token.
"""
webhook_url = (getattr(settings, "n8n_profile_update_webhook", None) or "").strip()
if not webhook_url:
raise HTTPException(
status_code=503,
detail="N8N_PROFILE_UPDATE_WEBHOOK не настроен",
)
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
session_token=body.session_token,
entry_channel=body.entry_channel,
chat_id=None,
)
payload: dict = {
"unified_id": unified_id,
"entry_channel": (body.entry_channel or "web").strip() or "web",
"session_token": body.session_token,
"last_name": (body.last_name or "").strip(),
"first_name": (body.first_name or "").strip(),
"middle_name": (body.middle_name or "").strip(),
"birth_date": (body.birth_date or "").strip(),
"birth_place": (body.birth_place or "").strip(),
"inn": (body.inn or "").strip(),
"email": (body.email or "").strip(),
"registration_address": (body.registration_address or "").strip(),
"mailing_address": (body.mailing_address or "").strip(),
"bank_for_compensation": (body.bank_for_compensation or "").strip(),
}
if contact_id is not None:
payload["contact_id"] = contact_id
if body.phone is not None and str(body.phone).strip():
payload["phone"] = str(body.phone).strip()
elif phone is not None:
payload["phone"] = phone
if chat_id is not None and str(chat_id).strip():
payload["chat_id"] = str(chat_id).strip()
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
)
except Exception as e:
logger.exception("Ошибка вызова N8N_PROFILE_UPDATE_WEBHOOK: %s", e)
raise HTTPException(status_code=502, detail="Не удалось сохранить профиль, попробуйте позже")
if response.status_code < 200 or response.status_code >= 300:
logger.warning("N8N profile update webhook вернул %s: %s", response.status_code, response.text[:500])
raise HTTPException(
status_code=502,
detail="Не удалось сохранить профиль, попробуйте позже",
)
result: dict = {"success": True}
try:
data = response.json()
if isinstance(data, dict) and data:
result.update(data)
except Exception:
pass
return result

View File

@@ -2,7 +2,8 @@
Session management API endpoints
Обеспечивает управление сессиями пользователей через Redis:
- Верификация существующей сессии
- Верификация по session_token или по (channel, channel_user_id)
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
- Logout (удаление сессии)
"""
@@ -15,6 +16,8 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import redis.asyncio as redis
from ..services.redis_service import redis_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/session", tags=["session"])
@@ -22,13 +25,103 @@ router = APIRouter(prefix="/api/v1/session", tags=["session"])
# Redis connection (используем существующее подключение)
redis_client: Optional[redis.Redis] = None
# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL.
SESSION_BY_CHANNEL_TTL_HOURS = 24
def init_redis(redis_conn: redis.Redis):
"""Initialize Redis connection"""
def init_redis(redis_conn: Optional[redis.Redis]):
"""Initialize Redis connection (локальный Redis для сессий). None при shutdown."""
global redis_client
redis_client = redis_conn
def _session_key_by_channel(channel: str, channel_user_id: str) -> str:
"""Ключ Redis для сессии по каналу и id пользователя в канале."""
return f"session:{channel}:{channel_user_id}"
async def set_session_by_channel_user(
channel: str,
channel_user_id: str,
data: Dict[str, Any],
) -> None:
"""
Записать сессию в Redis по ключу session:{channel}:{channel_user_id}.
data: unified_id, phone, contact_id, chat_id, has_drafts, ...
"""
if not redis_client:
raise HTTPException(status_code=500, detail="Redis connection not initialized")
key = _session_key_by_channel(channel, channel_user_id)
payload = {
"unified_id": data.get("unified_id") or "",
"phone": data.get("phone") or "",
"contact_id": data.get("contact_id") or "",
"chat_id": str(channel_user_id),
"has_drafts": data.get("has_drafts", False),
"verified_at": datetime.utcnow().isoformat(),
}
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
body = json.dumps(payload)
if ttl:
await redis_client.setex(key, ttl, body)
else:
await redis_client.set(key, body)
# Дублируем сессию в внешний Redis, чтобы n8n мог читать по тем же ключам
try:
if redis_service.client:
if ttl:
await redis_service.client.setex(key, ttl, body)
else:
await redis_service.client.set(key, body)
except Exception as e:
logger.warning("Не удалось продублировать сессию в внешний Redis (channel): %s", e)
logger.info("Сессия записана: %s, unified_id=%s", key, payload.get("unified_id"))
async def get_session_by_channel_user(channel: str, channel_user_id: str) -> Optional[Dict[str, Any]]:
"""Прочитать сессию из Redis по channel и channel_user_id. Если нет — None."""
if not redis_client:
return None
key = _session_key_by_channel(channel, channel_user_id)
raw = await redis_client.get(key)
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
async def set_session_by_token(session_token: str, data: Dict[str, Any]) -> None:
"""Записать сессию в Redis по ключу session:{session_token} (для совместимости с profile/claims)."""
if not redis_client:
return
key = f"session:{session_token}"
payload = {
"unified_id": data.get("unified_id") or "",
"phone": data.get("phone") or "",
"contact_id": data.get("contact_id") or "",
"chat_id": data.get("chat_id") or "",
"has_drafts": data.get("has_drafts", False),
"verified_at": datetime.utcnow().isoformat(),
}
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
body = json.dumps(payload)
if ttl:
await redis_client.setex(key, ttl, body)
else:
await redis_client.set(key, body)
# Дублируем сессию по токену в внешний Redis для доступа из n8n
try:
if redis_service.client:
if ttl:
await redis_service.client.setex(key, ttl, body)
else:
await redis_service.client.set(key, body)
except Exception as e:
logger.warning("Не удалось продублировать сессию в внешний Redis (token): %s", e)
class SessionVerifyRequest(BaseModel):
session_token: str
@@ -39,10 +132,16 @@ class SessionVerifyResponse(BaseModel):
unified_id: Optional[str] = None
phone: Optional[str] = None
contact_id: Optional[str] = None
chat_id: Optional[str] = None # telegram_user_id или max_user_id
verified_at: Optional[str] = None
expires_in_seconds: Optional[int] = None
class SessionVerifyByChannelRequest(BaseModel):
channel: str # tg | max
channel_user_id: str
class SessionLogoutRequest(BaseModel):
session_token: str
@@ -92,6 +191,7 @@ async def verify_session(request: SessionVerifyRequest):
unified_id=session_data.get('unified_id'),
phone=session_data.get('phone'),
contact_id=session_data.get('contact_id'),
chat_id=session_data.get('chat_id'),
verified_at=session_data.get('verified_at'),
expires_in_seconds=ttl if ttl > 0 else None
)
@@ -143,20 +243,47 @@ async def logout_session(request: SessionLogoutRequest):
raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}")
@router.post("/verify-by-channel", response_model=SessionVerifyResponse)
async def verify_session_by_channel(request: SessionVerifyByChannelRequest):
"""
Проверить сессию по channel и channel_user_id (ключ Redis: session:{channel}:{channel_user_id}).
Используется, когда клиент не хранит session_token и передаёт channel + channel_user_id.
"""
try:
data = await get_session_by_channel_user(request.channel, request.channel_user_id)
if not data:
return SessionVerifyResponse(success=True, valid=False)
ttl = await redis_client.ttl(_session_key_by_channel(request.channel, request.channel_user_id)) if redis_client else 0
return SessionVerifyResponse(
success=True,
valid=True,
unified_id=data.get("unified_id"),
phone=data.get("phone"),
contact_id=data.get("contact_id"),
chat_id=data.get("chat_id"),
verified_at=data.get("verified_at"),
expires_in_seconds=ttl if ttl > 0 else None,
)
except Exception as e:
logger.exception("Ошибка verify-by-channel: %s", e)
raise HTTPException(status_code=500, detail="Ошибка проверки сессии")
class SessionCreateRequest(BaseModel):
session_token: str
unified_id: str
phone: str
contact_id: str
ttl_hours: int = 24
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
@router.post("/create")
async def create_session(request: SessionCreateRequest):
"""
Создать новую сессию (вызывается после успешной SMS верификации)
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
Обычно вызывается из Step1Phone после получения данных от n8n.
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
"""
try:
if not redis_client:
@@ -171,6 +298,8 @@ async def create_session(request: SessionCreateRequest):
'verified_at': datetime.utcnow().isoformat(),
'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).isoformat()
}
if request.chat_id is not None:
session_data['chat_id'] = str(request.chat_id).strip()
# Сохраняем в Redis с TTL
await redis_client.setex(

View File

@@ -15,14 +15,19 @@ async def send_sms_code(request: SMSSendRequest):
- **phone**: Номер телефона в формате +79001234567
"""
from ..config import settings
code = await sms_service.send_verification_code(request.phone)
if code:
return {
response = {
"success": True,
"message": "Код отправлен на указанный номер",
"debug_code": code if sms_service.enabled else None # Показываем код только в dev
"message": "Код отправлен на указанный номер"
}
# 🔧 DEV MODE: Возвращаем debug_code только в development
if settings.debug or settings.app_env == "development":
response["debug_code"] = code
return response
else:
raise HTTPException(
status_code=429,

699
backend/app/api/support.py Normal file
View File

@@ -0,0 +1,699 @@
"""
Support API: диалог поддержки (треды + сообщения).
POST /api/v1/support — multipart, создание/поиск треда, запись сообщения user, прокси в n8n.
GET /api/v1/support/thread — получить тред и сообщения.
GET /api/v1/support/stream — SSE: один канал на юзера, события из Postgres NOTIFY.
POST /api/v1/support/incoming — webhook для n8n: добавить сообщение от поддержки.
GET /api/v1/support/limits — лимиты вложений.
"""
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set, Tuple
import asyncpg
import httpx
from fastapi import APIRouter, Header, HTTPException, Query, Request
from starlette.responses import StreamingResponse
from ..config import settings
from ..services.database import db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/support", tags=["support"])
# Реестр SSE по unified_id: кому пушить при NOTIFY (один канал support_events)
_support_stream_registry: Dict[str, Set[asyncio.Queue]] = {}
_support_notify_inbox: asyncio.Queue = asyncio.Queue()
_SUPPORT_EVENTS_CHANNEL = "support_events"
def _get_support_webhook() -> str:
url = (getattr(settings, "n8n_support_webhook", None) or "").strip()
if not url:
raise HTTPException(
status_code=503,
detail="N8N_SUPPORT_WEBHOOK не настроен",
)
return url
async def _resolve_session(
session_token: Optional[str] = None,
channel: Optional[str] = None,
channel_user_id: Optional[str] = None,
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Возвращает (unified_id, phone, email, session_id)."""
unified_id: Optional[str] = None
phone: Optional[str] = None
session_id: Optional[str] = session_token
if channel and channel_user_id:
try:
from .session import get_session_by_channel_user
data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
if data:
unified_id = data.get("unified_id")
phone = data.get("phone")
if session_token is None:
session_id = data.get("session_token")
except Exception as e:
logger.warning("Ошибка чтения сессии по channel: %s", e)
if not unified_id and session_token:
try:
from .session import SessionVerifyRequest, verify_session
res = await verify_session(SessionVerifyRequest(session_token=session_token))
if getattr(res, "valid", False):
unified_id = getattr(res, "unified_id", None)
phone = getattr(res, "phone", None)
except HTTPException:
raise
except Exception as e:
logger.warning("Ошибка верификации сессии для support: %s", e)
raise HTTPException(status_code=401, detail="Сессия недействительна")
if not unified_id:
raise HTTPException(
status_code=401,
detail="Укажите session_token или (channel + channel_user_id)",
)
return (unified_id, phone, None, session_id or session_token)
def _check_attachment_limits(
files: List[Tuple[str, bytes, Optional[str]]],
) -> None:
"""Проверяет лимиты вложений; 0 или пусто = не проверять."""
max_count = getattr(settings, "support_attachments_max_count", 0) or 0
max_bytes = settings.support_attachments_max_size_bytes
allowed = (getattr(settings, "support_attachments_allowed_types", None) or "").strip()
if max_count > 0 and len(files) > max_count:
raise HTTPException(
status_code=400,
detail=f"Слишком много файлов: максимум {max_count}",
)
if max_bytes > 0:
for name, content, _ in files:
if len(content) > max_bytes:
raise HTTPException(
status_code=400,
detail=f"Файл {name} превышает допустимый размер ({max_bytes // (1024*1024)} МБ)",
)
if allowed:
allowed_list = [x.strip().lower() for x in allowed.split(",") if x.strip()]
for filename, content, mime in files:
mime = (mime or "").strip().lower()
ext = ""
if "." in filename:
ext = "." + filename.rsplit(".", 1)[-1].lower()
ok = False
for a in allowed_list:
if a.startswith("."):
if ext == a:
ok = True
break
elif "/" in a:
if mime == a or (a.endswith("/*") and mime.split("/")[0] == a.split("/")[0]):
ok = True
break
else:
if ext == f".{a}" or mime == a:
ok = True
break
if not ok:
raise HTTPException(
status_code=400,
detail=f"Тип файла «{filename}» не разрешён. Допустимые: {allowed}",
)
def _uuid_str(val: Any) -> str:
if val is None:
return ""
return str(val)
async def _get_or_create_thread(unified_id: str, claim_id: Optional[str], source: str) -> str:
"""Найти тред по (unified_id, claim_id) или создать. Возвращает thread_id (UUID str)."""
if claim_id and claim_id.strip():
row = await db.fetch_one(
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
unified_id,
claim_id.strip(),
)
else:
row = await db.fetch_one(
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
unified_id,
)
if row:
return _uuid_str(row["id"])
thread_id = uuid.uuid4()
await db.execute(
"INSERT INTO clpr_support_threads (id, unified_id, claim_id, source) VALUES ($1, $2, $3, $4)",
thread_id,
unified_id,
claim_id.strip() if claim_id and claim_id.strip() else None,
source or "bar",
)
return str(thread_id)
def _support_notify_callback(conn: Any, pid: int, channel: str, payload: str) -> None:
"""Вызывается asyncpg при NOTIFY support_events. Кладём payload во inbox."""
try:
_support_notify_inbox.put_nowait(payload)
except Exception as e:
logger.warning("Support notify inbox put: %s", e)
async def _run_support_listener() -> None:
"""
Один подписчик на Postgres NOTIFY support_events.
Держит соединение, слушает канал, раскидывает по unified_id в реестр.
"""
conn: Optional[asyncpg.Connection] = None
try:
conn = await asyncpg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
database=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
)
await conn.execute("LISTEN " + _SUPPORT_EVENTS_CHANNEL)
conn.add_listener(_SUPPORT_EVENTS_CHANNEL, _support_notify_callback)
logger.info("Support LISTEN %s started", _SUPPORT_EVENTS_CHANNEL)
while True:
payload = await _support_notify_inbox.get()
try:
data = json.loads(payload)
u_id = data.get("unified_id")
if not u_id:
continue
queues = _support_stream_registry.get(u_id)
if not queues:
continue
for q in list(queues):
try:
q.put_nowait(data)
except asyncio.QueueFull:
pass
except Exception as e:
logger.warning("Support stream put: %s", e)
except json.JSONDecodeError as e:
logger.warning("Support notify payload not JSON: %s", e)
except Exception as e:
logger.exception("Support listener dispatch: %s", e)
except asyncio.CancelledError:
logger.info("Support listener cancelled")
except Exception as e:
logger.exception("Support listener error: %s", e)
finally:
if conn and not conn.is_closed():
await conn.close()
logger.info("Support LISTEN stopped")
@router.get("/limits")
async def get_support_limits():
"""Лимиты вложений (0/пусто = без ограничений)."""
max_count = getattr(settings, "support_attachments_max_count", 0) or 0
max_bytes = settings.support_attachments_max_size_bytes
allowed = (getattr(settings, "support_attachments_allowed_types", None) or "").strip()
unlimited = max_count == 0 and max_bytes == 0 and not allowed
return {
"max_count": max_count,
"max_size_per_file": max_bytes,
"allowed_types": allowed,
"unlimited": unlimited,
}
@router.get("/threads")
async def get_support_threads(
session_token: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
channel_user_id: Optional[str] = Query(None),
):
"""
Список всех тредов пользователя для экрана «Мои обращения».
Сессия: session_token или channel + channel_user_id.
"""
unified_id, _, _, _ = await _resolve_session(
session_token=session_token, channel=channel, channel_user_id=channel_user_id
)
rows = await db.fetch_all(
"""
SELECT
t.id,
t.claim_id,
t.source,
t.ticket_id,
t.created_at,
t.updated_at,
(SELECT m.body FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1) AS last_body,
(SELECT m.created_at FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1) AS last_at,
(SELECT COUNT(*)::int FROM clpr_support_messages m WHERE m.thread_id = t.id) AS messages_count,
(SELECT COUNT(*)::int FROM clpr_support_messages m
WHERE m.thread_id = t.id AND m.direction = 'support'
AND m.created_at > COALESCE(
(SELECT r.last_read_at FROM clpr_support_reads r WHERE r.unified_id = t.unified_id AND r.thread_id = t.id),
'1970-01-01'::timestamptz
)) AS unread_count
FROM clpr_support_threads t
WHERE t.unified_id = $1
ORDER BY COALESCE(
(SELECT m.created_at FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1),
t.updated_at,
t.created_at
) DESC
""",
unified_id,
)
threads = []
for r in rows:
last_at = r.get("last_at")
if hasattr(last_at, "isoformat"):
last_at = last_at.isoformat()
elif last_at is not None:
last_at = str(last_at)
threads.append({
"thread_id": _uuid_str(r["id"]),
"claim_id": str(r["claim_id"]).strip() if r.get("claim_id") else None,
"source": str(r.get("source") or "bar"),
"ticket_id": str(r["ticket_id"]) if r.get("ticket_id") else None,
"created_at": r["created_at"].isoformat() if hasattr(r.get("created_at"), "isoformat") else str(r.get("created_at") or ""),
"updated_at": r["updated_at"].isoformat() if hasattr(r.get("updated_at"), "isoformat") else str(r.get("updated_at") or ""),
"last_body": (r.get("last_body") or "")[:200] if r.get("last_body") else None,
"last_at": last_at,
"messages_count": r.get("messages_count") or 0,
"unread_count": r.get("unread_count") or 0,
})
return {"threads": threads}
@router.get("/unread-count")
async def get_support_unread_count(
session_token: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
channel_user_id: Optional[str] = Query(None),
):
"""Суммарное число непрочитанных сообщений от поддержки (для бейджа в баре)."""
unified_id, _, _, _ = await _resolve_session(
session_token=session_token, channel=channel, channel_user_id=channel_user_id
)
row = await db.fetch_one(
"""
SELECT COALESCE(SUM(cnt), 0)::int AS total
FROM (
SELECT COUNT(*)::int AS cnt
FROM clpr_support_threads t
JOIN clpr_support_messages m ON m.thread_id = t.id AND m.direction = 'support'
WHERE t.unified_id = $1
AND m.created_at > COALESCE(
(SELECT r.last_read_at FROM clpr_support_reads r WHERE r.unified_id = t.unified_id AND r.thread_id = t.id),
'1970-01-01'::timestamptz
)
GROUP BY t.id
) s
""",
unified_id,
)
total = (row and row.get("total")) or 0
return {"unread_count": total}
@router.post("/read")
async def mark_support_thread_read(
request: Request,
session_token: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
channel_user_id: Optional[str] = Query(None),
):
"""
Отметить тред как прочитанный (пользователь открыл чат).
Тело JSON: { "thread_id": "..." } или query thread_id= / claim_id=.
"""
unified_id, _, _, _ = await _resolve_session(
session_token=session_token, channel=channel, channel_user_id=channel_user_id
)
thread_id = request.query_params.get("thread_id")
claim_id = request.query_params.get("claim_id")
if not thread_id:
try:
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
except Exception:
body = {}
thread_id = body.get("thread_id")
if not thread_id:
claim_id = claim_id or body.get("claim_id")
if claim_id and not thread_id:
cid = str(claim_id).strip()
if cid:
row = await db.fetch_one(
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
unified_id,
cid,
)
else:
row = await db.fetch_one(
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
unified_id,
)
if row:
thread_id = str(row["id"])
if not thread_id:
raise HTTPException(status_code=400, detail="thread_id или claim_id обязателен")
try:
thread_uuid = uuid.UUID(thread_id)
except Exception:
raise HTTPException(status_code=400, detail="Некорректный thread_id")
# Проверяем, что тред принадлежит пользователю
row = await db.fetch_one(
"SELECT id FROM clpr_support_threads WHERE id = $1 AND unified_id = $2",
thread_uuid,
unified_id,
)
if not row:
raise HTTPException(status_code=404, detail="Тред не найден")
await db.execute(
"""
INSERT INTO clpr_support_reads (unified_id, thread_id, last_read_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (unified_id, thread_id) DO UPDATE SET last_read_at = CURRENT_TIMESTAMP
""",
unified_id,
thread_uuid,
)
return {"success": True}
@router.get("/thread")
async def get_support_thread(
claim_id: Optional[str] = Query(None),
session_token: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
channel_user_id: Optional[str] = Query(None),
):
"""
Получить тред поддержки и сообщения. Query: claim_id (опционально).
Сессия: session_token или channel + channel_user_id.
"""
unified_id, _, _, _ = await _resolve_session(
session_token=session_token, channel=channel, channel_user_id=channel_user_id
)
cid = claim_id.strip() if claim_id and str(claim_id).strip() else None
if cid:
row = await db.fetch_one(
"SELECT id, ticket_id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
unified_id,
cid,
)
else:
row = await db.fetch_one(
"SELECT id, ticket_id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
unified_id,
)
if not row:
return {"thread_id": None, "messages": [], "ticket_id": None}
thread_id = _uuid_str(row["id"])
ticket_id = row.get("ticket_id")
if ticket_id is not None:
ticket_id = str(ticket_id)
rows = await db.fetch_all(
"SELECT id, direction, body, attachments, created_at FROM clpr_support_messages WHERE thread_id = $1 ORDER BY created_at ASC",
row["id"],
)
messages = []
for r in rows:
att = r.get("attachments")
if att is not None and not isinstance(att, list):
try:
att = json.loads(att) if isinstance(att, str) else att
except Exception:
att = []
messages.append({
"id": _uuid_str(r["id"]),
"direction": r["direction"],
"body": r["body"] or "",
"attachments": att or [],
"created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else str(r["created_at"]),
})
return {
"thread_id": thread_id,
"messages": messages,
"ticket_id": ticket_id,
}
@router.get("/stream")
async def support_stream(
session_token: Optional[str] = Query(None),
channel: Optional[str] = Query(None),
channel_user_id: Optional[str] = Query(None),
):
"""
SSE: один поток на пользователя по unified_id. События приходят из Postgres NOTIFY (триггер на clpr_support_messages).
Query: session_token или channel + channel_user_id.
"""
unified_id, _, _, _ = await _resolve_session(
session_token=session_token, channel=channel, channel_user_id=channel_user_id
)
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
if unified_id not in _support_stream_registry:
_support_stream_registry[unified_id] = set()
_support_stream_registry[unified_id].add(queue)
async def event_gen():
try:
yield f"data: {json.dumps({'event': 'connected', 'unified_id': unified_id}, ensure_ascii=False)}\n\n"
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
# Формат как в SupportChat: id, direction, body, attachments, created_at
created_at = msg.get("created_at")
if hasattr(created_at, "isoformat"):
created_at = created_at.isoformat()
elif created_at is not None:
created_at = str(created_at)
event = {
"event": "support_message",
"message": {
"id": str(msg.get("message_id", "")),
"direction": msg.get("direction", "support"),
"body": msg.get("body", ""),
"attachments": json.loads(msg.get("attachments", "[]")) if isinstance(msg.get("attachments"), str) else (msg.get("attachments") or []),
"created_at": created_at,
},
"thread_id": str(msg.get("thread_id", "")),
}
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
_support_stream_registry.get(unified_id, set()).discard(queue)
if unified_id in _support_stream_registry and not _support_stream_registry[unified_id]:
del _support_stream_registry[unified_id]
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("")
async def submit_support(request: Request):
"""
Отправить сообщение в поддержку. Multipart: message, subject?, claim_id?, source, thread_id?,
session_token (или channel + channel_user_id), файлы. Создаёт/находит тред, пишет сообщение, проксирует в n8n.
"""
form = await request.form()
message = form.get("message")
if not message or not str(message).strip():
raise HTTPException(status_code=400, detail="Поле message обязательно")
message = str(message).strip()
subject = form.get("subject")
subject = str(subject).strip() if subject else None
claim_id = form.get("claim_id")
claim_id = str(claim_id).strip() if claim_id else None
source = form.get("source")
source = str(source).strip() if source else "bar"
thread_id_param = form.get("thread_id")
thread_id_param = str(thread_id_param).strip() if thread_id_param else None
session_token = form.get("session_token")
session_token = str(session_token).strip() if session_token else None
channel = form.get("channel")
channel = str(channel).strip() if channel else None
channel_user_id = form.get("channel_user_id")
channel_user_id = str(channel_user_id).strip() if channel_user_id else None
file_items: List[Tuple[str, bytes, Optional[str]]] = []
for key, value in form.multi_items():
if hasattr(value, "read") and hasattr(value, "filename"):
content = await value.read()
file_items.append((value.filename or key, content, getattr(value, "content_type", None)))
_check_attachment_limits(file_items)
unified_id, phone, email, session_id = await _resolve_session(
session_token=session_token,
channel=channel,
channel_user_id=channel_user_id,
)
thread_id = await _get_or_create_thread(unified_id, claim_id or None, source)
attachments_json = json.dumps([{"filename": fn} for fn, _, _ in file_items])
message_id = uuid.uuid4()
await db.execute(
"INSERT INTO clpr_support_messages (id, thread_id, direction, body, attachments) VALUES ($1, $2, 'user', $3, $4)",
message_id,
uuid.UUID(thread_id),
message,
attachments_json,
)
row = await db.fetch_one("SELECT ticket_id FROM clpr_support_threads WHERE id = $1", uuid.UUID(thread_id))
ticket_id = str(row["ticket_id"]) if row and row.get("ticket_id") else None
webhook_url = _get_support_webhook()
timestamp = datetime.now(timezone.utc).isoformat()
data: Dict[str, str] = {
"message": message,
"source": source or "bar",
"unified_id": unified_id or "",
"phone": (phone or "").strip(),
"email": (email or "").strip(),
"session_id": (session_id or "").strip(),
"timestamp": timestamp,
"thread_id": thread_id,
}
if subject:
data["subject"] = subject
if claim_id:
data["claim_id"] = claim_id
if ticket_id:
data["ticket_id"] = ticket_id
files_for_upload: Dict[str, Tuple[str, bytes, Optional[str]]] = {}
for i, (filename, content, content_type) in enumerate(file_items):
key = f"attachments[{i}]" if len(file_items) > 1 else "attachments"
if key in files_for_upload:
key = f"attachments[{i}]"
files_for_upload[key] = (filename, content, content_type or "application/octet-stream")
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
webhook_url,
data=data,
files=files_for_upload or None,
)
except httpx.TimeoutException:
logger.error("Таймаут вызова N8N support webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к сервису поддержки")
except Exception as e:
logger.exception("Ошибка вызова N8N support webhook: %s", e)
raise HTTPException(status_code=502, detail="Сервис поддержки временно недоступен")
if response.status_code != 200:
logger.warning("N8N support webhook вернул %s: %s", response.status_code, response.text[:500])
raise HTTPException(status_code=502, detail="Сервис поддержки вернул ошибку")
try:
resp_json = response.json()
if isinstance(resp_json, dict) and resp_json.get("ticket_id"):
tid = str(resp_json.get("ticket_id")).strip()
if tid:
await db.execute(
"UPDATE clpr_support_threads SET ticket_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2",
tid,
uuid.UUID(thread_id),
)
except Exception:
pass
return {
"success": True,
"thread_id": thread_id,
"message_id": str(message_id),
}
@router.post("/incoming")
async def support_incoming(
request: Request,
x_support_incoming_secret: Optional[str] = Header(None, alias="X-Support-Incoming-Secret"),
):
"""
Webhook для n8n: добавить сообщение от поддержки в тред.
Тело: JSON { "thread_id" или "ticket_id", "body", "attachments?" }.
Заголовок X-Support-Incoming-Secret должен совпадать с SUPPORT_INCOMING_SECRET (если задан).
"""
secret = (getattr(settings, "support_incoming_secret", None) or "").strip()
if secret:
header_secret = x_support_incoming_secret or request.query_params.get("secret") or ""
if header_secret.strip() != secret:
raise HTTPException(status_code=403, detail="Invalid secret")
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="JSON body required")
thread_id = body.get("thread_id")
ticket_id = body.get("ticket_id")
msg_body = (body.get("body") or "").strip()
attachments = body.get("attachments")
if isinstance(attachments, list):
attachments = json.dumps(attachments)
else:
attachments = "[]"
if not thread_id and not ticket_id:
raise HTTPException(status_code=400, detail="thread_id or ticket_id required")
if ticket_id and not thread_id:
row = await db.fetch_one("SELECT id FROM clpr_support_threads WHERE ticket_id = $1", str(ticket_id))
if not row:
raise HTTPException(status_code=404, detail="Thread not found by ticket_id")
thread_id = str(row["id"])
try:
thread_uuid = uuid.UUID(thread_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid thread_id")
msg_id = uuid.uuid4()
await db.execute(
"INSERT INTO clpr_support_messages (id, thread_id, direction, body, attachments) VALUES ($1, $2, 'support', $3, $4)",
msg_id,
thread_uuid,
msg_body,
attachments,
)
logger.info("Support incoming message added: thread_id=%s", thread_id)
return {"success": True, "message_id": str(msg_id)}

View File

@@ -0,0 +1,176 @@
"""
Telegram Mini App (WebApp) auth endpoint.
/api/v1/tg/auth:
- Принимает init_data от Telegram WebApp и (опционально) session_token
- Валидирует init_data и извлекает данные пользователя Telegram
- Проксирует telegram_user_id в n8n для получения unified_id/контакта
- Создаёт сессию в Redis через существующий /api/v1/session/create
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
from ..config import settings
from . import n8n_proxy
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/tg", tags=["Telegram"])
class TelegramAuthRequest(BaseModel):
init_data: str
session_token: Optional[str] = None
class TelegramAuthResponse(BaseModel):
success: bool
session_token: Optional[str] = None
unified_id: Optional[str] = None
contact_id: Optional[str] = None
phone: Optional[str] = None
has_drafts: Optional[bool] = None
need_contact: Optional[bool] = None
def _generate_session_token() -> str:
"""Генерирует новый session_token в формате, похожем на текущий веб-флоу."""
import uuid
return f"sess-{uuid.uuid4()}"
@router.post("/auth", response_model=TelegramAuthResponse)
async def telegram_auth(request: TelegramAuthRequest):
"""
Авторизация пользователя через Telegram WebApp.
Ничего не ломает в текущем SMS-флоу: это параллельный способ входа.
"""
# Логирование: что пришло на бэкенд
init_data = request.init_data or ""
logger.info(
"[TG] POST /api/v1/tg/auth вызван: init_data длина=%s, session_token передан=%s",
len(init_data),
bool(request.session_token),
)
if not init_data:
logger.warning("[TG] init_data пустой — запрос отклонён")
raise HTTPException(status_code=400, detail="init_data обязателен")
bot_token_configured = bool((getattr(settings, "telegram_bot_token", None) or "").strip())
n8n_webhook_configured = bool((getattr(settings, "n8n_tg_auth_webhook", None) or "").strip())
logger.info("[TG] Конфиг: TELEGRAM_BOT_TOKEN задан=%s, N8N_TG_AUTH_WEBHOOK задан=%s", bot_token_configured, n8n_webhook_configured)
# 1. Валидация и разбор init_data
try:
tg_user = extract_telegram_user(request.init_data)
except TelegramAuthError as e:
logger.warning("[TG] Ошибка валидации initData: %s", e)
raise HTTPException(status_code=400, detail=str(e))
telegram_user_id = tg_user["telegram_user_id"]
logger.info("[TG] Telegram user валиден: id=%s, username=%s", telegram_user_id, tg_user.get("username"))
# 2. Определяем session_token
session_token = request.session_token or _generate_session_token()
# 3. Вызываем n8n через прокси для маппинга telegram_user_id → unified_id
n8n_payload = {
"telegram_user_id": telegram_user_id,
"username": tg_user.get("username"),
"first_name": tg_user.get("first_name"),
"last_name": tg_user.get("last_name"),
"session_token": session_token,
"form_id": "ticket_form",
"init_data": request.init_data, # сырая строка из Telegram (подпись уже проверена)
}
logger.info("[TG] Вызов n8n webhook, payload keys=%s", list(n8n_payload.keys()))
# Используем уже существующий n8n_proxy роут (внутренний вызов)
try:
from fastapi.encoders import jsonable_encoder
# Объект с async .json() для proxy_telegram_auth(request), без Pydantic __root__
class _DummyRequest:
def __init__(self, payload: dict):
self._payload = payload
async def json(self):
return self._payload
dummy_request = _DummyRequest(n8n_payload)
n8n_response = await n8n_proxy.proxy_telegram_auth(dummy_request) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
logger.info("[TG] n8n ответ получен: keys=%s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
except HTTPException:
# Пробрасываем HTTPException наверх
raise
except Exception as e:
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
# Логируем сырой ответ n8n для отладки (ключи и need_contact/unified_id)
logger.info("[TG] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
_result = n8n_data.get("result")
_result_dict = _result if isinstance(_result, dict) else {}
if _result_dict:
logger.info("[TG] n8n result ключи: %s", list(_result_dict.keys()))
# Если n8n вернул need_contact — пользователя нет в базе, мини-апп должен закрыться
_raw = (
n8n_data.get("need_contact")
or _result_dict.get("need_contact")
or n8n_data.get("needContact")
or _result_dict.get("needContact")
)
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
if need_contact:
logger.info("[TG] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
return TelegramAuthResponse(success=False, need_contact=True)
# Ожидаем от n8n как минимум unified_id
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
phone = n8n_data.get("phone") or _result_dict.get("phone")
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
# Нет unified_id = пользователь не найден в базе → тоже возвращаем need_contact, чтобы фронт закрыл мини-апп
if not unified_id:
logger.info("[TG] n8n не вернул unified_id (пользователь не в базе) — возвращаем need_contact=true. Ответ n8n: %s", n8n_data)
return TelegramAuthResponse(success=False, need_contact=True)
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
session_request = session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
chat_id=str(telegram_user_id) if telegram_user_id else None,
)
try:
await session_api.create_session(session_request)
except HTTPException:
# Если ошибка уже обёрнута в HTTPException — пробрасываем как есть
raise
except Exception as e:
logger.exception("❌ Error creating Redis session for Telegram user")
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
return TelegramAuthResponse(
success=True,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone,
has_drafts=has_drafts,
)

View File

@@ -1,15 +1,21 @@
"""
Конфигурация приложения
"""
import os
import json
from pathlib import Path
from pydantic_settings import BaseSettings
from functools import lru_cache
from typing import List
from typing import List, Optional
BASE_DIR = Path(__file__).resolve().parents[2]
ENV_PATH = BASE_DIR / ".env"
# Список CORS, обновляется при изменении .env (чтобы не перезапускать бэкенд)
_cors_origins_live: List[str] = []
_settings_cache: Optional["Settings"] = None
_env_mtime_cache: float = 0
class Settings(BaseSettings):
# ============================================
@@ -57,7 +63,7 @@ class Settings(BaseSettings):
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
# ============================================
# REDIS
# REDIS (внешний — события, буферы, SMS и т.д.)
# ============================================
redis_host: str = "localhost"
redis_port: int = 6379
@@ -65,13 +71,26 @@ class Settings(BaseSettings):
redis_db: int = 0
redis_prefix: str = "ticket_form:"
# Redis для сессий (локальный в Docker — miniapp_redis; снаружи — localhost:6383 или свой)
redis_session_host: str = "localhost"
redis_session_port: int = 6383
redis_session_password: str = ""
redis_session_db: int = 0
@property
def redis_url(self) -> str:
"""Формирует URL для подключения к Redis"""
"""Формирует URL для подключения к Redis (внешний)"""
if self.redis_password:
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
@property
def redis_session_url(self) -> str:
"""URL для локального Redis сессий"""
if self.redis_session_password:
return f"redis://:{self.redis_session_password}@{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
return f"redis://{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
# ============================================
# RABBITMQ
# ============================================
@@ -120,9 +139,17 @@ class Settings(BaseSettings):
aviationstack_base_url: str = "http://api.aviationstack.com/v1"
# ============================================
# NSPK BANKS API
# NSPK BANKS API (и альтернативный BANK_IP из .env)
# ============================================
nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json"
bank_ip: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
bank_api_url: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
# ============================================
# DADATA (подсказки адресов в форме профиля)
# ============================================
forma_dadata_api_key: str = "" # FORMA_DADATA_API_KEY
forma_dadata_secret: str = "" # FORMA_DADATA_SECRET
# ============================================
# SMS SERVICE (SigmaSMS)
@@ -177,8 +204,73 @@ class Settings(BaseSettings):
n8n_api_key: str = "" # Нужно задать в .env
n8n_policy_check_webhook: str = ""
n8n_file_upload_webhook: str = ""
n8n_create_contact_webhook: str = ""
n8n_create_claim_webhook: str = ""
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
n8n_description_webhook: str = "https://n8n.clientright.ru/webhook/ticket_form_description" # Webhook для описания проблемы (переопределяется через N8N_DESCRIPTION_WEBHOOK в .env)
# Консультации: тикеты из CRM (MySQL) — тот же payload, что и у других хуков
n8n_ticket_form_consultation_webhook: str = "" # N8N_TICKET_FORM_CONSULTATION_WEBHOOK в .env
# Подробнее по тикету: session + ticket_id → ответ вебхука (HTML/JSON)
n8n_ticket_form_podrobnee_webhook: str = "" # N8N_TICKET_FORM_PODROBNEE_WEBHOOK в .env
n8n_project_form_podrobnee_webhook: str = "" # N8N_PROJECT_FORM_PODROBNEE_WEBHOOK — детали дела/проекта из CRM по project_id
# Wizard и финальная отправка заявки (create) — один webhook, меняется через .env
n8n_ticket_form_final_webhook: str = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
# Контактные данные из CRM для раздела «Профиль» (массив или пусто)
n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env
n8n_profile_update_webhook: str = "" # N8N_PROFILE_UPDATE_WEBHOOK в .env — обновление профиля (verification=0)
# ============================================
# TELEGRAM BOT
# ============================================
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
def get_telegram_bot_tokens(self) -> List[tuple]:
"""Список (bot_id, token) для проверки подписи Telegram initData. Один токен — [('default', token)]."""
token = (self.telegram_bot_token or "").strip()
if token:
return [("default", token)]
return []
# ============================================
# MAX (мессенджер) — Mini App auth
# ============================================
max_bot_token: str = "" # Токен бота MAX (один бот)
max_bot_tokens: str = "" # Мультибот: JSON {"bot_id": "token", ...}. Если задан — используется вместо max_bot_token.
def get_max_bot_tokens(self) -> List[tuple]:
"""Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)]."""
s = (self.max_bot_tokens or os.environ.get("MAX_BOT_TOKENS") or "").strip()
if s:
try:
d = json.loads(s)
out = [(k, str(v).strip()) for k, v in d.items() if v and str(v).strip()]
if out:
return out
except Exception:
pass
token = (self.max_bot_token or os.environ.get("MAX_BOT_TOKEN") or "").strip()
if token:
return [("default", token)]
return []
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts
# ============================================
# ПОДДЕРЖКА (чат, треды, n8n webhook)
# ============================================
n8n_support_webhook: str = "" # N8N_SUPPORT_WEBHOOK — URL webhook n8n (multipart). Обязателен для отправки сообщений.
support_attachments_max_count: int = 0 # 0 = без ограничений
support_attachments_max_size_mb: int = 0 # 0 = без ограничений
support_attachments_allowed_types: str = "" # пусто = любые (например: .pdf,.jpg,image/*)
support_incoming_secret: str = "" # Секрет для POST /api/v1/support/incoming (n8n → backend)
@property
def support_attachments_max_size_bytes(self) -> int:
if self.support_attachments_max_size_mb <= 0:
return 0
return self.support_attachments_max_size_mb * 1024 * 1024
# ============================================
# LOGGING
@@ -192,9 +284,25 @@ class Settings(BaseSettings):
extra = "ignore" # Игнорируем лишние поля из .env
@lru_cache()
def get_settings() -> Settings:
return Settings()
"""Текущие настройки. При изменении .env подхватываются без перезапуска."""
global _settings_cache, _env_mtime_cache, _cors_origins_live
mtime = os.path.getmtime(ENV_PATH) if ENV_PATH.exists() else 0.0
if _settings_cache is None or mtime > _env_mtime_cache:
_settings_cache = Settings()
_env_mtime_cache = mtime
_cors_origins_live.clear()
_cors_origins_live.extend(_settings_cache.cors_origins_list)
return _settings_cache
def get_cors_origins_live() -> List[str]:
"""
Список CORS origins для middleware; обновляется при изменении .env без перезапуска.
Обработчики, которые используют get_settings() при каждом запросе, тоже видят новые значения.
"""
get_settings() # обновить кеш и _cors_origins_live при изменении .env
return _cors_origins_live
settings = get_settings()

View File

@@ -2,25 +2,114 @@
Ticket Form Intake Platform - FastAPI Backend
"""
from fastapi import FastAPI, Request
import json
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import asyncio
import logging
import time
import uuid
from typing import Any, Dict, Optional, Tuple
from .config import settings
import redis.asyncio as redis
from .config import settings, get_cors_origins_live, get_settings
from .services.database import db
from .services.redis_service import redis_service
from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service
from .services.crm_mysql_service import crm_mysql_service
from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, auth_universal, documents_draft_open, profile, support
from .api import debug_session
# Настройка логирования
# Настройка логирования (уровень из config: LOG_LEVEL=DEBUG для отладки)
import sys
_level = getattr(logging, (getattr(get_settings(), "log_level", None) or "INFO").upper(), logging.INFO)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
level=_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stdout,
)
# Применяем уровень ко всем логгерам приложения
logging.getLogger("app").setLevel(_level)
logger = logging.getLogger(__name__)
logger.info("Backend log level: %s", logging.getLevelName(_level))
DEBUG_SESSION_ID = "2a4d38"
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)
DEBUG_LOG_PATH = "/app/logs/cursor-debug-2a4d38.log"
def _debug_write(
*,
hypothesis_id: str,
run_id: str,
location: str,
message: str,
data: Dict[str, Any],
) -> None:
"""
NDJSON debug log for Cursor Debug Mode.
IMPORTANT: do not log secrets/PII (tokens, tg hash, full init_data, phone, etc).
"""
try:
ts = int(time.time() * 1000)
entry = {
"sessionId": DEBUG_SESSION_ID,
"id": f"log_{ts}_{uuid.uuid4().hex[:8]}",
"timestamp": ts,
"location": location,
"message": message,
"data": data,
"runId": run_id,
"hypothesisId": hypothesis_id,
}
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
# Never break prod request handling due to debug logging
return
def _extract_client_bundle_info(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Returns (moduleUrl, scriptSrc, build) from the last 'boot' entry if present.
"""
logs = payload.get("logs") or []
if not isinstance(logs, list):
return (None, None, None)
for entry in reversed(logs):
if not isinstance(entry, dict):
continue
if entry.get("event") != "boot":
continue
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
module_url = data.get("moduleUrl") if isinstance(data.get("moduleUrl"), str) else None
script_src = data.get("scriptSrc") if isinstance(data.get("scriptSrc"), str) else None
build = data.get("build") if isinstance(data.get("build"), str) else None
return (module_url, script_src, build)
return (None, None, None)
def _extract_last_window_error(payload: Dict[str, Any]) -> Dict[str, Any]:
logs = payload.get("logs") or []
if not isinstance(logs, list):
return {}
for entry in reversed(logs):
if not isinstance(entry, dict):
continue
if entry.get("event") != "window_error":
continue
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
# Keep only safe fields
return {
"message": data.get("message"),
"filename": data.get("filename"),
"lineno": data.get("lineno"),
"colno": data.get("colno"),
"hasStack": bool(data.get("stack")),
}
return {}
@asynccontextmanager
@@ -38,13 +127,24 @@ async def lifespan(app: FastAPI):
logger.warning(f"⚠️ PostgreSQL not available: {e}")
try:
# Подключаем Redis
# Подключаем внешний Redis (события, буферы, SMS и т.д.)
await redis_service.connect()
# Инициализируем session API с Redis connection
session.init_redis(redis_service.client)
except Exception as e:
logger.warning(f"⚠️ Redis not available: {e}")
try:
# Подключаем локальный Redis для сессий (отдельно от внешнего)
session_redis = await redis.from_url(
settings.redis_session_url,
encoding="utf-8",
decode_responses=True,
)
await session_redis.ping()
session.init_redis(session_redis)
logger.info(f"✅ Session Redis connected: {settings.redis_session_host}:{settings.redis_session_port}")
except Exception as e:
logger.warning(f"⚠️ Session Redis not available: {e}")
try:
# Подключаем RabbitMQ
await rabbitmq_service.connect()
@@ -69,15 +169,32 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"⚠️ S3 storage not available: {e}")
# Postgres LISTEN support_events для доставки сообщений поддержки в реальном времени (SSE)
support_listener_task = None
try:
support_listener_task = asyncio.create_task(support._run_support_listener())
logger.info("✅ Support NOTIFY listener task started")
except Exception as e:
logger.warning(f"⚠️ Support listener not started: {e}")
logger.info("✅ Ticket Form Intake Platform started successfully!")
yield
# SHUTDOWN
logger.info("🛑 Shutting down Ticket Form Intake Platform...")
if support_listener_task and not support_listener_task.done():
support_listener_task.cancel()
try:
await support_listener_task
except asyncio.CancelledError:
pass
await db.disconnect()
await redis_service.disconnect()
if session.redis_client:
await session.redis_client.close()
session.init_redis(None)
await rabbitmq_service.disconnect()
await policy_service.close()
await crm_mysql_service.close()
@@ -93,14 +210,43 @@ app = FastAPI(
lifespan=lifespan
)
# CORS
# CORS (список обновляется при изменении .env без перезапуска)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_origins=get_cors_origins_live(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Обновление конфига с .env при каждом запросе, чтобы CORS и прочее подхватывали изменения
@app.middleware("http")
async def refresh_config_on_request(request, call_next):
get_settings()
return await call_next(request)
# Temporary middleware for capturing incoming init_data / startapp / claim_id for debugging.
@app.middleware("http")
async def capture_initdata_middleware(request, call_next):
try:
# Check query string first
qs = str(request.url.query or "")
if qs and ("claim_id" in qs or "startapp" in qs or "start_param" in qs):
logger.info("[CAPTURE Q] %s %s QUERY: %s", request.method, request.url.path, qs)
# Check JSON body for known keys
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.body()
if body:
text = body.decode(errors="ignore")
if any(k in text for k in ("init_data", "startapp", "start_param", "claim_id")):
# Log truncated body (limit 10k chars)
snippet = text if len(text) <= 10000 else (text[:10000] + "...[truncated]")
logger.info("[CAPTURE B] %s %s BODY: %s", request.method, request.url.path, snippet)
except Exception:
logger.exception("❌ Error in capture_initdata_middleware")
return await call_next(request)
# API Routes
app.include_router(sms.router)
@@ -112,6 +258,15 @@ app.include_router(events.router)
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
app.include_router(session.router) # 🔑 Session management через Redis
app.include_router(documents.router) # 📄 Documents upload and processing
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
app.include_router(max_auth.router) # 📱 MAX Mini App auth
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
app.include_router(auth_universal.router) # Универсальный auth: channel + init_data → N8N_AUTH_WEBHOOK, Redis session:{channel}:{channel_user_id}
app.include_router(profile.router) # 👤 Профиль: контакты из CRM через N8N_CONTACT_WEBHOOK
app.include_router(support.router) # 📞 Поддержка: форма из бара и карточек жалоб → n8n
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
@app.get("/")
@@ -210,6 +365,71 @@ async def get_client_ip(request: Request):
}
@app.post("/api/v1/utils/client-log")
async def client_log(request: Request):
"""
Принимает клиентские логи (для отладки webview/miniapp) и пишет в backend-логи.
Формат: { reason, client: {...}, logs: [...] }
"""
client_host = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
try:
payload = await request.json()
except Exception:
payload = {"error": "invalid_json"}
# Cursor debug-mode evidence (sanitized)
try:
if isinstance(payload, dict):
reason = payload.get("reason")
client = payload.get("client") if isinstance(payload.get("client"), dict) else {}
pathname = client.get("pathname") if isinstance(client.get("pathname"), str) else None
origin = client.get("origin") if isinstance(client.get("origin"), str) else None
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
module_url, script_src, build = _extract_client_bundle_info(payload)
last_err = _extract_last_window_error(payload)
first_err_file = None
last_err_file = None
if isinstance(logs, list):
for e in logs:
if isinstance(e, dict) and e.get("event") == "window_error":
d = e.get("data") if isinstance(e.get("data"), dict) else {}
fn = d.get("filename")
if isinstance(fn, str):
if first_err_file is None:
first_err_file = fn
last_err_file = fn
_debug_write(
hypothesis_id="H1",
run_id="pre-fix",
location="backend/app/main.py:client_log",
message="client_log_received",
data={
"ip": client_host,
"uaPrefix": ua[:80] if isinstance(ua, str) else "",
"reason": reason,
"origin": origin,
"pathname": pathname,
"logsCount": len(logs) if isinstance(logs, list) else None,
"boot": {"moduleUrl": module_url, "scriptSrc": script_src, "build": build},
"windowErrorLast": last_err,
"windowErrorFiles": {"first": first_err_file, "last": last_err_file},
},
)
except Exception:
pass
# Ограничим размер вывода, но оставим самое важное
try:
s = json.dumps(payload, ensure_ascii=False)[:20000]
except Exception:
s = str(payload)[:20000]
logger.warning(f"📱 CLIENT_LOG ip={client_host} ua={ua} payload={s}")
return {"success": True}
@app.get("/api/v1/info")
async def info():
"""Информация о платформе"""

View File

@@ -0,0 +1,120 @@
"""
MAX WebApp (Mini App) auth helper.
Валидация initData от MAX Bridge — тот же алгоритм, что и у Telegram:
secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN), data_check_string без hash, сравнение с hash.
"""
import hashlib
import hmac
import json
import logging
from typing import Dict, Any
from urllib.parse import parse_qsl
from ..config import settings
logger = logging.getLogger(__name__)
class MaxAuthError(Exception):
"""Ошибка проверки подлинности MAX initData."""
def _parse_init_data(init_data: str) -> Dict[str, Any]:
"""Разбирает строку initData (query string) в словарь."""
data: Dict[str, Any] = {}
for key, value in parse_qsl(init_data, keep_blank_values=True):
data[key] = value
return data
def _verify_with_token(parsed: Dict[str, Any], data_check_string: str, received_hash: str, bot_token: str) -> bool:
"""Проверяет подпись initData одним MAX ботом. Возвращает True, если подпись верна."""
secret_key = hmac.new(
key="WebAppData".encode("utf-8"),
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
return hmac.compare_digest(calculated_hash, received_hash)
def verify_max_init_data(init_data: str) -> Dict[str, Any]:
"""
Проверяет подпись initData по правилам MAX (аналогично Telegram).
Поддерживает один бот (MAX_BOT_TOKEN) или несколько (MAX_BOT_TOKENS — JSON).
Перебирает токены, пока один не подойдёт; в результат добавляется ключ bot_id.
- secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
- data_check_string: пары key=value без hash, сортировка по key, разделитель \n
- hex(HMAC_SHA256(secret_key, data_check_string)) === hash из initData
"""
if not init_data:
logger.warning("[MAX] verify_max_init_data: init_data пустой")
raise MaxAuthError("init_data is empty")
tokens_list = settings.get_max_bot_tokens()
if not tokens_list:
logger.warning("[MAX] Ни MAX_BOT_TOKEN, ни MAX_BOT_TOKENS не заданы в .env")
raise MaxAuthError("MAX bot token is not configured")
parsed = _parse_init_data(init_data)
logger.info("[MAX] initData распарсен, ключи: %s", list(parsed.keys()))
received_hash = parsed.pop("hash", None)
if not received_hash:
logger.warning("[MAX] В initData отсутствует поле hash")
raise MaxAuthError("Missing hash in init_data")
data_check_items = [f"{k}={parsed[k]}" for k in sorted(parsed.keys())]
data_check_string = "\n".join(data_check_items)
for bot_id, bot_token in tokens_list:
if _verify_with_token(parsed, data_check_string, received_hash, bot_token):
parsed["bot_id"] = bot_id
logger.info("[MAX] Подпись MAX initData проверена, bot_id=%s", bot_id)
return parsed
logger.warning("[MAX] Подпись initData не совпадает ни с одним из токенов MAX ботов")
raise MaxAuthError("Invalid init_data hash")
def extract_max_user(init_data: str) -> Dict[str, Any]:
"""
Валидирует initData и возвращает данные пользователя MAX.
В поле `user` — JSON с id, first_name, last_name, username, language_code, photo_url и т.д.
"""
parsed = verify_max_init_data(init_data)
user_raw = parsed.get("user")
if not user_raw:
logger.warning("[MAX] В initData отсутствует поле user")
raise MaxAuthError("No user field in init_data")
try:
user_obj = json.loads(user_raw)
except Exception as e:
raise MaxAuthError(f"Failed to parse user JSON: {e}") from e
if "id" not in user_obj:
raise MaxAuthError("MAX user.id is missing")
result = {
"max_user_id": str(user_obj.get("id")),
"username": user_obj.get("username"),
"first_name": user_obj.get("first_name"),
"last_name": user_obj.get("last_name"),
"language_code": user_obj.get("language_code"),
"photo_url": user_obj.get("photo_url"),
"raw": user_obj,
}
if "bot_id" in parsed:
result["bot_id"] = parsed["bot_id"]
return result

View File

@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
WORKFLOW_ID = "b4K4u851b4JFivyD"
N8N_URL = "https://n8n.clientright.pro"
MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
MAX_RETRY_ATTEMPTS = 2 # Максимум попыток перезапуска подряд
async def check_workflow_status() -> Optional[dict]:
@@ -50,7 +51,7 @@ async def check_workflow_status() -> Optional[dict]:
async def restart_workflow() -> bool:
"""
Перезапуск workflow через n8n API
Перезапуск workflow через n8n API с улучшенной обработкой зависших состояний
Returns:
True если успешно, False при ошибке
@@ -63,37 +64,64 @@ async def restart_workflow() -> bool:
if not headers:
return False
import asyncio
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Шаг 1: Деактивировать workflow
# Увеличиваем таймаут для обработки зависших workflow
async with httpx.AsyncClient(timeout=30.0) as client:
# Шаг 1: Проверяем текущий статус
logger.info(f"🔍 Проверяю текущий статус workflow {WORKFLOW_ID}...")
status_response = await client.get(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
headers=headers
)
if status_response.status_code == 200:
workflow_data = status_response.json()
is_active = workflow_data.get("active", False)
logger.info(f"📊 Workflow активен: {is_active}")
# Шаг 2: Деактивировать workflow (даже если уже неактивен - для сброса состояния)
logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
try:
deactivate_response = await client.post(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
headers=headers
headers=headers,
timeout=15.0 # Отдельный таймаут для деактивации
)
if deactivate_response.status_code not in [200, 404]:
if deactivate_response.status_code in [200, 404]:
logger.info("✅ Workflow деактивирован")
else:
logger.warning(
f"⚠️ Неожиданный статус при деактивации: "
f"{deactivate_response.status_code}"
f"{deactivate_response.status_code} - {deactivate_response.text[:200]}"
)
else:
logger.info("✅ Workflow деактивирован")
# Продолжаем даже если деактивация не удалась - возможно workflow уже неактивен
except httpx.TimeoutException:
logger.warning("⏱️ Таймаут при деактивации workflow (возможно завис)")
# Продолжаем попытку активации - иногда помогает
except Exception as e:
logger.warning(f"⚠️ Ошибка при деактивации: {e}, продолжаю...")
# Задержка перед активацией
import asyncio
await asyncio.sleep(2)
# Задержка перед активацией (увеличена для стабильности)
await asyncio.sleep(3)
# Шаг 2: Активировать workflow
# Шаг 3: Активировать workflow
logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
try:
activate_response = await client.post(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
headers=headers
headers=headers,
timeout=15.0 # Отдельный таймаут для активации
)
if activate_response.status_code == 200:
logger.info("✅ Workflow активирован")
# Дополнительная задержка для инициализации trigger node
await asyncio.sleep(2)
# После успешного перезапуска отправляем сообщения из буфера
await _send_buffered_messages()
@@ -104,9 +132,18 @@ async def restart_workflow() -> bool:
f"{activate_response.status_code} - {activate_response.text[:200]}"
)
return False
except httpx.TimeoutException:
logger.error("⏱️ Таймаут при активации workflow - возможно n8n перегружен")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при перезапуске workflow: {e}")
logger.error(f"Ошибка при активации workflow: {e}")
return False
except httpx.TimeoutException:
logger.error("⏱️ Общий таймаут при перезапуске workflow")
return False
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True)
return False

View File

@@ -65,11 +65,11 @@ class SMSService:
logger.warning("SMS отправка отключена в конфигурации")
return False
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
# 🔧 DEV MODE: Не отправляем реальные SMS в development, экономим бюджет
if settings.debug or settings.app_env == "development":
logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
logger.info(f"🔧 DEV MODE: SMS to {phone} not sent (saving money!)")
logger.info(f"📱 Message would be: {message}")
return True
return True # Возвращаем True чтобы код сохранился в Redis для проверки
try:
# Получаем актуальный токен

View File

@@ -0,0 +1,132 @@
"""
Telegram WebApp (Mini App) auth helper.
В этом модуле:
- Парсим и валидируем initData от Telegram WebApp
- Проверяем подпись по токену бота из настроек
- Возвращаем разобранные данные пользователя Telegram
"""
import hashlib
import hmac
import logging
from typing import Dict, Any
from urllib.parse import parse_qsl
from ..config import settings
logger = logging.getLogger(__name__)
class TelegramAuthError(Exception):
"""Ошибка проверки подлинности Telegram initData."""
def _parse_init_data(init_data: str) -> Dict[str, Any]:
"""
Разбирает строку initData в словарь.
Формат initData — это query string, см. Telegram WebApp docs.
"""
data: Dict[str, Any] = {}
for key, value in parse_qsl(init_data, keep_blank_values=True):
data[key] = value
return data
def verify_telegram_init_data(init_data: str) -> Dict[str, Any]:
"""
Проверяет подпись initData согласно Telegram WebApp правилам.
Алгоритм из официальной документации:
- Берём токен бота: BOT_TOKEN
- Вычисляем secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
- Собираем data_check_string: строки "<key>=<value>" по всем полям, кроме 'hash',
отсортированные по key, соединённые '\n'
- Считаем хэш: HMAC_SHA256(secret_key, data_check_string)
- Сравниваем с полем 'hash' из initData (hex)
"""
if not init_data:
logger.warning("[TG] verify_telegram_init_data: init_data пустой")
raise TelegramAuthError("init_data is empty")
bot_token = (getattr(settings, "telegram_bot_token", None) or "").strip()
if not bot_token:
logger.warning("[TG] verify_telegram_init_data: TELEGRAM_BOT_TOKEN не задан в .env")
raise TelegramAuthError("Telegram bot token is not configured")
parsed = _parse_init_data(init_data)
logger.info("[TG] initData распарсен, ключи: %s", list(parsed.keys()))
received_hash = parsed.pop("hash", None)
if not received_hash:
logger.warning("[TG] В initData отсутствует поле hash")
raise TelegramAuthError("Missing hash in init_data")
# Формируем data_check_string
data_check_items = []
for key in sorted(parsed.keys()):
value = parsed[key]
data_check_items.append(f"{key}={value}")
data_check_string = "\n".join(data_check_items)
# secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
secret_key = hmac.new(
key="WebAppData".encode("utf-8"),
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
# HMAC_SHA256(secret_key, data_check_string)
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("[TG] Подпись initData не совпадает (неверный токен бота или поддельные данные)")
raise TelegramAuthError("Invalid init_data hash")
return parsed
def extract_telegram_user(init_data: str) -> Dict[str, Any]:
"""
Валидирует initData и возвращает данные пользователя Telegram.
В field `user` лежит JSON-строка с полями:
{
"id": 123456789,
"first_name": "...",
"last_name": "...",
"username": "...",
...
}
"""
import json
parsed = verify_telegram_init_data(init_data)
user_raw = parsed.get("user")
if not user_raw:
logger.warning("[TG] В initData отсутствует поле user")
raise TelegramAuthError("No user field in init_data")
try:
user_obj = json.loads(user_raw)
except Exception as e:
raise TelegramAuthError(f"Failed to parse user JSON: {e}") from e
if "id" not in user_obj:
raise TelegramAuthError("Telegram user.id is missing")
return {
"telegram_user_id": str(user_obj.get("id")),
"username": user_obj.get("username"),
"first_name": user_obj.get("first_name"),
"last_name": user_obj.get("last_name"),
"language_code": user_obj.get("language_code"),
"raw": user_obj,
}

View File

@@ -0,0 +1,32 @@
-- Треды и сообщения поддержки (диалог). Префикс таблиц: clpr_
-- Один тред на (unified_id, claim_id или null); сообщения user/support
CREATE TABLE IF NOT EXISTS clpr_support_threads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
unified_id VARCHAR(255) NOT NULL,
claim_id VARCHAR(255),
source VARCHAR(50) NOT NULL DEFAULT 'bar',
ticket_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_clpr_support_threads_unified_claim ON clpr_support_threads(unified_id, claim_id);
CREATE INDEX IF NOT EXISTS idx_clpr_support_threads_ticket ON clpr_support_threads(ticket_id);
CREATE TABLE IF NOT EXISTS clpr_support_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
thread_id UUID NOT NULL REFERENCES clpr_support_threads(id) ON DELETE CASCADE,
direction VARCHAR(20) NOT NULL CHECK (direction IN ('user', 'support')),
body TEXT NOT NULL DEFAULT '',
attachments JSONB DEFAULT '[]',
external_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_clpr_support_messages_thread ON clpr_support_messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_clpr_support_messages_created ON clpr_support_messages(thread_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_clpr_support_messages_external ON clpr_support_messages(thread_id, external_id) WHERE external_id IS NOT NULL;
COMMENT ON TABLE clpr_support_threads IS 'Треды обращений в поддержку: один на пользователя (бар) или по claim_id';
COMMENT ON TABLE clpr_support_messages IS 'Сообщения в треде: user — от пользователя, support — от оператора';

View File

@@ -0,0 +1,37 @@
-- NOTIFY при INSERT в clpr_support_messages для доставки в реальном времени (SSE).
-- Один канал support_events, в payload — unified_id и данные сообщения.
-- Таблицы поддержки с префиксом clpr_
CREATE OR REPLACE FUNCTION support_messages_notify()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
u_id VARCHAR(255);
payload TEXT;
BEGIN
SELECT unified_id INTO u_id FROM clpr_support_threads WHERE id = NEW.thread_id;
IF u_id IS NULL THEN
RETURN NEW;
END IF;
payload := json_build_object(
'unified_id', u_id,
'thread_id', NEW.thread_id,
'message_id', NEW.id,
'direction', NEW.direction,
'body', NEW.body,
'attachments', COALESCE(NEW.attachments::TEXT, '[]'),
'created_at', NEW.created_at
)::TEXT;
PERFORM pg_notify('support_events', payload);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS after_support_message_insert ON clpr_support_messages;
CREATE TRIGGER after_support_message_insert
AFTER INSERT ON clpr_support_messages
FOR EACH ROW
EXECUTE PROCEDURE support_messages_notify();
COMMENT ON FUNCTION support_messages_notify() IS 'NOTIFY support_events при новом сообщении для SSE';

View File

@@ -0,0 +1,13 @@
-- Отметки «прочитано» по тредам: когда пользователь последний раз видел тред.
-- Непрочитанные = сообщения от support с created_at > last_read_at.
CREATE TABLE IF NOT EXISTS clpr_support_reads (
unified_id VARCHAR(255) NOT NULL,
thread_id UUID NOT NULL REFERENCES clpr_support_threads(id) ON DELETE CASCADE,
last_read_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (unified_id, thread_id)
);
CREATE INDEX IF NOT EXISTS idx_clpr_support_reads_unified ON clpr_support_reads(unified_id);
COMMENT ON TABLE clpr_support_reads IS 'Когда пользователь последний раз «прочитал» тред (открыл чат)';

86
deploy-to-prod.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/bash
# ============================================
# Скрипт переноса изменений из DEV в PROD
# ============================================
set -e
cd "$(dirname "$0")"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Перенос изменений из DEV в PROD"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Проверка что мы в правильной директории
if [ ! -f "docker-compose.dev.yml" ]; then
echo "❌ Ошибка: запустите скрипт из корня проекта ticket_form"
exit 1
fi
# 1. Проверка изменений в git
echo "📊 Проверяю изменения в git..."
if [ -n "$(git status --porcelain)" ]; then
echo "⚠️ Есть незакоммиченные изменения!"
echo ""
git status --short
echo ""
read -p "Закоммитить изменения перед деплоем? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "💾 Коммичу изменения..."
git add -A
git commit -m "chore: Изменения перед деплоем в prod $(date +%Y-%m-%d)"
fi
fi
# 2. Push в dev репозиторий
echo ""
echo "📤 Отправляю изменения в DEV репозиторий..."
if git remote | grep -q "aiform_dev"; then
git push aiform_dev main 2>/dev/null || git push aiform_dev master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_dev"
fi
# 3. Push в prod репозиторий
echo ""
echo "📤 Отправляю изменения в PROD репозиторий..."
if git remote | grep -q "aiform_prod"; then
git push aiform_prod main 2>/dev/null || git push aiform_prod master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_prod"
else
echo "⚠️ Remote 'aiform_prod' не найден. Добавьте:"
echo " git remote add aiform_prod http://147.45.146.17:3002/negodiy/aiform_prod.git"
fi
# 4. Пересборка prod контейнеров
echo ""
echo "🔨 Пересобираю PROD контейнеры..."
docker-compose -f docker-compose.prod.yml build --no-cache
# 5. Перезапуск prod
echo ""
echo "🔄 Перезапускаю PROD окружение..."
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
# 6. Проверка статуса
echo ""
echo "⏳ Жду запуска (5 сек)..."
sleep 5
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ Деплой завершён!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "📍 PROD доступен:"
echo " Frontend: http://localhost:5176"
echo " Backend: http://localhost:8200"
echo " Production: https://aiform.clientright.ru"
echo ""
echo "📊 Статус контейнеров:"
docker-compose -f docker-compose.prod.yml ps
echo ""
echo "📋 Логи (последние 20 строк):"
docker-compose -f docker-compose.prod.yml logs --tail=20
echo ""

39
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
aiform_frontend_dev:
container_name: aiform_frontend_dev
build: ./frontend
ports:
- "5177:3000"
environment:
- VITE_API_URL=http://localhost:8201
- NODE_ENV=development
volumes:
- ./frontend/src:/app/src:ro
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- aiform-dev-network
restart: unless-stopped
aiform_backend_dev:
container_name: aiform_backend_dev
build: ./backend
ports:
- "8201:8200"
env_file:
- ./backend/.env
environment:
- APP_ENV=development
- DEBUG=true
networks:
- aiform-dev-network
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
networks:
aiform-dev-network:
driver: bridge

62
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,62 @@
version: '3.8'
# ============================================
# PRODUCTION ENVIRONMENT
# Запуск: docker-compose -f docker-compose.prod.yml up -d
# ============================================
services:
ticket_form_frontend_prod:
container_name: miniapp_front
build:
context: ./frontend
dockerfile: Dockerfile.prod
ports:
- "4176:3000"
environment:
- VITE_API_URL=https://aiform.clientright.ru
- NODE_ENV=production
networks:
- ticket-form-prod-network
restart: unless-stopped
labels:
- "environment=production"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
ticket_form_backend_prod:
container_name: miniapp_back
build:
context: ./backend
dockerfile: Dockerfile
network_mode: host # Для доступа к localhost MySQL/Redis
env_file:
- .env
environment:
- APP_ENV=production
- DEBUG=false
- LOG_LEVEL=INFO
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
labels:
- "environment=production"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4200/health"]
interval: 30s
timeout: 10s
retries: 3
# В проде используем внешние БД (не создаём локальные)
# PostgreSQL: 147.45.189.234:5432
# Redis: localhost:6379 (системный)
# MySQL: localhost:3306 (системный)
networks:
ticket-form-prod-network:
driver: bridge
name: ticket-form-prod-network

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# ============================================================================
# Пример curl запроса для Browserless (HTML → PDF)
# Используйте этот запрос в HTTP Request ноде n8n
# ============================================================================
# ВАРИАНТ 1: С data URL (HTML в base64)
curl -X POST http://147.45.146.17:3000/pdf \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"url": "data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PGgxPlRlc3Q8L2gxPjwvYm9keT48L2h0bWw+",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}'
# ============================================================================
# ВАРИАНТ 2: С прямым HTML (если Browserless поддерживает)
# ============================================================================
# curl -X POST http://147.45.146.17:3000/pdf \
# -H "Content-Type: application/json" \
# -H "Authorization: Bearer YOUR_TOKEN" \
# -d '{
# "html": "<!DOCTYPE html><html><body><h1>Test</h1></body></html>",
# "options": {
# "format": "A4",
# "printBackground": true,
# "margin": {
# "top": "20mm",
# "right": "15mm",
# "bottom": "20mm",
# "left": "15mm"
# }
# }
# }'
# ============================================================================
# НАСТРОЙКА В HTTP REQUEST НОДЕ:
# ============================================================================
# Method: POST
# URL: http://147.45.146.17:3000/pdf
# Headers:
# Content-Type: application/json
# Authorization: Bearer YOUR_TOKEN (если требуется)
# Body (JSON):
# {
# "url": "data:text/html;base64,{{ $json.html_base64_encoded }}",
# "options": {
# "format": "A4",
# "printBackground": true,
# "margin": {
# "top": "20mm",
# "right": "15mm",
# "bottom": "20mm",
# "left": "15mm"
# }
# }
# }
# Response Format: Binary
# ============================================================================

View File

@@ -0,0 +1,163 @@
# Настройка HTTP Request для Browserless Function API
## Готовые настройки для HTTP Request ноды
### Method
`POST`
### URL
```
http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9
```
### Headers
```json
{
"Content-Type": "application/javascript"
}
```
### Body (Raw)
**Content Type:** `application/javascript`
**Body:**
```javascript
export default async function ({ page }) {
const html = `{{ $json.html }}`;
if (!html) {
throw new Error('❌ HTML не передан');
}
// универсальный sleep
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
await page.setViewport({ width: 1240, height: 1754 });
// Загружаем HTML напрямую
await page.setContent(html, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
});
// Даём браузеру применить стили
await sleep(300);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
return {
status: 'success',
pdf_base64: pdfBuffer.toString('base64'),
size_bytes: pdfBuffer.length,
};
}
```
### Options
- **Timeout:** `40000` (40 секунд)
### Response Format
`JSON` (Browserless вернёт JSON с `pdf_base64`)
---
## Вариант с html_base64
Если у вас HTML в base64, используйте этот вариант:
```javascript
export default async function ({ page }) {
// Получаем HTML из base64
const htmlBase64 = `{{ $json.html_base64 }}`;
const html = Buffer.from(htmlBase64, 'base64').toString('utf8');
if (!html) {
throw new Error('❌ HTML не передан');
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
await page.setViewport({ width: 1240, height: 1754 });
await page.setContent(html, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
});
await sleep(300);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
return {
status: 'success',
pdf_base64: pdfBuffer.toString('base64'),
size_bytes: pdfBuffer.length,
};
}
```
---
## Полный Workflow
```
[Code: Process Flights Data] ← Генерирует HTML
[HTTP Request: Browserless Function] ← Используйте настройки выше
[Code: Extract PDF Base64] ← Если нужно обработать ответ
```
---
## Code Node: Extract PDF Base64 (опционально)
Если Browserless уже вернул `pdf_base64` в JSON, можно просто передать дальше:
```javascript
const response = $input.first().json;
return [{
json: {
pdf_base64: response.pdf_base64,
pdf_size_bytes: response.size_bytes,
pdf_size_mb: (response.size_bytes / (1024 * 1024)).toFixed(2),
status: response.status,
success: true
}
}];
```
---
## Преимущества этого подхода
**Прямая работа с HTML** - не нужно конвертировать в data URL
**Полный контроль** - можете добавить любую логику в функцию
**Готовый base64** - Browserless сразу возвращает base64 PDF
**Надёжность** - sleep даёт время браузеру применить стили
---
## Отладка
Если получаете ошибки:
- **"HTML не передан"** → Проверьте, что предыдущая нода вернула `html` или `html_base64`
- **Timeout** → Увеличьте timeout в Options до 60000 (60 секунд)
- **Пустой PDF** → Увеличьте sleep до 500-1000ms

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"parameters": {
"method": "POST",
"url": "http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/javascript"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/javascript",
"body": "export default async function ({ page }) {\n const html = `{{ $json.html }}`;\n\n if (!html) {\n throw new Error('❌ HTML не передан');\n }\n\n // универсальный sleep\n const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));\n\n await page.setViewport({ width: 1240, height: 1754 });\n\n // Загружаем HTML напрямую\n await page.setContent(html, {\n waitUntil: ['load', 'domcontentloaded', 'networkidle0'],\n });\n\n // Даём браузеру применить стили\n await sleep(300);\n\n const pdfBuffer = await page.pdf({\n format: 'A4',\n printBackground: true,\n margin: {\n top: '20mm',\n right: '15mm',\n bottom: '20mm',\n left: '15mm',\n },\n });\n\n return {\n status: 'success',\n pdf_base64: pdfBuffer.toString('base64'),\n size_bytes: pdfBuffer.length,\n };\n}",
"options": {
"timeout": 40000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"name": "Browserless: HTML to PDF"
}
]
}

View File

@@ -0,0 +1,135 @@
# Настройка HTTP Request ноды для Browserless
## Готовый запрос для вставки
### Вариант 1: С использованием html_base64 из предыдущей ноды
**Method:** `POST`
**URL:** `http://147.45.146.17:3000/pdf`
**Headers:**
```json
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
```
*Примечание: Если токен не требуется, уберите строку Authorization*
**Body (JSON):**
```json
{
"url": "data:text/html;base64,{{ $json.html_base64 }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}
```
**Response Format:** `Binary`
---
### Вариант 2: Если у вас HTML в строке (не base64)
**Method:** `POST`
**URL:** `http://147.45.146.17:3000/pdf`
**Headers:**
```json
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
```
**Body (JSON):**
```json
{
"html": "{{ $json.html }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}
```
**Response Format:** `Binary`
---
## Полный workflow
```
[Code: Process Flights Data] ← Генерирует HTML
[Code: HTML to Base64] ← Конвертирует HTML в base64 (если нужно)
[HTTP Request: Browserless PDF] ← Используйте настройки выше
[Code: Extract Base64 PDF] ← Конвертирует binary в base64
```
---
## Code Node: HTML to Base64 (если нужно)
Если у вас HTML в строке, а нужен base64 для data URL:
```javascript
const html = $json.html;
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
return [{
json: {
html_base64: htmlBase64,
html: html
}
}];
```
---
## Code Node: Extract Base64 PDF (после HTTP Request)
```javascript
const pdfBinary = $binary.data;
const base64 = Buffer.isBuffer(pdfBinary)
? pdfBinary.toString('base64')
: Buffer.from(pdfBinary).toString('base64');
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true
}
}];
```
---
## Отладка
Если получаете ошибку:
- **"Bad or missing authentication"** → Проверьте токен или уберите Authorization header
- **"Not Found"** → Проверьте URL эндпоинта
- **Пустой ответ** → Проверьте формат HTML и data URL

View File

@@ -0,0 +1,698 @@
// ============================================================================
// n8n Code Node: Обработка данных о рейсах из FlightAware и FlightRadar24
// ============================================================================
// Объединяет данные из двух источников и формирует красивый HTML для PDF
// ============================================================================
// ==== ПОЛУЧЕНИЕ ВХОДНЫХ ДАННЫХ ====
// Ожидаемая структура: массив с двумя элементами
// [0] - данные из FlightAware (body.flights[])
// [1] - данные из FlightRadar24 (body.data[])
const inputItems = $input.all();
if (!inputItems || inputItems.length === 0) {
return [{
json: {
error: 'Нет входных данных',
html: '<html><body><h1>Ошибка: данные не получены</h1></body></html>',
flights: [],
sources: { flightaware: false, flightradar24: false }
}
}];
}
// ==== ИЗВЛЕЧЕНИЕ ДАННЫХ ИЗ ИСТОЧНИКОВ ====
let flightAwareData = [];
let flightRadar24Data = [];
try {
// Первый элемент - FlightAware
const faItem = inputItems[0];
if (faItem && faItem.json && faItem.json.body && faItem.json.body.flights) {
flightAwareData = Array.isArray(faItem.json.body.flights)
? faItem.json.body.flights
: [];
}
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
}
try {
// Второй элемент - FlightRadar24
const fr24Item = inputItems[1];
if (fr24Item && fr24Item.json && fr24Item.json.body && fr24Item.json.body.data) {
flightRadar24Data = Array.isArray(fr24Item.json.body.data)
? fr24Item.json.body.data
: [];
}
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
}
// ==== УТИЛИТЫ ====
const safeStr = (v) => (v == null ? '' : String(v));
const safeDate = (v) => {
if (!v) return '—';
try {
const d = new Date(v);
return d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return v;
}
};
const formatDuration = (seconds) => {
if (!seconds) return '—';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}ч ${minutes}м`;
};
const formatDistance = (km) => {
if (!km) return '—';
return `${Number(km).toFixed(2)} км`;
};
// ==== ОБЪЕДИНЕНИЕ ДАННЫХ ПО REGISTRATION ====
// Создаём карту для быстрого поиска
const flightsMap = new Map();
// Добавляем данные из FlightAware
flightAwareData.forEach(flight => {
const reg = safeStr(flight.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(flight.flight_number),
ident: safeStr(flight.ident),
identIata: safeStr(flight.ident_iata),
aircraftType: safeStr(flight.aircraft_type),
flightAware: flight,
flightRadar24: null
});
} else {
flightsMap.get(reg).flightAware = flight;
}
});
// Добавляем данные из FlightRadar24
flightRadar24Data.forEach(flight => {
const reg = safeStr(flight.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(flight.flight),
ident: safeStr(flight.callsign),
identIata: safeStr(flight.flight),
aircraftType: safeStr(flight.type),
flightAware: null,
flightRadar24: flight
});
} else {
flightsMap.get(reg).flightRadar24 = flight;
}
});
// Преобразуем Map в массив
const mergedFlights = Array.from(flightsMap.values());
// ==== ГЕНЕРАЦИЯ HTML ====
const generateFlightCard = (flight) => {
const fa = flight.flightAware;
const fr24 = flight.flightRadar24;
let html = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${flight.flightNumber || flight.ident || 'N/A'}</h2>
<span class="registration">${flight.registration}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${flight.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${flight.ident || '—'} (${flight.identIata || '—'})</span>
</div>
</div>
`;
// Данные из FlightAware
if (fa) {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Плановый вылет:</span>
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Статус:</span>
<span class="status-value">${safeStr(fa.status || '—')}</span>
</div>
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка вылета:</span>
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
</div>
` : ''}
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка прилёта:</span>
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
</div>
` : ''}
${fa.gate_origin ? `
<div class="status-item">
<span class="status-label">Гейт вылета:</span>
<span class="status-value">${fa.gate_origin}</span>
</div>
` : ''}
${fa.gate_destination ? `
<div class="status-item">
<span class="status-label">Гейт прилёта:</span>
<span class="status-value">${fa.gate_destination}</span>
</div>
` : ''}
${fa.baggage_claim ? `
<div class="status-item">
<span class="status-label">Выдача багажа:</span>
<span class="status-value">${fa.baggage_claim}</span>
</div>
` : ''}
</div>
</div>
</div>
`;
} else {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>
`;
}
// Данные из FlightRadar24
if (fr24) {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr24.orig_iata || '—')} (${safeStr(fr24.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr24.dest_iata || '—')} (${safeStr(fr24.dest_icao || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fr24.datetime_takeoff)} ${fr24.runway_takeoff ? `(ВПП ${fr24.runway_takeoff})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fr24.datetime_landed)} ${fr24.runway_landed ? `(ВПП ${fr24.runway_landed})` : ''}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr24.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Фактическое расстояние:</span>
<span class="status-value">${formatDistance(fr24.actual_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Кратчайшее расстояние:</span>
<span class="status-value">${formatDistance(fr24.circle_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Статус полёта:</span>
<span class="status-value">${fr24.flight_ended ? 'Завершён' : 'В процессе'}</span>
</div>
</div>
</div>
</div>
`;
} else {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>
`;
}
html += `</div>`;
return html;
};
// ==== ГЕНЕРАЦИЯ ПОЛНОГО HTML ДОКУМЕНТА ====
const generateFullHTML = (flights) => {
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
let flightsHTML = '';
if (flights.length === 0) {
flightsHTML = '<div class="no-data">Данные о рейсах не найдены</div>';
} else {
flightsHTML = flights.map(flight => generateFlightCard(flight)).join('');
}
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #1e40af;
font-size: 28px;
margin-bottom: 10px;
}
.header-meta {
color: #666;
font-size: 14px;
}
.sources-info {
display: flex;
gap: 15px;
margin-top: 10px;
flex-wrap: wrap;
}
.source-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.source-tag.available {
background: #d1fae5;
color: #065f46;
}
.source-tag.unavailable {
background: #fee2e2;
color: #991b1b;
}
.flight-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 25px;
overflow: hidden;
background: white;
}
.flight-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.flight-header h2 {
font-size: 24px;
margin: 0;
}
.registration {
background: rgba(255,255,255,0.2);
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 14px;
}
.flight-info {
padding: 15px 20px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.info-row {
display: flex;
margin-bottom: 8px;
}
.info-row .label {
font-weight: 600;
color: #4b5563;
width: 150px;
flex-shrink: 0;
}
.info-row .value {
color: #111827;
}
.source-section {
border-top: 1px solid #e5e7eb;
padding: 20px;
}
.source-section:first-of-type {
border-top: none;
}
.source-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.source-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: white;
}
.source-badge.source-flightaware {
background: #3b82f6;
}
.source-badge.source-flightradar24 {
background: #10b981;
}
.source-missing {
color: #ef4444;
font-size: 13px;
font-style: italic;
}
.source-content {
margin-left: 0;
}
.route-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
background: #f9fafb;
border-radius: 6px;
}
.route-item {
display: flex;
flex-direction: column;
}
.route-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.route-value {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.timeline {
margin-bottom: 20px;
}
.timeline-item {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #e5e7eb;
}
.timeline-item:last-child {
border-bottom: none;
}
.timeline-label {
font-weight: 500;
color: #4b5563;
width: 180px;
flex-shrink: 0;
}
.timeline-value {
color: #111827;
text-align: right;
}
.status-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 15px;
background: #f9fafb;
border-radius: 6px;
}
.status-item {
display: flex;
flex-direction: column;
}
.status-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.delay-negative {
color: #10b981;
}
.delay-positive {
color: #ef4444;
}
.no-data {
text-align: center;
padding: 60px 20px;
color: #6b7280;
font-size: 18px;
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
padding: 20px;
}
.flight-card {
page-break-inside: avoid;
margin-bottom: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flightsHTML}
</div>
</div>
</body>
</html>`;
};
// ==== ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ====
const html = generateFullHTML(mergedFlights);
// ==== ПОДГОТОВКА ДАННЫХ ДЛЯ КОНВЕРТАЦИИ В BASE64 PDF ====
// Эти данные будут использованы в следующей HTTP Request ноде
// для конвертации HTML в PDF и получения base64
// Настройки сервиса конвертации (замените на ваши)
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
// Подготовка запроса для HTTP Request ноды
const pdfRequestData = {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
})
};
return [{
json: {
html: html,
flights: mergedFlights,
flights_count: mergedFlights.length,
sources: {
flightaware: {
available: flightAwareData.length > 0,
count: flightAwareData.length
},
flightradar24: {
available: flightRadar24Data.length > 0,
count: flightRadar24Data.length
}
},
generated_at: new Date().toISOString(),
// Данные для конвертации в PDF (используйте в следующей HTTP Request ноде)
pdf_request: pdfRequestData,
pdf_request_method: pdfRequestData.method,
pdf_request_url: pdfRequestData.url,
pdf_request_headers: pdfRequestData.headers,
pdf_request_body: pdfRequestData.body
}
}];

View File

@@ -0,0 +1,51 @@
// ========================================
// Code Node: Формирование JSON для ответа N8N_CONTACT_WEBHOOK (профиль)
// Данные берутся из ноды select_user1 (SQL/запрос контакта).
// Выход этой ноды подаётся в "Respond to Webhook" как Response Body.
// ========================================
//
// Вход из ноды select_user1 (массив строк или один item на строку):
// contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet,
// middle_name, birthplace, inn, verification, bank
//
// Выход для вебхука: { "items": [ { ...поля в snake_case... } ] } или { "items": [] }
// ========================================
// Данные из ноды select_user1
const rawItems = $('select_user1').all();
let rows = [];
if (rawItems.length === 1 && Array.isArray(rawItems[0].json)) {
rows = rawItems[0].json;
} else if (rawItems.length === 1 && Array.isArray(rawItems[0].json?.items)) {
rows = rawItems[0].json.items;
} else if (rawItems.length === 1 && rawItems[0].json && !Array.isArray(rawItems[0].json)) {
rows = [rawItems[0].json];
} else {
rows = rawItems.map(i => i.json).filter(Boolean);
}
function mapRow(r) {
const v = (key) => {
const x = r[key];
return x !== undefined && x !== null && String(x).trim() !== '' ? String(x).trim() : '';
};
return {
contact_id: r.contactid ?? r.contact_id ?? '',
last_name: v('lastname') || v('last_name'),
first_name: v('firstname') || v('first_name'),
middle_name: v('middle_name') || v('middleName'),
birth_date: v('birthday') || v('birth_date') || v('birthDate'),
birth_place: v('birthplace') || v('birth_place') || v('birthPlace'),
inn: v('inn'),
email: v('email'),
registration_address: v('mailingstreet') || v('registration_address') || v('address'),
mailing_address: v('mailing_address') || v('postal_address'),
bank_for_compensation: v('bank') || v('bank_for_compensation'),
phone: v('mobile') || v('phone') || v('mobile_phone'),
};
}
const items = rows.map(mapRow);
// Один выходной item с телом ответа для Respond to Webhook
return [{ json: { items } }];

View File

@@ -0,0 +1,110 @@
// ============================================================================
// n8n Code Node: Извлечение Base64 PDF из ответа HTTP Request
// ============================================================================
// Используйте этот код ПОСЛЕ HTTP Request ноды, которая конвертировала HTML в PDF
// ============================================================================
const response = $input.first();
if (!response) {
throw new Error('Ответ от HTTP Request не получен');
}
let base64 = null;
let pdfSize = 0;
// ==== ВАРИАНТ 1: Сервис вернул base64 в JSON ====
if (response.json) {
// htmlpdfapi.com возвращает: { pdf: "base64..." }
if (response.json.pdf) {
base64 = response.json.pdf;
pdfSize = Math.floor(base64.length * 0.75); // Примерный размер
}
// api2pdf.com возвращает: { Pdf: "base64..." }
else if (response.json.Pdf) {
base64 = response.json.Pdf;
pdfSize = Math.floor(base64.length * 0.75);
}
// pdfshift.io возвращает: { pdf: "base64..." }
else if (response.json.pdf) {
base64 = response.json.pdf;
pdfSize = Math.floor(base64.length * 0.75);
}
// Если base64 в другом поле
else if (response.json.base64) {
base64 = response.json.base64;
pdfSize = Math.floor(base64.length * 0.75);
}
// Если base64 в body
else if (response.json.body && typeof response.json.body === 'string') {
base64 = response.json.body;
pdfSize = Math.floor(base64.length * 0.75);
}
}
// ==== ВАРИАНТ 2: Сервис вернул binary PDF ====
if (!base64 && response.binary && response.binary.data) {
const pdfBinary = response.binary.data;
// Конвертируем binary в base64
if (Buffer.isBuffer(pdfBinary)) {
base64 = pdfBinary.toString('base64');
pdfSize = pdfBinary.length;
} else if (typeof pdfBinary === 'string') {
// Если уже base64 строка
base64 = pdfBinary;
pdfSize = Buffer.from(base64, 'base64').length;
} else {
// Пытаемся преобразовать
const buffer = Buffer.from(pdfBinary);
base64 = buffer.toString('base64');
pdfSize = buffer.length;
}
}
// ==== ВАРИАНТ 3: PDF в текстовом формате (base64 строка) ====
if (!base64 && response.json && typeof response.json === 'string') {
base64 = response.json;
pdfSize = Buffer.from(base64, 'base64').length;
}
// ==== ПРОВЕРКА РЕЗУЛЬТАТА ====
if (!base64) {
console.error('❌ Не удалось извлечь base64. Структура ответа:', Object.keys(response));
throw new Error('Не удалось извлечь base64 PDF из ответа. Проверьте формат ответа сервиса.');
}
// Проверяем, что это действительно base64
if (!/^[A-Za-z0-9+/=]+$/.test(base64)) {
throw new Error('Извлечённые данные не являются валидным base64');
}
const pdfSizeMB = (pdfSize / (1024 * 1024)).toFixed(2);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `flights-report-${timestamp}.pdf`;
console.log('✅ Base64 PDF извлечён успешно');
console.log('📊 Размер PDF:', pdfSizeMB, 'MB');
// ==== ВОЗВРАТ РЕЗУЛЬТАТА ====
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: pdfSize,
pdf_size_mb: pdfSizeMB,
filename: filename,
success: true,
generated_at: new Date().toISOString()
}
}];
// ============================================================================
// ИСПОЛЬЗОВАНИЕ РЕЗУЛЬТАТА:
// ============================================================================
// Теперь у вас есть base64 PDF в поле pdf_base64
// Вы можете:
// 1. Сохранить в файл
// 2. Отправить по email
// 3. Загрузить в S3/Nextcloud
// 4. Вернуть в API response
// ============================================================================

View File

@@ -0,0 +1,99 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless (полная версия)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
if ($json.html) {
html = $json.html;
} else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
} else if ($json.body?.html) {
html = $json.body.html;
} else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
} else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ BROWSERLESS ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
// ⚠️ ВАЖНО: Если Browserless требует токен, замените на ваш токен
// Если токен не требуется, оставьте пустую строку или удалите Authorization header
const BROWSERLESS_TOKEN = ''; // Замените на ваш токен, если требуется
// Конвертируем HTML в data URL для передачи в Browserless
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
// Формируем headers
const headers = {
'Content-Type': 'application/json'
};
// Добавляем токен, если он указан
if (BROWSERLESS_TOKEN) {
headers['Authorization'] = `Bearer ${BROWSERLESS_TOKEN}`;
}
// ================== ПОДГОТОВКА ЗАПРОСА ==================
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: headers,
// Тело запроса - передаём HTML через data URL
body: JSON.stringify({
url: dataUrl,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Метаданные для отладки
html_length: html.length,
data_url_length: dataUrl.length,
browserless_url: BROWSERLESS_URL
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Замените BROWSERLESS_TOKEN на ваш токен (если требуется)
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// - Response Format: Binary (Browserless возвращает PDF как binary)
// 4. После HTTP Request добавьте Code Node для конвертации binary в base64:
//
// const pdfBinary = $binary.data;
// const base64 = Buffer.isBuffer(pdfBinary)
// ? pdfBinary.toString('base64')
// : Buffer.from(pdfBinary).toString('base64');
//
// return [{
// json: {
// pdf_base64: base64,
// pdf_size_bytes: Buffer.from(base64, 'base64').length,
// success: true
// }
// }];
// ============================================================================

View File

@@ -0,0 +1,99 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// Подготавливает запрос для HTTP Request ноды к Browserless
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
// Вариант 1: HTML уже есть в json.html
if ($json.html) {
html = $json.html;
}
// Вариант 2: HTML в base64
else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
}
// Вариант 3: HTML в другом поле
else if ($json.body?.html) {
html = $json.body.html;
}
// Вариант 4: Пытаемся получить из binary
else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
}
else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ BROWSERLESS ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен Browserless
// ================== ВАРИАНТ 1: Использование data URL ==================
// Browserless может принимать HTML через data URL
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}` // Если требуется токен
},
body: JSON.stringify({
url: dataUrl, // Передаём HTML через data URL
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Альтернативный вариант (если Browserless поддерживает прямой HTML)
body_alternative: JSON.stringify({
html: html, // Прямая передача HTML (если поддерживается)
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Метаданные
html_length: html.length,
data_url_length: dataUrl.length
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Замените YOUR_TOKEN на ваш реальный токен Browserless (если требуется)
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// - Response Format: Binary (или JSON, если Browserless возвращает base64)
// 4. После HTTP Request добавьте Code Node для извлечения base64 из ответа
// (используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js)
// ============================================================================

View File

@@ -0,0 +1,124 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless (вариант с прямым HTML)
// ============================================================================
// Альтернативный вариант - передача HTML напрямую в body
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
if ($json.html) {
html = $json.html;
} else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
} else if ($json.body?.html) {
html = $json.body.html;
} else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
} else {
throw new Error('HTML не найден');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен
// ================== ВАРИАНТ: Использование /screenshot или /pdf ==================
// Browserless может иметь разные эндпоинты
// Вариант A: POST /pdf с HTML в body
const requestA = {
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
}
})
};
// Вариант B: POST /pdf с data URL
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
const requestB = {
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
url: dataUrl,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
}
})
};
// Вариант C: POST /screenshot (если /pdf не работает)
const requestC = {
method: 'POST',
url: `${BROWSERLESS_URL}/screenshot`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
url: dataUrl,
options: {
type: 'pdf',
format: 'A4',
printBackground: true
}
})
};
return [{
json: {
// Используйте один из вариантов ниже
// Попробуйте сначала вариант A, если не работает - B, затем C
// === ВАРИАНТ A: Прямой HTML ===
method_a: requestA.method,
url_a: requestA.url,
headers_a: requestA.headers,
body_a: requestA.body,
// === ВАРИАНТ B: Data URL ===
method_b: requestB.method,
url_b: requestB.url,
headers_b: requestB.headers,
body_b: requestB.body,
// === ВАРИАНТ C: Screenshot (PDF) ===
method_c: requestC.method,
url_c: requestC.url,
headers_c: requestC.headers,
body_c: requestC.body,
// Метаданные
html_length: html.length,
instruction: 'Попробуйте сначала вариант A в HTTP Request ноде'
}
}];
// ============================================================================
// ОТЛАДКА:
// ============================================================================
// Если получаете ошибку аутентификации:
// 1. Проверьте, нужен ли токен для вашего Browserless
// 2. Если токен не требуется, уберите строку Authorization из headers
// 3. Проверьте документацию Browserless: https://docs.browserless.io
// ============================================================================

View File

@@ -0,0 +1,112 @@
# Полный Workflow: HTML → Base64 PDF
## Структура
```
[HTTP Request: FlightAware]
[HTTP Request: FlightRadar24]
[Code: Process Flights Data] ← Генерирует HTML + подготавливает запрос для PDF
[HTTP Request: Convert to PDF] ← Конвертирует HTML в base64 PDF
[Code: Extract Base64 PDF] ← Извлекает base64 из ответа
[Использование base64 PDF]
```
## Настройка нод
### 1. Code: Process Flights Data
**Код:** Используйте обновлённый `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
**Выходные данные:**
```json
{
"html": "<!DOCTYPE html>...",
"flights": [...],
"pdf_request_method": "POST",
"pdf_request_url": "https://api.htmlpdfapi.com/v1/pdf",
"pdf_request_headers": {...},
"pdf_request_body": "{...}"
}
```
### 2. HTTP Request: Convert to PDF
**Название:** `HTTP Request: Convert to PDF`
**Настройка:**
- **Method:** `{{ $json.pdf_request_method }}`
- **URL:** `{{ $json.pdf_request_url }}`
- **Authentication:** None (или по необходимости)
- **Headers:**
```json
{{ $json.pdf_request_headers }}
```
- **Body:**
```json
{{ $json.pdf_request_body }}
```
- **Response Format:** `JSON`
### 3. Code: Extract Base64 PDF
**Название:** `Code: Extract Base64 PDF`
**Код:** Используйте `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
"pdf_size_mb": "0.12",
"filename": "flights-report-2026-01-16.pdf",
"success": true
}
```
## Альтернатива: Использование Convert to File
Если вы хотите использовать ноду **Convert to File** для создания HTML файла, а затем конвертировать его в PDF:
### Вариант A: HTML файл → PDF через сервис
```
[Code: Process Flights Data]
[Convert to File] ← Operation: "html", Put Output File in Field: {{ $json.html }}
[HTTP Request: Convert to PDF] ← Отправьте binary HTML файл в сервис конвертации
[Code: Extract Base64 PDF]
```
### Вариант B: Прямая конвертация HTML → Base64 PDF
Пропустите ноду Convert to File и используйте HTML напрямую:
```
[Code: Process Flights Data]
[HTTP Request: Convert to PDF] ← Используйте {{ $json.html }} в body
[Code: Extract Base64 PDF]
```
## Настройка API ключа
В файле `N8N_CODE_PROCESS_FLIGHTS_DATA.js` найдите строку:
```javascript
const PDF_API_KEY = 'YOUR_API_KEY';
```
Замените `YOUR_API_KEY` на ваш реальный API ключ от сервиса конвертации.
## Популярные сервисы
1. **htmlpdfapi.com** - 100 PDF/месяц бесплатно
2. **pdfshift.io** - 100 PDF/месяц бесплатно
3. **api2pdf.com** - 50 PDF/месяц бесплатно

View File

@@ -0,0 +1,132 @@
// ============================================================================
// n8n Code Node: HTML → PDF через браузер (Puppeteer/Playwright)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// Подготавливает команду для Execute Command ноды с puppeteer
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
// Вариант 1: HTML уже есть в json.html
if ($json.html) {
html = $json.html;
}
// Вариант 2: HTML в base64
else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
}
// Вариант 3: HTML в другом поле
else if ($json.body?.html) {
html = $json.body.html;
}
// Вариант 4: Пытаемся получить из binary
else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
}
else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== ВАРИАНТ 1: Execute Command с Puppeteer ==================
// Требует: npm install puppeteer в контейнере n8n
// Команда для Execute Command ноды:
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const timestamp = Date.now();
const htmlFile = `/tmp/flights-${timestamp}.html`;
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
// Команда для Execute Command ноды:
const command = `node -e "
const puppeteer = require('puppeteer');
const fs = require('fs');
const html = Buffer.from('${htmlBase64}', 'base64').toString('utf8');
(async () => {
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.pdf({
path: '${pdfFile}',
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
});
await browser.close();
const pdfBuffer = fs.readFileSync('${pdfFile}');
const base64 = pdfBuffer.toString('base64');
console.log(base64);
fs.unlinkSync('${pdfFile}');
})();
"`;
return [{
json: {
// Команда для Execute Command ноды
command: command,
// Или используйте этот вариант (проще):
html_file: htmlFile,
pdf_file: pdfFile,
html_base64: htmlBase64,
// Инструкция
instruction: 'Используйте Execute Command ноду с одной из команд ниже'
}
}];
// ================== ВАРИАНТ 2: HTTP Request к сервису с браузером ==================
// Раскомментируйте, если используете внешний сервис (Gotenberg, Browserless, etc.)
/*
const PDF_SERVICE_URL = 'https://api.gotenberg.dev/forms/chromium/convert/html';
// Или Browserless: 'https://chrome.browserless.io/pdf'
return [{
json: {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'multipart/form-data'
},
body: {
files: [{
name: 'index.html',
content: html
}],
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}
}
}];
*/
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// ВАРИАНТ 1: Execute Command (если puppeteer установлен)
// 1. Установите puppeteer в контейнере n8n:
// docker exec -it <n8n_container> npm install puppeteer
// 2. Добавьте Execute Command ноду после этого Code Node
// 3. В команде используйте: {{ $json.command }}
// 4. После Execute Command добавьте Code Node для извлечения base64 из вывода
//
// ВАРИАНТ 2: HTTP Request к Gotenberg (self-hosted браузер)
// 1. Запустите Gotenberg: docker run -p 3000:3000 gotenberg/gotenberg:7
// 2. Используйте код выше (раскомментируйте)
// 3. Добавьте HTTP Request ноду
//
// ВАРИАНТ 3: HTTP Request к Browserless (cloud сервис)
// 1. Зарегистрируйтесь на browserless.io
// 2. Используйте их API для конвертации
// ============================================================================

View File

@@ -0,0 +1,96 @@
// ============================================================================
// n8n Code Node: Конвертация HTML в Base64 PDF
// ============================================================================
// Используйте этот код после "Code: Process Flights Data"
// для подготовки данных для конвертации в PDF и получения base64
// ============================================================================
// Получаем HTML из предыдущей ноды
const processedData = $('Code: Process Flights Data').first().json;
if (!processedData || !processedData.html) {
throw new Error('HTML не получен из предыдущей ноды');
}
const html = processedData.html;
// ==== ВАРИАНТ 1: HTTP Request к сервису, который возвращает base64 PDF ====
// Используйте этот вариант с HTTP Request нодой после этого Code Node
// Сервисы, которые поддерживают base64:
// - htmlpdfapi.com
// - pdfshift.io
// - api2pdf.com
// - и другие
return [{
json: {
method: 'POST',
url: 'https://api.htmlpdfapi.com/v1/pdf', // Замените на ваш сервис
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' // Замените на ваш API ключ
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
// Если сервис поддерживает прямое возвращение base64
base64: true
})
}
}];
// ==== ВАРИАНТ 2: Если сервис возвращает binary, конвертируем в base64 ====
// Используйте этот код в Code Node ПОСЛЕ HTTP Request ноды
// (когда получили PDF в binary формате)
/*
const pdfBinary = $binary.data; // Получаем binary данные из HTTP Request
// Конвертируем binary в base64
const base64 = pdfBinary.toString('base64');
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: pdfBinary.length,
pdf_size_mb: (pdfBinary.length / (1024 * 1024)).toFixed(2),
flights_count: processedData.flights_count,
generated_at: processedData.generated_at,
filename: `flights-report-${new Date().toISOString().split('T')[0]}.pdf`
}
}];
*/
// ==== ВАРИАНТ 3: Использование Execute Command с wkhtmltopdf ====
// Если у вас установлен wkhtmltopdf на сервере n8n
// Раскомментируйте и используйте в Execute Command ноде
/*
// Сохраняем HTML во временный файл
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const timestamp = Date.now();
const htmlFile = `/tmp/flights-${timestamp}.html`;
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
// Команда для Execute Command ноды:
// echo '{{ $json.html_base64 }}' | base64 -d > {{ $json.html_file }} && \
// wkhtmltopdf --page-size A4 --margin-top 20mm --margin-right 15mm --margin-bottom 20mm --margin-left 15mm \
// --print-media-type {{ $json.html_file }} {{ $json.pdf_file }} && \
// cat {{ $json.pdf_file }} | base64 && \
// rm -f {{ $json.html_file }} {{ $json.pdf_file }}
return [{
json: {
html_base64: htmlBase64,
html_file: htmlFile,
pdf_file: pdfFile
}
}];
*/

View File

@@ -0,0 +1,35 @@
# Улучшенное форматирование PDF отчёта
## Что изменено
### Уменьшены отступы:
- **Padding секций:** с `20px``12px 18px`
- **Margin между элементами:** с `20px``12px`
- **Padding карточек:** с `20px``14px 18px`
- **Отступы в timeline:** с `10px``6px`
### Уменьшены размеры шрифтов:
- **Заголовки:** с `24px``20px`
- **Основной текст:** с `14px``13px`
- **Метки:** с `12px``11px`
### Более компактная компоновка:
- **Route info:** уменьшен gap с `15px``12px`
- **Status info:** уменьшен gap с `15px``10px`, minmax с `200px``180px`
- **Timeline:** уменьшена ширина label с `180px``160px`
### Общие улучшения:
- Уменьшен `line-height` с `1.6``1.4` для более плотного текста
- Уменьшены отступы body с `20px``15px`
- Уменьшены отступы container с `30px``20px`
## Результат
**Более компактное отображение** - данные расположены ближе друг к другу
**Меньше разрывов** - плавные переходы между секциями
**Лучшая читаемость** - оптимальный баланс между компактностью и читаемостью
**Экономия места** - больше информации на странице
## Как применить
Скопируйте обновлённый код из `N8N_FLIGHTS_TO_BASE64.js` в вашу Code Node "причесываем данные".

View File

@@ -0,0 +1,72 @@
// ============================================================================
// n8n Code Node: Полный цикл - HTML → Base64 PDF (всё в одном)
// ============================================================================
// Этот код делает всё: получает HTML, отправляет на конвертацию, получает base64
// Требует настройки HTTP Request ноды или внешнего сервиса
// ============================================================================
// Получаем HTML из предыдущей ноды "Code: Process Flights Data"
const processedData = $('Code: Process Flights Data').first().json;
if (!processedData || !processedData.html) {
throw new Error('HTML не получен из предыдущей ноды');
}
const html = processedData.html;
// ==== НАСТРОЙКИ ====
// Замените на ваши параметры
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
const PDF_API_KEY = 'YOUR_API_KEY'; // Замените на ваш ключ
// ==== ПОДГОТОВКА ЗАПРОСА ДЛЯ HTTP REQUEST ====
// Этот код подготавливает данные для HTTP Request ноды
// После этого Code Node добавьте HTTP Request ноду и используйте эти данные
return [{
json: {
// Данные для HTTP Request ноды
http_method: 'POST',
http_url: PDF_SERVICE_URL,
http_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
http_body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
}),
// Метаданные
html_length: html.length,
flights_count: processedData.flights_count,
generated_at: processedData.generated_at,
// Инструкция для следующей ноды
next_step: 'HTTP Request → Code: Extract Base64 PDF'
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Этот Code Node подготавливает запрос
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде используйте:
// - Method: {{ $json.http_method }}
// - URL: {{ $json.http_url }}
// - Headers: {{ $json.http_headers }}
// - Body: {{ $json.http_body }}
// 4. После HTTP Request добавьте Code Node с кодом из N8N_FLIGHTS_PDF_BASE64_FULL.js
// для извлечения base64 из ответа
// ============================================================================

View File

@@ -0,0 +1,81 @@
// ============================================================================
// n8n Code Node: Полная обработка - HTML → Base64 PDF
// ============================================================================
// Этот код обрабатывает ответ от HTTP Request и возвращает base64 PDF
// Используйте ПОСЛЕ HTTP Request ноды, которая конвертирует HTML в PDF
// ============================================================================
// Получаем данные из HTTP Request ноды
const httpResponse = $input.first();
if (!httpResponse) {
throw new Error('Ответ от HTTP Request не получен');
}
// ==== ВАРИАНТ 1: Сервис вернул base64 напрямую в JSON ====
if (httpResponse.json && httpResponse.json.pdf) {
const base64 = httpResponse.json.pdf;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: Math.floor(base64.length * 0.75), // Примерный размер
pdf_size_mb: (Math.floor(base64.length * 0.75) / (1024 * 1024)).toFixed(2),
success: true,
source: 'json_response'
}
}];
}
// ==== ВАРИАНТ 2: Сервис вернул binary данные ====
if (httpResponse.binary && httpResponse.binary.data) {
const pdfBinary = httpResponse.binary.data;
// Конвертируем binary в base64
// В n8n binary.data может быть Buffer или строка
let base64;
if (Buffer.isBuffer(pdfBinary)) {
base64 = pdfBinary.toString('base64');
} else if (typeof pdfBinary === 'string') {
// Если уже base64 строка
base64 = pdfBinary;
} else {
// Пытаемся преобразовать
base64 = Buffer.from(pdfBinary).toString('base64');
}
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true,
source: 'binary_response'
}
}];
}
// ==== ВАРИАНТ 3: Сервис вернул base64 в поле body или data ====
if (httpResponse.json) {
const body = httpResponse.json.body || httpResponse.json.data || httpResponse.json;
if (body.pdf || body.base64 || body.content) {
const base64 = body.pdf || body.base64 || body.content;
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true,
source: 'body_field'
}
}];
}
}
// ==== ОШИБКА: Не удалось извлечь PDF ====
throw new Error('Не удалось извлечь PDF из ответа. Структура ответа: ' + JSON.stringify(Object.keys(httpResponse), null, 2));

View File

@@ -0,0 +1,65 @@
// ============================================================================
// n8n Code Node: Подготовка данных запроса рейса
// ============================================================================
// Используйте эту ноду ПЕРЕД "причесываем данные"
// Она безопасно получает данные из ноды "запрос рейса" и передаёт их дальше
// ============================================================================
// Получаем данные из ноды "запрос рейса"
let requestData = {
flight_number: null,
departure_date_local: null,
arrival_date_local: null
};
try {
const requestNode = $('запрос рейса');
if (requestNode && requestNode.first()) {
const requestJson = requestNode.first().json;
if (requestJson) {
requestData = {
flight_number: requestJson.flight_number || requestJson.ident || requestJson.flight || null,
departure_date_local: requestJson.departure_date_local || null,
arrival_date_local: requestJson.arrival_date_local || null
};
}
}
} catch (e) {
console.log('⚠️ Не удалось получить данные из ноды "запрос рейса":', e.message);
}
// Получаем данные из входных элементов (fallback)
const inputItems = $input.all();
inputItems.forEach(item => {
if (item.json) {
if (!requestData.flight_number && item.json.flight_number) {
requestData.flight_number = item.json.flight_number;
}
if (!requestData.departure_date_local && item.json.departure_date_local) {
requestData.departure_date_local = item.json.departure_date_local;
}
if (!requestData.arrival_date_local && item.json.arrival_date_local) {
requestData.arrival_date_local = item.json.arrival_date_local;
}
}
});
// Передаём данные дальше вместе с входными данными
const outputItems = inputItems.map(item => ({
...item,
json: {
...item.json,
// Добавляем данные запроса
request_flight_number: requestData.flight_number,
request_departure_date: requestData.departure_date_local,
request_arrival_date: requestData.arrival_date_local
}
}));
return outputItems.length > 0 ? outputItems : [{
json: {
request_flight_number: requestData.flight_number,
request_departure_date: requestData.departure_date_local,
request_arrival_date: requestData.arrival_date_local
}
}];

View File

@@ -0,0 +1,193 @@
# Обработка данных о рейсах в n8n
## Описание
Код для обработки данных о рейсах из двух источников (FlightAware и FlightRadar24), объединения их и генерации красивого HTML для последующей конвертации в PDF.
## Структура входных данных
Workflow должен получать данные в следующем формате:
```json
[
{
"body": {
"flights": [
{
"ident": "CES747",
"registration": "B-1308",
"origin": { "code_iata": "KMG", "name": "Kunming Changshui Int'l" },
"destination": { "code_iata": "PVG", "name": "Shanghai Pudong Int'l" },
...
}
]
}
},
{
"body": {
"data": [
{
"flight": "MU747",
"reg": "B-1308",
"orig_iata": "KMG",
"dest_iata": "PVG",
...
}
]
}
}
]
```
## Установка в n8n
### Шаг 1: Добавить Code Node
1. В вашем workflow после получения данных из FlightAware и FlightRadar24
2. Добавьте ноду **Code** (JavaScript)
3. Назовите её: `Code: Process Flights Data`
### Шаг 2: Вставить код
Скопируйте содержимое файла `N8N_CODE_PROCESS_FLIGHTS_DATA.js` в Code Node.
### Шаг 3: Настройка выхода
Code Node вернёт объект с полями:
- `html` - готовый HTML для конвертации в PDF
- `flights` - массив объединённых данных о рейсах
- `flights_count` - количество рейсов
- `sources` - информация о доступности источников
- `generated_at` - время генерации
## Конвертация HTML в Base64 PDF
### Вариант 1: HTTP Request → Base64 PDF (Рекомендуется)
**Шаг 1:** После Code Node добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
- Этот код подготавливает запрос для HTTP Request
**Шаг 2:** Добавьте HTTP Request ноду:
- Method: `POST`
- URL: `{{ $json.http_url }}` (например, `https://api.htmlpdfapi.com/v1/pdf`)
- Headers: `{{ $json.http_headers }}`
- Body: `{{ $json.http_body }}`
- Response Format: `JSON` или `Binary` (в зависимости от сервиса)
**Шаг 3:** После HTTP Request добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
- Этот код извлекает base64 из ответа сервиса
**Результат:** В выходных данных будет поле `pdf_base64` с готовым PDF в формате base64
### Вариант 2: Прямой запрос к сервису
Используйте код из `N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js` для подготовки запроса к сервису конвертации.
**Популярные сервисы:**
- **htmlpdfapi.com** - возвращает base64 в JSON
- **pdfshift.io** - поддерживает base64
- **api2pdf.com** - возвращает base64
- **gotenberg.dev** - бесплатный self-hosted вариант
### Вариант 3: Execute Command с wkhtmltopdf
Если на сервере n8n установлен `wkhtmltopdf`:
1. Сохраните HTML во временный файл
2. Выполните команду:
```bash
wkhtmltopdf --page-size A4 \
--margin-top 20mm --margin-right 15mm \
--margin-bottom 20mm --margin-left 15mm \
--print-media-type input.html output.pdf && \
cat output.pdf | base64
```
3. Получите base64 из вывода команды
### Использование base64 PDF
После получения base64 вы можете:
- Сохранить в файл
- Отправить по email
- Загрузить в S3/Nextcloud
- Вернуть в API response
- Использовать в других workflow
## Особенности обработки
### Объединение данных
Данные объединяются по полю `registration` (номер самолёта):
- FlightAware: `flight.registration`
- FlightRadar24: `flight.reg`
Если для рейса есть данные только из одного источника, они всё равно будут отображены.
### Обработка отсутствующих данных
- Если данные из источника отсутствуют, показывается сообщение "Данные не получены"
- Пустые значения отображаются как "—"
- Даты форматируются в читаемый формат
### Форматирование
HTML включает:
- Красивый дизайн с градиентами и карточками
- Адаптивную вёрстку
- Стили для печати (media queries для print)
- Цветовую индикацию источников данных
- Информацию о задержках (зелёный/красный)
## Пример workflow
```
HTTP Request (FlightAware)
HTTP Request (FlightRadar24)
Code: Process Flights Data ← Вставить код отсюда
HTML/CSS to PDF (или HTTP Request для конвертации)
Save File / Send Email / etc.
```
## Отладка
Если данные не обрабатываются:
1. Проверьте структуру входных данных через `console.log`:
```javascript
console.log('FlightAware:', JSON.stringify(flightAwareData, null, 2));
console.log('FlightRadar24:', JSON.stringify(flightRadar24Data, null, 2));
```
2. Убедитесь, что данные приходят в правильном порядке:
- Первый элемент = FlightAware
- Второй элемент = FlightRadar24
3. Проверьте наличие полей `body.flights` и `body.data`
## Дополнительные возможности
### Кастомизация HTML
Вы можете изменить стили в функции `generateFullHTML()`:
- Цвета
- Шрифты
- Размеры
- Расположение элементов
### Добавление дополнительных полей
В функции `generateFlightCard()` можно добавить отображение дополнительных полей из API.
### Фильтрация рейсов
Перед генерацией HTML можно отфильтровать рейсы:
```javascript
const filteredFlights = mergedFlights.filter(flight => {
// Ваша логика фильтрации
return flight.flightAware || flight.flightRadar24;
});
```

View File

@@ -0,0 +1,236 @@
# Быстрый старт: HTML → Base64 PDF в n8n
## Проблема
У вас есть HTML в формате:
```json
{
"html": "<!DOCTYPE html>..."
}
```
Нужно получить base64 PDF.
## Решение: 3 ноды
### Шаг 1: Code Node - Подготовка запроса
**Название:** `Code: Prepare PDF Request`
**Код:** Скопируйте из `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`
**Важно:**
- Замените `YOUR_API_KEY` на ваш реальный API ключ
- Выберите сервис конвертации (htmlpdfapi.com, pdfshift.io и т.д.)
**Выходные данные:**
```json
{
"method": "POST",
"url": "https://api.htmlpdfapi.com/v1/pdf",
"headers": {...},
"body": "{...}"
}
```
---
### Шаг 2: HTTP Request - Конвертация
**Название:** `HTTP Request: Convert to PDF`
**Настройка:**
- **Method:** `{{ $json.method }}`
- **URL:** `{{ $json.url }}`
- **Authentication:** None (или Basic, если требуется)
- **Headers:**
```json
{{ $json.headers }}
```
- **Body:**
```json
{{ $json.body }}
```
- **Response Format:** `JSON` (или `Binary`, если сервис возвращает binary)
**Что делает:** Отправляет HTML в сервис конвертации и получает PDF
---
### Шаг 3: Code Node - Извлечение Base64
**Название:** `Code: Extract Base64 PDF`
**Код:** Скопируйте из `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
"pdf_size_bytes": 123456,
"pdf_size_mb": "0.12",
"filename": "flights-report-2026-01-16.pdf",
"success": true
}
```
---
## Готово!
Теперь у вас есть base64 PDF в поле `pdf_base64`.
## Что дальше?
### Вариант A: Сохранить в файл
Добавьте Code Node:
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
binary: {
data: pdfBuffer,
fileName: $('Code: Extract Base64 PDF').first().json.filename,
mimeType: 'application/pdf'
}
}];
```
Затем используйте ноду **Write Binary File** или **Save to S3**.
### Вариант B: Вернуть в API
Добавьте Code Node перед Response:
```javascript
const pdfData = $('Code: Extract Base64 PDF').first().json;
return [{
json: {
success: true,
pdf_base64: pdfData.pdf_base64,
pdf_size_mb: pdfData.pdf_size_mb,
filename: pdfData.filename
}
}];
```
### Вариант C: Отправить по Email
Добавьте Code Node:
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
const filename = $('Code: Extract Base64 PDF').first().json.filename;
return [{
json: {
to: 'recipient@example.com',
subject: 'Отчёт о рейсах',
text: 'Во вложении отчёт о рейсах.',
attachments: [{
filename: filename,
content: pdfBuffer,
contentType: 'application/pdf'
}]
}
}];
```
Затем используйте ноду **Email Send**.
---
## Популярные сервисы конвертации
### 1. htmlpdfapi.com (рекомендуется)
- **Бесплатно:** 100 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://htmlpdfapi.com
- **Возвращает:** `{ pdf: "base64..." }`
### 2. pdfshift.io
- **Бесплатно:** 100 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://pdfshift.io
- **Возвращает:** binary или base64
### 3. api2pdf.com
- **Бесплатно:** 50 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://www.api2pdf.com
- **Возвращает:** `{ Pdf: "base64..." }`
### 4. Self-hosted: Gotenberg
- **Бесплатно:** полностью
- **Требует:** Docker
- **URL:** https://gotenberg.dev
- **Возвращает:** binary PDF
---
## Отладка
### Проверка HTML
В Code Node добавьте:
```javascript
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 200));
```
### Проверка ответа сервиса
После HTTP Request добавьте Code Node:
```javascript
const response = $input.first();
console.log('Response keys:', Object.keys(response));
console.log('Response json keys:', response.json ? Object.keys(response.json) : 'no json');
console.log('Response binary:', response.binary ? 'yes' : 'no');
```
### Проверка base64
После извлечения base64:
```javascript
const base64 = $json.pdf_base64;
console.log('Base64 length:', base64.length);
console.log('Base64 preview:', base64.substring(0, 50));
```
---
## Частые проблемы
### Проблема: "HTML не найден"
**Решение:** Проверьте, что HTML приходит в поле `html`. Если нет, измените первую строку в `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`:
```javascript
const html = $json.html || $json.body?.html || $json.data?.html || $json;
```
### Проблема: "Не удалось извлечь base64"
**Решение:**
1. Проверьте формат ответа сервиса
2. Добавьте логирование в `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
3. Убедитесь, что сервис действительно вернул PDF
### Проблема: PDF пустой или повреждён
**Решение:**
1. Проверьте, что HTML валидный
2. Убедитесь, что CSS включён в HTML (inline styles)
3. Проверьте, что сервис поддерживает все используемые CSS свойства
---
## Готовый Workflow
```
[Ваша нода с HTML]
Code: Prepare PDF Request
HTTP Request: Convert to PDF
Code: Extract Base64 PDF
[Использование base64]
```
Всё готово! 🎉

View File

@@ -0,0 +1,103 @@
# Отображение запрошенных рейсов без данных
## Проблема
Когда данных о рейсе нет, нужно показывать, по какому рейсу и запросу информация отсутствует.
## Решение
Код автоматически извлекает информацию о запрошенных рейсах и показывает их даже если данных нет.
## Способы передачи информации о запрошенных рейсах
### Вариант 1: Прямая передача (рекомендуется)
В предыдущей ноде (перед Code Node) добавьте информацию о запрошенных рейсах:
```javascript
// В Code Node перед "причесываем данные"
return [{
json: {
// Ваши данные
...existingData,
// Информация о запрошенных рейсах
requested_flights: ['MU747', 'CES747'], // Массив номеров рейсов
// ИЛИ
flight_number: 'MU747', // Один рейс
// ИЛИ
flight_numbers: ['MU747', 'CES747'] // Альтернативный формат
}
}];
```
### Вариант 2: Автоматическое извлечение
Код автоматически пытается извлечь информацию о рейсах из:
- URL запросов (параметры `ident`, `flight_number`, `flight`, `callsign`)
- Query параметров
- Body запросов
- Прямых полей в JSON
## Что отображается
Если рейс был запрошен, но данных нет, показывается карточка:
```
┌─ Рейс MU747 ───────────────┐
│ Запрошен │
│ │
│ Запрошенный рейс: MU747 │
│ │
│ [FlightAware] │
│ ✗ Данные не получены │
│ │
│ [FlightRadar24] │
│ ✗ Данные не получены │
└────────────────────────────┘
```
## Пример использования
### В предыдущей ноде (HTTP Request или Code Node):
```javascript
// После запросов к FlightAware и FlightRadar24
return [{
json: {
data: [
{ body: { flights: [...] } }, // FlightAware ответ
{ body: { data: [...] } } // FlightRadar24 ответ
],
// Добавляем информацию о запрошенных рейсах
requested_flights: ['MU747', 'CES747']
}
}];
```
### Или в отдельной ноде перед обработкой:
```javascript
// Code Node: Prepare Request Info
const flightNumbers = ['MU747', 'CES747']; // Из вашего запроса
return [{
json: {
requested_flights: flightNumbers,
// Другие данные...
}
}];
```
## Преимущества
**Прозрачность** - видно, какие рейсы запрашивались
**Отладка** - легко понять, почему данных нет
**Информативность** - пользователь видит, что запрос был выполнен
**Автоматика** - код пытается извлечь информацию автоматически
## Если данные всё равно не показываются
1. Проверьте, что передаёте `requested_flights` в предыдущей ноде
2. Убедитесь, что формат правильный: массив строк или объект с полем `flight_number`
3. Проверьте логи в Code Node - там будут сообщения о найденных запрошенных рейсах

View File

@@ -0,0 +1,370 @@
// ============================================================================
// n8n Code Node: Отчёт о рейсах (HTML → Binary + Base64 PDF)
// ============================================================================
// Упрощённая версия с возвратом binary HTML и подготовкой для PDF конвертации
// ============================================================================
const inputItems = $input.all();
// ================== FALLBACK ==================
if (!inputItems || inputItems.length === 0) {
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
return [{
binary: {
data: Buffer.from(html, 'utf8'),
mimeType: 'text/html',
fileName: 'flights-report.html'
},
json: {
html: html,
flights_count: 0,
error: 'Нет входных данных'
}
}];
}
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
let flightAwareData = [];
let flightRadar24Data = [];
try {
const fa = inputItems[0]?.json?.body?.flights;
if (Array.isArray(fa)) flightAwareData = fa;
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
}
try {
const fr = inputItems[1]?.json?.body?.data;
if (Array.isArray(fr)) flightRadar24Data = fr;
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
}
// ================== УТИЛИТЫ ==================
const safeStr = v => (v == null ? '' : String(v));
const safeDate = v => {
if (!v) return '—';
try {
const d = new Date(v);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '—';
}
};
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
// ================== MERGE ПО REGISTRATION ==================
const flightsMap = new Map();
flightAwareData.forEach(f => {
const reg = safeStr(f.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight_number),
ident: safeStr(f.ident),
identIata: safeStr(f.ident_iata),
aircraftType: safeStr(f.aircraft_type),
fa: f,
fr: null
});
} else {
flightsMap.get(reg).fa = f;
}
});
flightRadar24Data.forEach(f => {
const reg = safeStr(f.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight),
ident: safeStr(f.callsign),
identIata: safeStr(f.flight),
aircraftType: safeStr(f.type),
fa: null,
fr: f
});
} else {
flightsMap.get(reg).fr = f;
}
});
const flights = Array.from(flightsMap.values());
// ================== HTML GENERATION ==================
const generateFlightCard = f => {
const fa = f.fa;
const fr = f.fr;
let card = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">${f.registration}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${f.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
</div>
</div>`;
if (fa) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Статус:</span>
<span class="timeline-value">${safeStr(fa.status || '—')}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
if (fr) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Расстояние:</span>
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
card += `</div>`;
return card;
};
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }
.header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }
.header-meta { color: #666; font-size: 14px; }
.sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
.source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.source-tag.available { background: #d1fae5; color: #065f46; }
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
.flight-header h2 { font-size: 24px; margin: 0; }
.registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
.flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.info-row { display: flex; margin-bottom: 8px; }
.info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }
.info-row .value { color: #111827; }
.source-section { border-top: 1px solid #e5e7eb; padding: 20px; }
.source-section:first-of-type { border-top: none; }
.source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
.source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }
.source-badge.source-flightaware { background: #3b82f6; }
.source-badge.source-flightradar24 { background: #10b981; }
.source-missing { color: #ef4444; font-size: 13px; font-style: italic; }
.source-content { margin-left: 0; }
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }
.route-item { display: flex; flex-direction: column; }
.route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.route-value { font-size: 16px; font-weight: 600; color: #111827; }
.timeline { margin-bottom: 20px; }
.timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
.timeline-item:last-child { border-bottom: none; }
.timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }
.timeline-value { color: #111827; text-align: right; }
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }
.status-item { display: flex; flex-direction: column; }
.status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.status-value { font-size: 14px; font-weight: 600; color: #111827; }
.no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flights.length ? flights.map(generateFlightCard).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
</div>
</div>
</body>
</html>`;
// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ PDF КОНВЕРТАЦИИ ==================
// Настройки сервиса (замените на ваши)
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf';
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
// ================== RETURN ==================
return [{
// Binary HTML файл (для использования в Convert to File ноде или сохранения)
binary: {
data: Buffer.from(html, 'utf8'),
mimeType: 'text/html',
fileName: `flights-report-${now.toISOString().split('T')[0]}.html`
},
// JSON данные
json: {
// HTML строка (для конвертации в PDF через HTTP Request)
html: html,
// Метаданные
flights_count: flights.length,
generated_at: now.toISOString(),
sources: {
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
},
// Данные для конвертации в base64 PDF (используйте в следующей HTTP Request ноде)
pdf_request: {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
},
base64: true
})
},
// Удобные поля для HTTP Request ноды
pdf_request_method: 'POST',
pdf_request_url: PDF_SERVICE_URL,
pdf_request_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
pdf_request_body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
},
base64: true
})
}
}];
// ============================================================================
// ИСПОЛЬЗОВАНИЕ:
// ============================================================================
// 1. Binary HTML можно использовать в ноде "Convert to File" или сохранить
// 2. JSON.html можно использовать для конвертации в PDF через HTTP Request
// 3. JSON.pdf_request_* поля готовы для использования в HTTP Request ноде
// 4. После HTTP Request используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js
// для извлечения base64 PDF из ответа
// ============================================================================

View File

@@ -0,0 +1,630 @@
// ============================================================================
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
// ============================================================================
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
// Выход: base64 HTML
// ============================================================================
const inputItems = $input.all();
// ================== FALLBACK ==================
if (!inputItems || inputItems.length === 0) {
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
return [{
json: {
html_base64: htmlBase64,
html: html,
flights_count: 0,
error: 'Нет входных данных'
}
}];
}
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
// Новая структура: [{ data: [{ body: { flights: [...] }}, { error: {...} }, { flight_number, ... }] }]
let flightAwareData = [];
let flightRadar24Data = [];
let requestData = null; // Данные из ноды "запрос рейса"
let flightRadar24Error = null; // Ошибка от FlightRadar24
try {
const firstItem = inputItems[0];
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
// Первый элемент массива data - FlightAware
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
if (firstItem.json.data[0].body.flights) {
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
? firstItem.json.data[0].body.flights
: [];
}
}
// Второй элемент массива data - FlightRadar24 (может быть ошибка)
if (firstItem.json.data[1]) {
// Проверяем, есть ли ошибка
if (firstItem.json.data[1].error) {
flightRadar24Error = firstItem.json.data[1].error;
console.log('⚠️ Ошибка FlightRadar24:', flightRadar24Error.message);
flightRadar24Data = [];
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
? firstItem.json.data[1].body.data
: [];
}
}
// Третий элемент массива data - данные из ноды "запрос рейса"
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
requestData = {
flight_number: firstItem.json.data[2].flight_number,
departure_date_local: firstItem.json.data[2].departure_date_local || null,
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
};
console.log('✅ Данные запроса получены:', requestData);
}
}
} catch (e) {
console.log('⚠️ Ошибка извлечения данных:', e.message);
}
// ================== УТИЛИТЫ ==================
const safeStr = v => (v == null ? '' : String(v));
const safeDate = v => {
if (!v) return '—';
try {
const d = new Date(v);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '—';
}
};
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
// ================== MERGE ПО REGISTRATION ==================
const flightsMap = new Map();
// Добавляем данные из FlightAware
flightAwareData.forEach(f => {
const reg = safeStr(f.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight_number),
ident: safeStr(f.ident),
identIata: safeStr(f.ident_iata),
aircraftType: safeStr(f.aircraft_type),
fa: f,
fr: null
});
} else {
flightsMap.get(reg).fa = f;
}
});
// Добавляем данные из FlightRadar24
flightRadar24Data.forEach(f => {
const reg = safeStr(f.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight),
ident: safeStr(f.callsign),
identIata: safeStr(f.flight),
aircraftType: safeStr(f.type),
fa: null,
fr: f
});
} else {
flightsMap.get(reg).fr = f;
}
});
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
// Если есть информация о запрошенных рейсах, но нет данных - добавляем их
// Пытаемся извлечь из предыдущих нод (HTTP Request) или получить из входных данных
const allInputItems = $input.all();
const firstItemForRequest = inputItems[0]; // Используем уже определённую переменную из блока выше
// Ищем информацию о запрошенных рейсах
let requestedFlightNumbers = new Set();
// ВАРИАНТ 1: Получение данных из ноды "запрос рейса"
// Используем данные, извлечённые выше из data[2]
let requestFlightNumber = null;
let requestDepartureDate = null;
let requestArrivalDate = null;
if (requestData) {
requestFlightNumber = requestData.flight_number;
requestDepartureDate = requestData.departure_date_local;
requestArrivalDate = requestData.arrival_date_local;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
// Дополнительно ищем в других местах (fallback)
allInputItems.forEach(item => {
if (item.json) {
// Прямые поля из ноды "запрос рейса"
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local)) {
if (!requestFlightNumber) {
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
requestDepartureDate = item.json.departure_date_local || null;
requestArrivalDate = item.json.arrival_date_local || null;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
}
// Данные, переданные из предыдущей ноды
if (item.json.request_flight_number) {
if (!requestFlightNumber) {
requestFlightNumber = item.json.request_flight_number;
requestDepartureDate = item.json.request_departure_date || null;
requestArrivalDate = item.json.request_arrival_date || null;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
}
}
});
// ВАРИАНТ 2: Прямая передача из предыдущей ноды
if (firstItemForRequest && firstItemForRequest.json) {
// Массив запрошенных рейсов
if (firstItemForRequest.json.requested_flights && Array.isArray(firstItemForRequest.json.requested_flights)) {
firstItemForRequest.json.requested_flights.forEach(flight => {
const flightNum = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
});
}
// Один рейс
if (firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight) {
const flightNum = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
requestedFlightNumbers.add(flightNum);
}
// Массив flight_numbers
if (firstItemForRequest.json.flight_numbers && Array.isArray(firstItemForRequest.json.flight_numbers)) {
firstItemForRequest.json.flight_numbers.forEach(flightNum => {
if (flightNum) requestedFlightNumbers.add(String(flightNum));
});
}
}
// ВАРИАНТ 3: Извлечение из всех входных элементов
allInputItems.forEach(item => {
if (item.json) {
if (item.json.flight_number) {
requestedFlightNumbers.add(String(item.json.flight_number));
}
if (item.json.ident) {
requestedFlightNumbers.add(String(item.json.ident));
}
if (item.json.flight) {
requestedFlightNumbers.add(String(item.json.flight));
}
}
});
// ВАРИАНТ 2: Извлечение из URL и параметров запросов
allInputItems.forEach(item => {
// Из URL запроса
if (item.json && item.json.url) {
const url = item.json.url;
const flightMatch = url.match(/(?:ident|flight_number|flight|callsign)=([^&]+)/i);
if (flightMatch) {
requestedFlightNumbers.add(flightMatch[1]);
}
}
// Из query параметров
if (item.json && item.json.query) {
const query = item.json.query;
const flightNum = query.ident || query.flight_number || query.flight || query.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
// Из body запроса
if (item.json && item.json.body) {
const body = item.json.body;
const flightNum = body.ident || body.flight_number || body.flight || body.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
// Прямо из json
if (item.json) {
const flightNum = item.json.ident || item.json.flight_number || item.json.flight || item.json.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
});
// Добавляем запрошенные рейсы, для которых нет данных
requestedFlightNumbers.forEach(flightNum => {
// Проверяем, есть ли уже этот рейс в flightsMap
let found = false;
flightsMap.forEach((flight, reg) => {
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) {
found = true;
}
});
// Если не найден - добавляем как запрошенный без данных
if (!found) {
flightsMap.set(`REQUESTED-${flightNum}`, {
registration: '—',
flightNumber: flightNum,
ident: flightNum,
identIata: flightNum,
aircraftType: '—',
fa: null,
fr: null,
isRequested: true // Флаг, что это запрошенный рейс без данных
});
}
});
const flights = Array.from(flightsMap.values());
// ================== HTML GENERATION ==================
// Делаем flightRadar24Error доступным в функции generateFlightCard
const generateFlightCard = (f, fr24ErrorParam = null) => {
const fa = f.fa;
const fr = f.fr;
const fr24Error = fr24ErrorParam; // Локальная переменная для использования в функции
// Если это запрошенный рейс без данных
if (f.isRequested && !fa && !fr) {
// Используем данные, полученные ранее из ноды "запрос рейса"
let requestInfo = '';
// Проверяем, соответствует ли этот рейс запрошенному
const matchesRequest = requestFlightNumber && (
String(f.flightNumber) === String(requestFlightNumber) ||
String(f.ident) === String(requestFlightNumber)
);
if (matchesRequest) {
if (requestDepartureDate) {
requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
}
if (requestArrivalDate) {
requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
}
}
return `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">Запрошен</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Запрошенный рейс:</span>
<span class="value">${f.flightNumber || f.ident || '—'}</span>
</div>
${requestInfo}
</div>
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
<div class="source-content">
<div style="padding: 10px; color: #666; font-size: 13px;">
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
</div>
</div>
</div>
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
<div class="source-content">
<div style="padding: 10px; color: #666; font-size: 13px;">
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
</div>
</div>
</div>
</div>`;
}
let card = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">${f.registration || '—'}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${f.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
</div>
</div>`;
// Данные из FlightAware
if (fa) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Плановый вылет:</span>
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Статус:</span>
<span class="status-value">${safeStr(fa.status || '—')}</span>
</div>
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка вылета:</span>
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
</div>
` : ''}
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка прилёта:</span>
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
</div>
` : ''}
${fa.gate_origin ? `
<div class="status-item">
<span class="status-label">Гейт вылета:</span>
<span class="status-value">${fa.gate_origin}</span>
</div>
` : ''}
${fa.gate_destination ? `
<div class="status-item">
<span class="status-label">Гейт прилёта:</span>
<span class="status-value">${fa.gate_destination}</span>
</div>
` : ''}
${fa.baggage_claim ? `
<div class="status-item">
<span class="status-label">Выдача багажа:</span>
<span class="status-value">${fa.baggage_claim}</span>
</div>
` : ''}
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
// Данные из FlightRadar24
if (fr) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Фактическое расстояние:</span>
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Кратчайшее расстояние:</span>
<span class="status-value">${formatDistance(fr.circle_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Статус полёта:</span>
<span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
card += `</div>`;
return card;
};
// Генерация полного HTML
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
.header-meta { color: #666; font-size: 13px; }
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
.source-tag.available { background: #d1fae5; color: #065f46; }
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
.flight-header h2 { font-size: 20px; margin: 0; }
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.info-row { display: flex; margin-bottom: 6px; }
.info-row:last-child { margin-bottom: 0; }
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
.info-row .value { color: #111827; font-size: 13px; }
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
.source-section:first-of-type { border-top: none; }
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
.source-badge.source-flightaware { background: #3b82f6; }
.source-badge.source-flightradar24 { background: #10b981; }
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
.source-content { margin-left: 0; }
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
.route-item { display: flex; flex-direction: column; }
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
.timeline { margin-bottom: 12px; }
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
.timeline-item:last-child { border-bottom: none; }
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
.timeline-value { color: #111827; text-align: right; font-size: 12px; }
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
.status-item { display: flex; flex-direction: column; }
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
.delay-negative { color: #10b981; }
.delay-positive { color: #ef4444; }
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
</div>
</div>
</body>
</html>`;
// ================== HTML → BASE64 ==================
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
// ================== RETURN ==================
return [{
json: {
html_base64: htmlBase64,
html: html,
flights_count: flights.length,
sources: {
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
},
generated_at: now.toISOString()
}
}];

View File

@@ -0,0 +1,320 @@
# Пример Workflow для обработки рейсов с Base64 PDF
## Структура Workflow
```
HTTP Request (FlightAware)
HTTP Request (FlightRadar24)
Code: Process Flights Data ← N8N_CODE_PROCESS_FLIGHTS_DATA.js
Code: Prepare PDF Request ← N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
HTTP Request (Convert to PDF) ← Внешний сервис конвертации
Code: Extract Base64 PDF ← N8N_FLIGHTS_PDF_BASE64_FULL.js
[Использование base64 PDF]
├─→ Save File
├─→ Send Email
├─→ Upload to S3
└─→ Return in API Response
```
## Детальная настройка нод
### 1. HTTP Request: FlightAware
- **Method:** GET/POST (в зависимости от API)
- **URL:** `https://flightaware.com/api/...`
- **Authentication:** По необходимости
### 2. HTTP Request: FlightRadar24
- **Method:** GET/POST (в зависимости от API)
- **URL:** `https://flightradar24.com/api/...`
- **Authentication:** По необходимости
### 3. Code: Process Flights Data
**Код:** Скопируйте из `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
**Входные данные:**
- Два элемента из предыдущих HTTP Request нод
**Выходные данные:**
```json
{
"html": "<!DOCTYPE html>...",
"flights": [...],
"flights_count": 2,
"sources": {...},
"generated_at": "2026-01-14T..."
}
```
### 4. Code: Prepare PDF Request
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
**Настройка:**
- Замените `PDF_SERVICE_URL` на URL вашего сервиса
- Замените `PDF_API_KEY` на ваш API ключ
**Выходные данные:**
```json
{
"http_method": "POST",
"http_url": "https://api.htmlpdfapi.com/v1/pdf",
"http_headers": {...},
"http_body": "{...}",
"html_length": 12345,
"flights_count": 2
}
```
### 5. HTTP Request: Convert to PDF
**Настройка:**
- **Method:** `{{ $json.http_method }}`
- **URL:** `{{ $json.http_url }}`
- **Authentication:** По необходимости (через Headers)
- **Headers:**
```json
{{ $json.http_headers }}
```
- **Body:**
```json
{{ $json.http_body }}
```
- **Response Format:** `JSON` или `Binary` (зависит от сервиса)
**Пример для htmlpdfapi.com:**
```json
{
"method": "POST",
"url": "https://api.htmlpdfapi.com/v1/pdf",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
"body": {
"html": "{{ $('Code: Process Flights Data').first().json.html }}",
"options": {
"format": "A4",
"printBackground": true
},
"base64": true
}
}
```
### 6. Code: Extract Base64 PDF
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
**Входные данные:**
- Ответ от HTTP Request ноды (JSON или Binary)
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGg...",
"pdf_size_bytes": 123456,
"pdf_size_mb": "0.12",
"success": true,
"source": "json_response"
}
```
## Использование base64 PDF
### Вариант A: Сохранение в файл
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
// Конвертируем base64 в binary
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
binary: {
data: pdfBuffer,
fileName: filename,
mimeType: 'application/pdf'
},
json: {
filename: filename,
size_bytes: pdfBuffer.length
}
}];
```
Затем используйте ноду **Write Binary File** или **Save to S3**.
### Вариант B: Отправка по Email
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
json: {
to: 'recipient@example.com',
subject: 'Отчёт о рейсах',
text: 'Во вложении отчёт о рейсах.',
attachments: [{
filename: 'flights-report.pdf',
content: pdfBuffer,
contentType: 'application/pdf'
}]
}
}];
```
Затем используйте ноду **Email Send**.
### Вариант C: Возврат в API Response
**Code Node (перед Response нодой):**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const processedData = $('Code: Process Flights Data').first().json;
return [{
json: {
success: true,
flights_count: processedData.flights_count,
pdf_base64: base64,
pdf_size_mb: $('Code: Extract Base64 PDF').first().json.pdf_size_mb,
generated_at: processedData.generated_at
}
}];
```
### Вариант D: Загрузка в S3/Nextcloud
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
return [{
binary: {
data: pdfBuffer,
fileName: filename,
mimeType: 'application/pdf'
},
json: {
bucket: 'your-bucket',
key: `reports/${filename}`,
contentType: 'application/pdf'
}
}];
```
Затем используйте ноду **S3 Upload** или **Nextcloud Upload**.
## Альтернативные сервисы конвертации
### 1. htmlpdfapi.com
```javascript
{
"url": "https://api.htmlpdfapi.com/v1/pdf",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
"body": {
"html": "{{ HTML }}",
"base64": true
}
}
```
### 2. pdfshift.io
```javascript
{
"url": "https://api.pdfshift.io/v3/convert/pdf",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Basic " + Buffer.from("api:YOUR_API_KEY").toString("base64")
},
"body": {
"source": "{{ HTML }}",
"format": "A4"
}
}
// Ответ содержит base64 в поле "pdf"
```
### 3. api2pdf.com
```javascript
{
"url": "https://v2.api2pdf.com/chrome/html",
"method": "POST",
"headers": {
"Authorization": "YOUR_API_KEY",
"Content-Type": "application/json"
},
"body": {
"html": "{{ HTML }}",
"inlinePdf": true,
"fileName": "flights-report.pdf"
}
}
// Ответ содержит base64 в поле "pdf"
```
### 4. Self-hosted: Gotenberg
```javascript
{
"url": "http://your-gotenberg-server:3000/forms/chromium/convert/html",
"method": "POST",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"files": [{
"name": "index.html",
"content": "{{ HTML }}"
}]
}
}
// Ответ - binary PDF, конвертируем в base64
```
## Отладка
### Проверка HTML
```javascript
const html = $('Code: Process Flights Data').first().json.html;
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 500));
```
### Проверка base64
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
console.log('Base64 length:', base64.length);
console.log('Base64 preview:', base64.substring(0, 100));
```
### Проверка размера PDF
```javascript
const data = $('Code: Extract Base64 PDF').first().json;
console.log('PDF size:', data.pdf_size_mb, 'MB');
console.log('PDF size bytes:', data.pdf_size_bytes);
```
## Обработка ошибок
Добавьте IF Node после HTTP Request для проверки успешности:
```javascript
// IF Node: Check PDF Conversion Success
{{ $json.success === true }}
```
Если ошибка - отправьте уведомление или сохраните HTML для ручной конвертации.

View File

@@ -0,0 +1,140 @@
# ✅ Рабочее решение: Обработка данных о рейсах → PDF
## Структура Workflow
```
[Входные данные: FlightAware + FlightRadar24]
[Code: причесываем данные] ← Генерирует HTML и конвертирует в base64
[HTTP Request: Browserless PDF] ← Конвертирует HTML в PDF через браузер
[Результат: PDF binary]
```
---
## Нода 1: Code - "причесываем данные"
**Тип:** Code (JavaScript)
**Код:** См. файл `N8N_FLIGHTS_TO_BASE64.js`
**Что делает:**
1. Извлекает данные из структуры `[{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]`
2. Объединяет рейсы по `registration` (номер самолёта)
3. Генерирует красивый HTML с CSS
4. Конвертирует HTML в base64
**Выходные данные:**
```json
{
"html_base64": "PCFET0NUWVBFIGh0bWw+...",
"html": "<!DOCTYPE html>...",
"flights_count": 2,
"sources": {
"flightaware": { "available": true, "count": 2 },
"flightradar24": { "available": true, "count": 2 }
},
"generated_at": "2026-01-16T07:23:00.000Z"
}
```
---
## Нода 2: HTTP Request - "Browserless PDF"
**Тип:** HTTP Request
**Настройки:**
- **Method:** `POST`
- **URL:** `http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9`
- **Send Body:** ✅ Да
- **Specify Body:** `JSON`
- **JSON Body:**
```json
{
"url": "data:text/html;base64, {{ $json.html_base64 }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "20mm"
}
}
}
```
**Response Format:** `Binary` (или `JSON`, если Browserless возвращает JSON)
---
## Результат
HTTP Request нода вернёт PDF в binary формате, который можно:
- Сохранить в файл
- Отправить по email
- Загрузить в S3/Nextcloud
- Конвертировать в base64 для API response
---
## Конвертация Binary PDF → Base64 (опционально)
Если нужен base64 PDF, добавьте Code Node после HTTP Request:
```javascript
const pdfBinary = $binary.data;
const base64 = Buffer.isBuffer(pdfBinary)
? pdfBinary.toString('base64')
: Buffer.from(pdfBinary).toString('base64');
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true
}
}];
```
---
## Преимущества решения
**Простота** - всего 2 ноды
**Надёжность** - Browserless использует реальный браузер
**Качество** - PDF с правильным форматированием и стилями
**Гибкость** - можно легко изменить параметры PDF (формат, отступы)
---
## Отладка
Если что-то не работает:
1. **Проверьте HTML** - в Code Node добавьте:
```javascript
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 200));
```
2. **Проверьте base64** - в Code Node добавьте:
```javascript
console.log('Base64 length:', htmlBase64.length);
```
3. **Проверьте ответ Browserless** - в HTTP Request включите "Always Output Data" и проверьте ответ
---
## Готово! 🎉
Workflow работает и генерирует красивые PDF отчёты о рейсах!

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,102 @@
// ============================================================================
// n8n Code Node: HTML → Base64 PDF (простой вариант)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML
// Этот код подготовит запрос для HTTP Request ноды
// ============================================================================
// Получаем HTML из предыдущей ноды
// Если HTML пришёл в поле "html", используем его
const html = $json.html || $json.body?.html || $json;
if (!html || typeof html !== 'string') {
throw new Error('HTML не найден в входных данных. Проверьте структуру данных.');
}
console.log('📄 HTML получен, длина:', html.length);
// ==== НАСТРОЙКИ СЕРВИСА КОНВЕРТАЦИИ ====
// Выберите один из вариантов ниже и раскомментируйте его
// ==== ВАРИАНТ 1: htmlpdfapi.com (рекомендуется) ====
// Бесплатный план: 100 PDF в месяц
// URL: https://htmlpdfapi.com
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: 'https://api.htmlpdfapi.com/v1/pdf',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' // ⚠️ ЗАМЕНИТЕ на ваш API ключ
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
})
}
}];
// ==== ВАРИАНТ 2: pdfshift.io ====
// Раскомментируйте, если используете pdfshift.io
/*
return [{
json: {
method: 'POST',
url: 'https://api.pdfshift.io/v3/convert/pdf',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + Buffer.from('api:YOUR_API_KEY').toString('base64')
},
body: JSON.stringify({
source: html,
format: 'A4',
margin: '20mm'
})
}
}];
*/
// ==== ВАРИАНТ 3: api2pdf.com ====
// Раскомментируйте, если используете api2pdf.com
/*
return [{
json: {
method: 'POST',
url: 'https://v2.api2pdf.com/chrome/html',
headers: {
'Authorization': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
html: html,
inlinePdf: true,
fileName: 'flights-report.pdf'
})
}
}];
*/
// ============================================================================
// ИНСТРУКЦИЯ:
// ============================================================================
// 1. Этот Code Node подготавливает запрос
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// 4. После HTTP Request добавьте Code Node с кодом из N8N_EXTRACT_BASE64_FROM_RESPONSE.js
// для извлечения base64 из ответа
// ============================================================================

View File

@@ -0,0 +1,62 @@
/**
* n8n Code node: парсинг сырого init_data из Telegram WebApp
*
* Вход: объект с полем init_data (строка query string от Telegram).
* Выход: тот же объект + поля init_data_parsed и user_decoded.
*
* Подключение: после Webhook — в Code передаётся $input.item.json.
* init_data должен быть в $json.init_data (как шлёт наш бэкенд).
*/
const item = $input.first().json;
// Сырая строка init_data (query string)
const rawInitData = item.init_data || item.body?.init_data || '';
if (!rawInitData) {
return [{ json: { ...item, init_data_error: 'init_data отсутствует' } }];
}
/**
* Парсит query string в объект (значения URL-декодированы)
*/
function parseQueryString(qs) {
const result = {};
const pairs = qs.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=').map(s => decodeURIComponent(s || ''));
if (key) result[key] = value;
}
return result;
}
const parsed = parseQueryString(rawInitData);
// user приходит как URL-encoded JSON строка
let userDecoded = null;
if (parsed.user) {
try {
userDecoded = JSON.parse(parsed.user);
} catch (e) {
userDecoded = { _parse_error: String(e), raw: parsed.user };
}
}
return [{
json: {
...item,
init_data_parsed: {
query_id: parsed.query_id || null,
auth_date: parsed.auth_date ? parseInt(parsed.auth_date, 10) : null,
hash: parsed.hash || null,
signature: parsed.signature || null,
user_raw: parsed.user || null,
},
user_decoded: userDecoded,
// удобные поля для маппинга в CRM
telegram_user_id: userDecoded?.id ?? null,
telegram_username: userDecoded?.username ?? null,
telegram_first_name: userDecoded?.first_name ?? null,
telegram_last_name: userDecoded?.last_name ?? null,
},
}];

View File

@@ -0,0 +1,31 @@
# Профиль: ответ N8N_CONTACT_WEBHOOK из SQL
## Цепочка в n8n
1. **Webhook** (POST) — получает от бэкенда `unified_id`, `entry_channel`, `chat_id`, `session_token`, `contact_id`, `phone`.
2. **SQL** — по `unified_id`/`contact_id` выбирает контакт из БД. Возвращает массив строк в формате:
- `contactid`, `firstname`, `lastname`, `email`, `mobile`, `phone`, `birthday`, `mailingstreet`, `middle_name`, `birthplace`, `inn`, `verification`, `bank`
3. **Code** — преобразует строки в JSON для ответа вебхука (см. `N8N_CODE_PROFILE_CONTACT_RESPONSE.js`).
4. **Respond to Webhook** — отдаёт ответ клиенту (тело = вывод Code).
## Формат ответа
- **Ничего не нашли:** вернуть **HTTP 200** и тело `{ "items": [] }`.
- **Нашли контакт(ы):** **HTTP 200** и тело `{ "items": [ { ...поля в snake_case... } ] }`.
Поля контакта (уже в формате мини-апа после Code):
- `last_name`, `first_name`, `middle_name`
- `birth_date`, `birth_place`
- `inn`, `email`, `phone`
- `registration_address` (в SQL: `mailingstreet` — адрес регистрации)
- `mailing_address`, `bank_for_compensation`
## Подстановка Code-ноды
- Скопировать код из `aiform_prod/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js` в ноду **Code**.
- Вход Code — вывод SQL (один item с массивом в `json` или несколько items по одному контакту).
- Выход Code — один item с `{ "items": [ ... ] }`.
- В **Respond to Webhook** указать: ответить телом из предыдущей ноды (всё из Code), чтобы в ответ ушёл именно `{ "items": [...] }`.
Если SQL не нашёл строк — перед Code добавьте условие (IF): при пустом результате отдавать в Respond to Webhook тело `{ "items": [] }` и статус 200.

View File

@@ -0,0 +1,147 @@
# 🔧 Решение проблемы зависших n8n workflow
## 🐛 Проблема
Workflow в n8n зависает и не может быть перезапущен даже через интерфейс. Redis Trigger node теряет соединение и не переподключается автоматически.
## ✅ Что сделано
### 1. Улучшена логика перезапуска workflow
**Файл:** `backend/app/services/n8n_service.py`
**Изменения:**
- ✅ Увеличены таймауты с 10 до 30 секунд (общий) и 15 секунд (для отдельных операций)
- ✅ Добавлена обработка таймаутов при деактивации (продолжаем даже если деактивация зависла)
- ✅ Увеличена задержка между деактивацией и активацией (3 секунды вместо 2)
- ✅ Добавлена дополнительная задержка после активации для инициализации trigger node
- ✅ Улучшено логирование ошибок с полным traceback
### 2. Улучшена проверка и перезапуск в фоне
**Файл:** `backend/app/api/claims.py`
**Изменения:**
- ✅ Добавлены повторные попытки перезапуска (до 2 попыток)
- ✅ Добавлена проверка подписчиков после перезапуска
- ✅ Улучшено логирование процесса перезапуска
## 🚀 Как это работает
1. **При публикации сообщения в Redis:**
- Проверяется количество подписчиков
- Если подписчиков нет → сообщение сохраняется в буфер
- Запускается фоновая задача перезапуска workflow
2. **Процесс перезапуска:**
- Проверяется Redis lock (защита от частых перезапусков)
- Проверяется статус workflow через API
- Деактивируется workflow (даже если завис)
- Ждёт 3 секунды
- Активирует workflow
- Ждёт 2 секунды для инициализации
- Проверяет подписчиков
- Отправляет сообщения из буфера
3. **Повторные попытки:**
- Если первая попытка не удалась → повтор через 5 секунд
- Максимум 2 попытки
## 📊 Мониторинг
### Проверка подписчиков вручную:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
```
### Проверка статуса workflow:
```bash
curl -H "X-N8N-API-KEY: ..." "https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '.active'
```
### Логи backend:
```bash
tail -f /var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend.log | grep -i "workflow\|redis\|subscriber"
```
## 🛠️ Если проблема повторится
### Вариант 1: Перезапуск через API (автоматически)
Код теперь автоматически пытается перезапустить workflow при обнаружении проблемы.
### Вариант 2: Ручной перезапуск через API
```bash
# Деактивировать
curl -X POST -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/deactivate"
# Подождать 5 секунд
sleep 5
# Активировать
curl -X POST -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/activate"
```
### Вариант 3: Перезапуск n8n (крайний случай)
Если workflow всё ещё завис, может потребоваться перезапуск самого n8n:
```bash
# Если n8n в Docker
docker restart <n8n_container>
# Если n8n как системный сервис
systemctl restart n8n
```
## 🔍 Диагностика
### Проверка что workflow активен но не слушает:
```bash
# 1. Проверить статус workflow
curl -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '{active: .active, updatedAt: .updatedAt}'
# 2. Проверить подписчиков
redis-cli -h crm.clientright.ru -p 6379 -a "..." PUBSUB NUMSUB "ticket_form:description"
# 3. Если active=true но подписчиков 0 → workflow завис
```
### Проверка Redis соединений:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "..." CLIENT LIST | grep "sub=1"
```
## 📝 Рекомендации на будущее
1. **Мониторинг:**
- Настроить автоматический мониторинг подписчиков (cron каждые 5 минут)
- Алерты при отсутствии подписчиков более 10 минут
2. **Автоматический перезапуск n8n:**
- Настроить health check для n8n
- Автоматический перезапуск при обнаружении проблем
3. **Логирование:**
- Включить детальное логирование в n8n
- Мониторинг логов на ошибки Redis соединений
4. **Настройка Redis:**
- Увеличить `tcp-keepalive` для стабильности соединений
- Настроить `timeout` для неактивных соединений
## 🔗 Связанные файлы
- `backend/app/services/n8n_service.py` - логика перезапуска workflow
- `backend/app/api/claims.py` - проверка подписчиков и запуск перезапуска
- `docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md` - общая диагностика Redis Trigger
- `docs/N8N_MEMORY_ISSUES.md` - проблемы с памятью в n8n

View File

@@ -0,0 +1,95 @@
# Профиль пользователя и контакт-вебхук (N8N_CONTACT_WEBHOOK)
Описание изменений: раздел «Профиль» в мини-апе, передача `chat_id` в n8n, формат ответа вебхука и Code-нода для формирования JSON из SQL.
---
## 1. Раздел «Профиль» в мини-апе
- **Роут:** `/profile` (фронт), кнопка «Профиль» в нижней панели ведёт на него без перезагрузки.
- **API:** `GET/POST /api/v1/profile/contact` — по `session_token` (или `unified_id`) запрашивает контактные данные из CRM через n8n-вебхук `N8N_CONTACT_WEBHOOK`.
- **Фронт:** страница `Profile.tsx` показывает поля: фамилия, имя, отчество, дата/место рождения, ИНН, email, адрес регистрации, почтовый адрес, банк для возмещения, мобильный телефон. Поддерживаются snake_case и camelCase из ответа.
---
## 2. Конфиг и бэкенд
- **config.py:** добавлена настройка `n8n_contact_webhook` из переменной окружения `N8N_CONTACT_WEBHOOK`.
- **main.py:** подключён роутер `profile`.
- **profile.py:** реализованы `GET/POST /api/v1/profile/contact`, верификация сессии по `session_token`, сборка тела запроса к вебхуку и нормализация ответа n8n в формат `{ "items": [...] }`.
---
## 3. Передача chat_id (Telegram / Max user id)
- **Сессия (session.py):**
- В `SessionCreateRequest` добавлено опциональное поле `chat_id`.
- При создании сессии в Redis сохраняется `chat_id`, если передан.
- В `SessionVerifyResponse` и в ответе `verify_session` возвращается `chat_id`.
- **Где передаётся chat_id при создании сессии:**
- **auth2 (TG):** `chat_id = str(tg_user["telegram_user_id"])`.
- **auth2 (MAX):** `chat_id = str(max_user["max_user_id"])`.
- **telegram_auth:** `chat_id = str(telegram_user_id)`.
- **max_auth:** `chat_id = str(max_user_id)`.
- SMS-флоу: `chat_id` не передаётся.
- **Профиль (profile.py):**
- В запрос к API добавлен параметр `chat_id` (query/body).
- При верификации сессии `chat_id` подставляется из сессии, если не передан явно.
- В теле POST на `N8N_CONTACT_WEBHOOK` всегда добавляется поле `chat_id` (строка), когда оно известно.
- **Фронт (Profile.tsx):**
- При запросе профиля передаётся `chat_id`: из `Telegram.WebApp.initDataUnsafe?.user?.id` или из `WebApp.initDataUnsafe?.user?.id` (MAX).
---
## 4. Формат запроса на N8N_CONTACT_WEBHOOK
**Тело POST от бэкенда к n8n:**
- `unified_id` (str) — идентификатор в CRM
- `entry_channel` (str) — `"telegram"` | `"max"` | `"web"`
- `chat_id` (str, опционально) — Telegram user id или Max user id
- `session_token`, `contact_id`, `phone` (опционально)
---
## 5. Формат ответа из n8n (как возвращать и как маппится)
**Ничего не нашли:** HTTP 200, тело: `[]` или `{ "items": [] }`.
**Нашли контакт(ы):** HTTP 200, тело одно из:
- массив `[{...}, ...]` → нормализуется в `{ "items": [...] }`;
- `{ "items": [...] }` — без изменений;
- `{ "contact": {...} }` / `{ "contact": [...] }` → в `items`;
- `{ "data": [...] }` → в `items`;
- один объект `{...}``{ "items": [{...}] }`;
- пустой объект `{}``{ "items": [] }`.
**Поля контакта** (snake_case или camelCase):
`last_name`, `first_name`, `middle_name`, `birth_date`, `birth_place`, `inn`, `email`, `registration_address`, `mailing_address`, `bank_for_compensation`, `phone`.
Подробности и маппинг полей описаны в докстринге модуля `backend/app/api/profile.py`.
---
## 6. Code-нода n8n для ответа вебхука из SQL
- **Файл:** `docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js`
- **Назначение:** после ноды **select_user1** (SQL) формирует JSON для ответа вебхука.
- **Вход:** данные из ноды `select_user1` (массив строк с полями contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet, middle_name, birthplace, inn, bank и т.д.).
- **Выход:** один item с `{ "items": [ {...}, ... ] }` в формате полей для мини-апа (snake_case). Пустой результат → `{ "items": [] }`.
- **Маппинг:** mailingstreet → registration_address, birthday → birth_date, birthplace → birth_place, bank → bank_for_compensation, mobile/phone → phone и т.д.
Инструкция по цепочке Webhook → SQL → Code → Respond to Webhook: `docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md`.
---
## 7. Прочие изменения (в рамках той же задачи)
- События SSE: единый формат `event_type` + `message`, цвета по типу (trash_message, out_of_scope, consumer_consultation, consumer_complaint), не показывать «Подключено к событиям» как ответ, не перезаписывать consumer_consultation в out_of_scope.
- Кнопка «Домой» — программная навигация на главную.
- Закрытие приложения при `need_contact` от вебхука (повторный вызов close, fallback без initData).
- Передача в контакт-хук: unified_id, entry_channel, session_token, contact_id, phone, chat_id.

View File

@@ -0,0 +1,36 @@
# Поддержка: чат, список тикетов, прочитано/непрочитано, SSE
## Что сделано
### БД (таблицы с префиксом `clpr_`)
- **clpr_support_threads** — треды обращений (unified_id, claim_id, source, ticket_id).
- **clpr_support_messages** — сообщения (thread_id, direction user/support, body, attachments).
- **clpr_support_reads** — когда пользователь последний раз «прочитал» тред (unified_id, thread_id, last_read_at).
- Триггер на INSERT в clpr_support_messages → NOTIFY `support_events` (payload: unified_id, thread_id, сообщение) для доставки в реальном времени.
### Backend API
- **POST /api/v1/support** — отправить сообщение (multipart), создание/поиск треда, прокси в n8n.
- **GET /api/v1/support/threads** — список всех тредов пользователя с unread_count.
- **GET /api/v1/support/thread** — один тред и сообщения (по claim_id или бар).
- **GET /api/v1/support/stream** — SSE: один поток на пользователя, события из Postgres NOTIFY.
- **GET /api/v1/support/unread-count** — суммарное число непрочитанных (бейдж в баре).
- **POST /api/v1/support/read** — отметить тред прочитанным (thread_id или claim_id).
- **POST /api/v1/support/incoming** — webhook для n8n: добавить ответ оператора в тред.
- **GET /api/v1/support/limits** — лимиты вложений.
При старте приложения запускается задача LISTEN на канал `support_events`; при NOTIFY события раскидываются по реестру стримов (unified_id → SSE).
### Frontend
- **Страница «Поддержка»** — первый экран: список обращений (тикетов) с бейджем непрочитанных; кнопка «Новое обращение»; по клику — чат выбранного треда.
- **SupportChat** — чат с SSE (новые сообщения от поддержки без перезагрузки); при открытии чата вызывается POST /read.
- **Нижний бар** — на иконке «Поддержка» бейдж с общим числом непрочитанных.
### Документация
- **docs/SUPPORT_N8N_WEBHOOK.md** — переменные окружения, API, миграции, тест SSE, прочитано/непрочитано и сценарии в n8n.
## Миграции
- 003_support_threads_messages.sql — создание clpr_support_threads, clpr_support_messages.
- 004_support_notify_trigger.sql — триггер NOTIFY support_events.
- 005_support_reads.sql — таблица clpr_support_reads.
Креды Postgres из .env. Применение: из корня aiform_prod подставить POSTGRES_* и выполнить psql -f для каждой миграции.

View File

@@ -0,0 +1,80 @@
# Поддержка: webhook n8n, диалог (треды), лимиты вложений
Функционал «Поддержка» реализован как диалог: треды и сообщения хранятся в БД. **Таблицы с префиксом `clpr_`:** `clpr_support_threads`, `clpr_support_messages`. Исходящие сообщения пользователя проксируются в n8n; входящие ответы оператора приходят в backend через webhook POST /api/v1/support/incoming (из n8n при ответе в CRM).
Подключение к PostgreSQL: креды берутся из `.env``POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`.
## Переменные окружения
В `.env` задаются:
| Переменная | Описание |
|------------|----------|
| `N8N_SUPPORT_WEBHOOK` | URL webhook n8n (multipart). Обязателен. |
| `SUPPORT_ATTACHMENTS_MAX_COUNT` | Макс. количество файлов (0 = без ограничений). |
| `SUPPORT_ATTACHMENTS_MAX_SIZE_MB` | Макс. размер одного файла в МБ (0 = без ограничений). |
| `SUPPORT_ATTACHMENTS_ALLOWED_TYPES` | Допустимые типы (пусто = любые). |
| `SUPPORT_INCOMING_SECRET` | Секрет для POST /api/v1/support/incoming (заголовок `X-Support-Incoming-Secret` или query `secret`). Если задан — только n8n с этим секретом может слать ответы в тред. |
Значение **0** или **пустая строка** для лимитов означает «без ограничений».
## Формат запроса от backend к n8n
Backend отправляет на `N8N_SUPPORT_WEBHOOK` **POST multipart/form-data**:
- **Поля:** `message`, `subject`, `claim_id`, `source`, `unified_id`, `phone`, `email`, `session_id`, `timestamp`, **`thread_id`** (UUID треда), **`ticket_id`** (если тред уже привязан к тикету в CRM).
- **Файлы:** `attachments[0]`, … или `attachments`.
Ответ n8n может содержать **`ticket_id`** — backend сохранит его в `clpr_support_threads` для последующих сообщений и для входящего webhook.
## API backend
- **POST /api/v1/support** — multipart: message, subject?, claim_id?, source, **thread_id?**, session_token (или channel+channel_user_id), файлы. Создаёт/находит тред по (unified_id, claim_id), записывает сообщение (user), проксирует в n8n. Ответ: `{ "success": true, "thread_id": "...", "message_id": "..." }`.
- **GET /api/v1/support/threads** — список всех тредов пользователя. В каждом элементе есть **`unread_count`** (число непрочитанных сообщений от поддержки). Ответ: `{ "threads": [{ "thread_id", "claim_id" | null, "source", "ticket_id", "created_at", "updated_at", "last_body", "last_at", "messages_count", "unread_count" }] }`.
- **GET /api/v1/support/unread-count** — суммарное число непрочитанных по всем тредам (для бейджа в баре). Ответ: `{ "unread_count": number }`.
- **POST /api/v1/support/read** — отметить тред как прочитанный (пользователь открыл чат). Query или body: `thread_id` или `claim_id`. Обновляет `clpr_support_reads`.
- **GET /api/v1/support/thread** — query: `claim_id?`, `session_token` (или `channel` + `channel_user_id`). Возвращает один тред и сообщения: `{ "thread_id": "...", "messages": [...], "ticket_id": "..." }`. Если треда нет — `thread_id: null`, `messages: []`.
- **POST /api/v1/support/incoming** — для n8n: добавить сообщение от поддержки в тред. Тело JSON: `{ "thread_id" или "ticket_id", "body", "attachments?": [] }`. Заголовок **`X-Support-Incoming-Secret`** или query **`secret`** должен совпадать с `SUPPORT_INCOMING_SECRET` (если задан). По `ticket_id` backend находит thread_id и вставляет сообщение с direction=support.
- **GET /api/v1/support/limits** — лимиты вложений из env.
- **GET /api/v1/support/stream** — SSE: один поток на пользователя (query `session_token` или `channel` + `channel_user_id`). Новые сообщения от поддержки приходят в реальном времени через Postgres NOTIFY (триггер на `clpr_support_messages`). События: `connected`, `support_message` (в теле — `thread_id`, `message`: id, direction, body, attachments, created_at).
## Доставка в реальном времени (Postgres NOTIFY)
При INSERT в `clpr_support_messages` срабатывает триггер, который делает `NOTIFY support_events` с payload (unified_id, thread_id, сообщение). Backend при старте подписывается на канал `support_events` одним LISTEN-соединением и раскидывает события по реестру стримов (unified_id → очереди SSE).
**Прочитано/непрочитано:** таблица `clpr_support_reads` (unified_id, thread_id, last_read_at). Пользователь «прочитал» тред, когда открывает чат — фронт вызывает POST /read. Непрочитанные = сообщения от support с created_at > last_read_at. По этим данным можно в n8n/CRM строить сценарии напоминаний (push, повторная отправка), если пользователь долго не читает.
**Миграции** (таблицы с префиксом `clpr_`): `003` — треды и сообщения; `004` — триггер NOTIFY; `005_support_reads.sql` — отметки прочтения. Применять к БД вручную. Креды Postgres — из `.env`:
```bash
# из корня aiform_prod, креды из .env
export $(grep -E '^POSTGRES_' .env | xargs)
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/003_support_threads_messages.sql
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/004_support_notify_trigger.sql
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/005_support_reads.sql
```
Если в БД уже есть таблицы без префикса (`support_threads`, `support_messages`), их нужно переименовать в `clpr_support_threads` и `clpr_support_messages` перед применением 004, либо пересоздать схему (миграция 003 с префиксом создаёт таблицы с `IF NOT EXISTS`).
## n8n
1. **Webhook приёма обращений** — multipart, при первом сообщении создаёт тикет в CRM, в ответе возвращает `ticket_id`. При последующих (есть thread_id/ticket_id) — добавляет комментарий к тикету.
2. **Вызов нашего incoming** — когда оператор ответил в CRM, workflow n8n должен вызвать **POST https://.../api/v1/support/incoming** с заголовком `X-Support-Incoming-Secret: <SUPPORT_INCOMING_SECRET>` и телом `{ "thread_id": "..." или "ticket_id": "...", "body": "текст ответа" }`, чтобы сообщение появилось в чате мини-аппа.
---
## Как тестировать SSE (ответы в реальном времени)
1. **В мини-аппе:** зайти в поддержку (бар → «Поддержка» или страница /support), авторизоваться, отправить первое сообщение (или открыть уже существующий тред). Оставить чат открытым.
2. **Узнать `thread_id`:** в DevTools → Network найти запрос `GET .../api/v1/support/thread` и в ответе скопировать `thread_id`, либо после отправки сообщения — ответ `POST .../api/v1/support` содержит `thread_id`.
3. **Имитация ответа поддержки:** вызвать incoming (как будет делать n8n):
```bash
# Подставить THREAD_ID и секрет из .env (SUPPORT_INCOMING_SECRET). Если секрет пустой — заголовок можно не передавать.
curl -s -X POST 'https://miniapp.clientright.ru/api/v1/support/incoming' \
-H 'Content-Type: application/json' \
-H 'X-Support-Incoming-Secret: ВАШ_SUPPORT_INCOMING_SECRET' \
-d '{"thread_id":"THREAD_ID","body":"Тестовый ответ от поддержки"}'
```
4. **Ожидание:** в открытом чате в мини-аппе в течение 12 секунд должно появиться новое сообщение **без перезагрузки и без повторного запроса** (доставка по SSE). Если сообщение появляется только после обновления страницы — проверить, что фронт пересобран с SSE (`docker compose build frontend && docker compose up -d frontend`) и что в Network есть запрос к `/api/v1/support/stream` со статусом pending (длинное соединение).

View File

@@ -0,0 +1,122 @@
# Как срабатывает Telegram Mini App (по шагам)
Ты в Telegram нажимаешь кнопку «Открыть мини-апп» → открывается **aiform.clientright.ru**. Ниже — что происходит дальше и где.
---
## 1. Где открывается страница
- **Кто:** Telegram (клиент на телефоне/десктопе).
- **Что:** Открывает aiform.clientright.ru **в своём встроенном браузере (WebView)** как Mini App.
- **Важно:** В этом режиме Telegram сам подставляет в страницу свой скрипт и объект `window.Telegram.WebApp` с полем **initData** (подпись пользователя и данные). В обычном браузере по прямой ссылке этого объекта нет.
---
## 2. Загрузка фронта (aiform.clientright.ru)
- Загружается твой SPA (React): главная страница — форма заявки **ClaimForm**.
- Рендерится первый экран формы (шаг 0).
- Сразу при монтировании компонента запускается **useEffect** с функцией `tryTelegramAuth()``ClaimForm.tsx`).
**Где в коде:** `frontend/src/pages/ClaimForm.tsx`, блок «Telegram Mini App: попытка авторизоваться через initData при первом заходе».
---
## 3. Проверка: это Mini App или обычный сайт?
Фронт делает:
1. Смотрит, есть ли `window.Telegram?.WebApp?.initData`.
2. Если нет — ждёт 300 ms (на случай асинхронной подгрузки скрипта Telegram) и проверяет снова.
3. Если после этого **нет** `initData` → в консоль пишется «Telegram WebApp не обнаружен», авторизация по Telegram **не вызывается**, форма ведёт себя как обычный веб-сайт (SMS, сессия из localStorage и т.д.).
4. Если **есть** `initData`:
- Проверяет, есть ли уже в **localStorage** ключ `session_token`.
- Если **есть** → считаем, что пользователь уже залогинен, tg/auth не вызываем, дальше работает обычное восстановление сессии.
- Если **нет** → идём в шаг 4.
**Итого:** срабатывание tg/auth **только** когда:
- страница открыта **из Telegram** (есть `initData`),
- и в localStorage **нет** сохранённого `session_token`.
---
## 4. Запрос на бэкенд: POST /api/v1/tg/auth
- **Кто:** фронт (ClaimForm).
- **Куда:** на тот же домен aiform.clientright.ru → запрос уходит на твой backend (через nginx/proxy на порт 8200).
- **URL:** `POST /api/v1/tg/auth`.
- **Тело:** `{ "init_data": "<строка initData от Telegram>" }`.
**Где в коде:** `ClaimForm.tsx``fetch('/api/v1/tg/auth', { method: 'POST', body: JSON.stringify({ init_data: webApp.initData }) })`.
---
## 5. Обработка на бэкенде (tg/auth)
- **Где:** `backend/app/api/telegram_auth.py`, эндпоинт `POST /api/v1/tg/auth`.
Последовательно:
1. **Валидация initData** (`backend/app/services/telegram_auth.py`):
- Проверка подписи через **TELEGRAM_BOT_TOKEN** из `.env`.
- Если токена нет или подпись не совпадает → ответ **400** (или 500), фронт пишет «Telegram auth failed» и ведёт себя как обычный сайт.
2. **Извлечение пользователя Telegram:** из initData достаются `id`, `username`, `first_name`, `last_name`.
3. **Запрос в n8n:**
- Бэкенд дергает **N8N_TG_AUTH_WEBHOOK** (URL из `.env`).
- Передаёт: `telegram_user_id`, `username`, `first_name`, `last_name`, `session_token`, `form_id`.
- Ожидает в ответе минимум **unified_id** (и при необходимости contact_id, phone, has_drafts).
4. **Создание сессии в Redis:**
- По `session_token` + `unified_id` (+ phone, contact_id) создаётся запись сессии (как после SMS-логина).
5. **Ответ фронту:**
`{ success: true, session_token, unified_id, contact_id?, phone?, has_drafts? }`.
Если на любом шаге ошибка (нет токена, n8n не вернул unified_id и т.д.) — бэкенд отдаёт ошибку, фронт считает tg/auth неуспешным и продолжает как обычный веб.
---
## 6. Что делает фронт после успешного ответа
- Сохраняет **session_token** в **localStorage** и в `sessionIdRef`.
- Обновляет состояние формы: `unified_id`, `phone`, `contact_id`, `session_id`.
- Ставит **isPhoneVerified = true** (шаг «телефон» считаем пройденным).
- Если в ответе **has_drafts === true** → показывает экран выбора черновиков.
- Если **has_drafts** нет или false → переводит на **шаг 1** (описание проблемы).
Дальше пользователь идёт по форме как обычно: описание → черновик/визард → подтверждение → оплата и т.д., но уже без ввода телефона и SMS, потому что он «залогинен» через Telegram.
---
## Сводка: где что срабатывает
| Шаг | Где | Что происходит |
|-----|-----|----------------|
| 1 | Telegram | Открывает aiform.clientright.ru в WebView, подставляет WebApp и initData |
| 2 | Браузер (WebView) | Загружается SPA, монтируется ClaimForm |
| 3 | ClaimForm.tsx (фронт) | Проверка: есть ли Telegram.WebApp.initData и нет ли session_token в localStorage |
| 4 | ClaimForm.tsx (фронт) | POST /api/v1/tg/auth с init_data |
| 5 | telegram_auth.py (бэкенд) | Валидация initData, запрос в n8n, создание сессии в Redis |
| 6 | ClaimForm.tsx (фронт) | Сохранение session_token, переход на шаг черновиков или описание |
---
## Если открыть aiform.clientright.ru не из Telegram
- В обычном браузере (Chrome, Safari по прямой ссылке) **нет** `window.Telegram.WebApp`.
- Фронт пишет в консоль «Telegram WebApp не обнаружен» и **не вызывает** /api/v1/tg/auth.
- Работает обычный сценарий: ввод телефона → SMS → сессия и т.д.
---
## Что должно быть настроено
1. **В Telegram:** у бота должна быть кнопка/меню, открывающее Mini App с URL **https://aiform.clientright.ru** (или с путём на эту форму).
2. **Backend .env:**
- **TELEGRAM_BOT_TOKEN** — токен этого же бота (для проверки initData).
- **N8N_TG_AUTH_WEBHOOK** — URL webhook в n8n, который по telegram_user_id возвращает unified_id (и при необходимости contact_id, phone, has_drafts).
3. **n8n:** workflow по этому webhook принимает JSON с telegram_user_id и т.д. и отдаёт JSON с полем **unified_id** (обязательно).
Если что-то из этого не настроено, цепочка обрывается на шаге 5 (бэкенд/n8n), и пользователь остаётся в «обычном» режиме формы без авторизации через Telegram.

View File

@@ -0,0 +1,71 @@
/**
* n8n Code node: нормализация ответа CRM (projects_json + tickets_json)
* в массив элементов с метками для фронта (type_code, payload.source, status_code по projectstatus).
*
* Вход: один элемент с полями projects_json[], tickets_json[] (и опционально contactid, unified_id, mobile).
* Выход: один элемент { crm_items: [...] } — массив готовых объектов для склейки с черновиками из Postgres.
*/
const input = $input.first().json;
const projects = input.projects_json || [];
const tickets = input.tickets_json || [];
const normalized = [];
// Проекты из CRM → один элемент на проект (карточка «В работе» / «Решены» / «Отклонены»)
for (const p of projects) {
const projectstatus = (p.projectstatus || '').toString().toLowerCase();
let status_code = 'active';
if (projectstatus.includes('завершено') || projectstatus === 'completed') status_code = 'completed';
else if (projectstatus.includes('отклонен')) status_code = 'rejected';
normalized.push({
id: `crm_project_${p.projectid}`,
claim_id: null,
type_code: 'external_case',
payload: {
source: 'CRM',
projectid: p.projectid,
projectstatus: p.projectstatus,
},
status_code,
channel: 'crm',
problem_title: p.projectname || '',
problem_description: '',
created_at: p.createdtime || null,
updated_at: p.createdtime || null,
documents_total: 0,
documents_uploaded: 0,
unified_id: input.unified_id || null,
contact_id: input.contactid != null ? String(input.contactid) : null,
phone: input.mobile || input.phone || null,
});
}
// Тикеты из CRM → один элемент на тикет (карточка «Консультации»)
for (const t of tickets) {
normalized.push({
id: `crm_ticket_${t.ticketid}`,
claim_id: null,
type_code: 'consultation',
payload: {
source: 'CRM',
ticketid: t.ticketid,
ticket_no: t.ticket_no,
},
status_code: 'active',
channel: 'crm',
problem_title: t.title || t.ticket_no || '',
problem_description: '',
created_at: t.createdtime || null,
updated_at: t.createdtime || null,
documents_total: 0,
documents_uploaded: 0,
unified_id: input.unified_id || null,
contact_id: input.contactid != null ? String(input.contactid) : null,
phone: input.mobile || input.phone || null,
});
}
// Отдаём один элемент с массивом crm_items (далее в workflow склеиваешь с data из Postgres)
return [{ json: { crm_items: normalized } }];

View File

@@ -0,0 +1,29 @@
/**
* n8n Code node: развернуть data в плоский список.
* Если в data попал объект вида { "crm_items": [...] }, он заменяется на сами элементы crm_items.
*
* Вход: один элемент с полем data (массив), где часть элементов могут быть { crm_items: [...] }.
* Выход: один элемент { data: [...] } — плоский массив только карточек (заявки Postgres + элементы CRM).
*/
const input = $input.first().json;
let data = input.data;
if (data == null) data = input.items || input.drafts || [];
if (!Array.isArray(data)) data = [data];
const flattened = [];
for (const item of data) {
if (
item &&
typeof item === 'object' &&
item.crm_items &&
Array.isArray(item.crm_items) &&
Object.keys(item).length === 1
) {
flattened.push(...item.crm_items);
} else {
flattened.push(item);
}
}
return [{ json: { ...input, data: flattened } }];

View File

@@ -1,36 +1,16 @@
# React Frontend Dockerfile (PRODUCTION BUILD)
# Продакшен: сборка + отдача dist (без dev-сервера).
# После правок в коде: docker compose build frontend && docker compose up -d frontend
FROM node:18-alpine AS builder
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем package.json
COPY package*.json ./
# Устанавливаем зависимости
COPY package.json package-lock.json* ./
RUN npm ci
# Копируем исходный код
COPY . .
RUN node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build
# Собираем production build
RUN npm run build
# Production stage
FROM node:18-alpine
# Устанавливаем serve глобально
RUN npm install -g serve
# Копируем собранное приложение из builder stage
COPY --from=builder /app/dist /app/dist
# Устанавливаем рабочую директорию
WORKDIR /app
# Открываем порт
RUN npm install -g serve
COPY --from=builder /app/dist ./dist
EXPOSE 3000
# Запускаем serve для раздачи статических файлов
CMD ["serve", "-s", "dist", "-l", "3000"]

View File

@@ -3,8 +3,19 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Clientright — защита прав потребителей</title>
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
<script>
(function() {
var u = window.location.href || '';
if (u.indexOf('tgWebAppData') !== -1 || u.indexOf('tgWebAppVersion') !== -1) {
var s = document.createElement('script'); s.src = 'https://telegram.org/js/telegram-web-app.js'; document.head.appendChild(s);
} else {
var s = document.createElement('script'); s.src = 'https://st.max.ru/js/max-web-app.js'; document.head.appendChild(s);
}
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -16,6 +16,7 @@
"dayjs": "^1.11.13",
"imask": "^7.6.1",
"jspdf": "^2.5.2",
"lucide-react": "^0.575.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
@@ -3562,6 +3563,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.575.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7725,6 +7734,12 @@
"yallist": "^3.0.2"
}
},
"lucide-react": {
"version": "0.575.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
"requires": {}
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -6,40 +6,40 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit",
"start": "serve -s dist -l 3000"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"antd": "^5.21.6",
"@ant-design/icons": "^5.5.1",
"axios": "^1.7.7",
"@tanstack/react-query": "^5.59.16",
"zustand": "^5.0.1",
"antd": "^5.21.6",
"axios": "^1.7.7",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.13",
"imask": "^7.6.1",
"react-dropzone": "^14.3.5",
"socket.io-client": "^4.8.1",
"serve": "^14.2.1",
"jspdf": "^2.5.2",
"browser-image-compression": "^2.0.2"
"lucide-react": "^0.575.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-router-dom": "^6.26.2",
"serve": "^14.2.1",
"socket.io-client": "^4.8.1",
"zustand": "^5.0.1"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"eslint": "^9.13.0",
"@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.13"
"eslint-plugin-react-refresh": "^0.4.13",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -5,6 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clientright — защита прав потребителей</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,18 @@
/**
* Полифилл crypto.getRandomValues для Node 16 (нужен Vite при сборке).
* Запуск: node -r ./scripts/crypto-polyfill.cjs node_modules/vite/bin/vite.js build
*/
const crypto = require('node:crypto');
function getRandomValues(buffer) {
if (!buffer) return buffer;
const bytes = crypto.randomBytes(buffer.length);
buffer.set(bytes);
return buffer;
}
if (typeof crypto.getRandomValues !== 'function') {
crypto.getRandomValues = getRandomValues;
}
if (typeof globalThis !== 'undefined') {
globalThis.crypto = globalThis.crypto || {};
globalThis.crypto.getRandomValues = getRandomValues;
}

View File

@@ -2,6 +2,8 @@
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
max-width: 100%;
}
.app-header {
@@ -27,8 +29,10 @@
flex: 1;
max-width: 1200px;
width: 100%;
min-width: 0;
margin: 0 auto;
padding: 2rem;
overflow-x: hidden;
}
.card {

View File

@@ -1,12 +1,111 @@
import ClaimForm from './pages/ClaimForm'
import './App.css'
import { useState, useEffect, useCallback, useRef } from 'react';
import ClaimForm from './pages/ClaimForm';
import HelloAuth from './pages/HelloAuth';
import Profile from './pages/Profile';
import Support from './pages/Support';
import Consultations from './pages/Consultations';
import BottomBar from './components/BottomBar';
import { DraftsProvider } from './context/DraftsContext';
import './App.css';
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
function App() {
const [pathname, setPathname] = useState<string>(() => {
const p = window.location.pathname || '';
if (p !== '/hello' && !p.startsWith('/hello')) return '/hello';
return p;
});
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
const [profileNeedsAttention, setProfileNeedsAttention] = useState<boolean>(false);
const lastRouteTsRef = useRef<number>(Date.now());
const lastPathRef = useRef<string>(pathname);
useEffect(() => {
const path = window.location.pathname || '/';
if (path !== '/hello' && !path.startsWith('/hello')) {
window.history.replaceState({}, '', '/hello' + (window.location.search || '') + (window.location.hash || ''));
}
}, []);
useEffect(() => {
const onPopState = () => setPathname(window.location.pathname || '');
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
// Логируем смену маршрута + ловим быстрый возврат на /hello (симптом бага)
useEffect(() => {
const now = Date.now();
const prev = lastPathRef.current;
lastPathRef.current = pathname;
lastRouteTsRef.current = now;
miniappLog('route', { prev, next: pathname });
if (pathname.startsWith('/hello') && !prev.startsWith('/hello')) {
// Вернулись на /hello: отправим дамп, чтобы поймать “ложится”
void miniappSendLogs('returned_to_hello');
}
}, [pathname]);
// Ловим клики в первые 2с после смены маршрута (ghost click / попадание в бар)
useEffect(() => {
const onClickCapture = (e: MouseEvent) => {
const dt = Date.now() - lastRouteTsRef.current;
if (dt > 2000) return;
const t = e.target as HTMLElement | null;
const inBar = !!t?.closest?.('.app-bottom-bar');
miniappLog('click_capture', {
dtFromRouteMs: dt,
inBottomBar: inBar,
tag: t?.tagName,
id: t?.id,
class: t?.className,
x: (e as MouseEvent).clientX,
y: (e as MouseEvent).clientY,
});
};
window.addEventListener('click', onClickCapture, true);
return () => window.removeEventListener('click', onClickCapture, true);
}, []);
useEffect(() => {
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
}, [pathname]);
const isNewClaimPage = pathname === '/new';
const navigateTo = useCallback((path: string) => {
window.history.pushState({}, '', path);
setPathname(path);
}, []);
return (
<DraftsProvider>
<div className="App">
<ClaimForm />
{pathname === '/profile' ? (
<Profile onNavigate={navigateTo} />
) : pathname === '/support' ? (
<Support onNavigate={navigateTo} />
) : pathname === '/consultations' ? (
<Consultations onNavigate={navigateTo} />
) : pathname.startsWith('/hello') ? (
<HelloAuth
onAvatarChange={setAvatarUrl}
onNavigate={navigateTo}
onProfileNeedsAttentionChange={setProfileNeedsAttention}
/>
) : (
<ClaimForm forceNewClaim={isNewClaimPage} onNavigate={navigateTo} />
)}
<BottomBar
currentPath={pathname}
avatarUrl={avatarUrl || undefined}
profileNeedsAttention={profileNeedsAttention}
onNavigate={navigateTo}
/>
</div>
)
</DraftsProvider>
);
}
export default App
export default App;

View File

@@ -0,0 +1,122 @@
.app-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-height: 64px;
height: calc(64px + env(safe-area-inset-bottom, 0));
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
background: #ffffff;
border-top: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
display: flex;
align-items: center;
justify-content: space-around;
z-index: 100;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.app-bottom-bar--hidden {
transform: translateY(120%);
opacity: 0;
pointer-events: none;
}
.app-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
color: #6b7280;
text-decoration: none;
font-size: 12px;
font-weight: 500;
transition: color 0.2s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.app-bar-item:hover {
color: #111827;
}
.app-bar-item:disabled {
cursor: default;
opacity: 0.45;
}
.app-bar-item:disabled:hover {
color: #6b7280;
}
.app-bar-item--active {
color: #2563EB;
font-weight: 600;
}
.app-bar-item--active:hover {
color: #2563EB;
}
.app-bar-item--exit:hover {
color: #dc2626;
}
.app-bar-item-icon-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.app-bar-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.app-bar-profile-badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 11px;
font-weight: 700;
line-height: 16px;
color: #fff;
text-align: center;
background: #dc2626;
border: 1.5px solid #fff;
border-radius: 50%;
box-sizing: border-box;
}
.app-bar-support-badge {
position: absolute;
bottom: -2px;
left: 50%;
transform: translate(-50%, 50%);
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 700;
line-height: 18px;
color: #fff;
text-align: center;
background: #dc2626;
border: 1.5px solid #fff;
border-radius: 9px;
box-sizing: border-box;
}

View File

@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import { Home, Headphones, User, LogOut, ArrowLeft } from 'lucide-react';
import './BottomBar.css';
import { miniappLog } from '../utils/miniappLogger';
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
interface BottomBarProps {
currentPath: string;
avatarUrl?: string;
profileNeedsAttention?: boolean;
onNavigate?: (path: string) => void;
}
export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttention, onNavigate }: BottomBarProps) {
const isHome = currentPath.startsWith('/hello');
const isProfile = currentPath === '/profile';
const isSupport = currentPath === '/support';
const [backEnabled, setBackEnabled] = useState(false);
const [supportUnreadCount, setSupportUnreadCount] = useState(0);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [supportChatMode, setSupportChatMode] = useState(false);
// Непрочитанные в поддержке — для бейджа на иконке
useEffect(() => {
const token = getSessionToken();
if (!token) {
setSupportUnreadCount(0);
return;
}
const params = new URLSearchParams();
params.set('session_token', token);
fetch(`/api/v1/support/unread-count?${params.toString()}`)
.then((res) => (res.ok ? res.json() : { unread_count: 0 }))
.then((data) => setSupportUnreadCount(data.unread_count ?? 0))
.catch(() => setSupportUnreadCount(0));
}, [currentPath]);
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться.
// На /support кнопка «Назад» включена — возврат из чата в список или из списка в «Мои обращения».
useEffect(() => {
if (isHome || isProfile) {
setBackEnabled(false);
return;
}
setBackEnabled(false);
const t = window.setTimeout(() => setBackEnabled(true), 1200);
return () => window.clearTimeout(t);
}, [isHome, isProfile, currentPath]);
// Если открыта клавиатура — прячем нижний бар, чтобы он не перекрывал поле ввода
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
setKeyboardOpen(inset > 80);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
// Универсально для любых WebView: если в фокусе поле ввода, нижний бар скрываем.
useEffect(() => {
const isEditable = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea' || el.isContentEditable;
};
const handleFocusIn = (e: FocusEvent) => {
if (isEditable(e.target)) setInputFocused(true);
};
const handleFocusOut = () => {
window.setTimeout(() => {
const active = document.activeElement;
setInputFocused(isEditable(active));
}, 30);
};
window.addEventListener('focusin', handleFocusIn);
window.addEventListener('focusout', handleFocusOut);
return () => {
window.removeEventListener('focusin', handleFocusIn);
window.removeEventListener('focusout', handleFocusOut);
};
}, []);
useEffect(() => {
const onSupportChatMode = (e: Event) => {
const detail = (e as CustomEvent<{ active?: boolean }>).detail;
setSupportChatMode(!!detail?.active);
};
window.addEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
return () => {
window.removeEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
};
}, []);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
miniappLog('bottom_bar_back_click', { backEnabled, currentPath });
if (!backEnabled) return;
window.dispatchEvent(new CustomEvent('miniapp:goBack'));
};
const handleExit = (e: React.MouseEvent) => {
e.preventDefault();
const tgWebApp = (window as any).Telegram?.WebApp;
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
const hasTgContext =
tgInitData.length > 0 ||
window.location.href.includes('tgWebAppData') ||
navigator.userAgent.includes('Telegram');
const maxWebApp = (window as any).WebApp;
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
const hasMaxContext =
maxInitData.length > 0 ||
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
// Если пользователь не поделился контактом, initData может быть пустым — всё равно пробуем close по наличию WebApp
const hasTgWebApp = !!tgWebApp && typeof tgWebApp.close === 'function';
const hasMaxWebApp = !!maxWebApp && (typeof maxWebApp.close === 'function' || typeof maxWebApp.postEvent === 'function');
miniappLog('bottom_bar_exit_click', {
currentPath,
hasTgContext,
hasMaxContext,
tgInitDataLen: tgInitData.length,
maxInitDataLen: maxInitData.length,
hasTgClose: hasTgWebApp,
hasMaxClose: hasMaxWebApp,
});
// ВАЖНО: выбираем платформу по контексту (URL/UA/initData). Если оба есть — приоритет у того, у кого есть initData.
if (hasTgContext && hasTgWebApp && !hasMaxContext) {
try {
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
tgWebApp.close();
return;
} catch (err) {
miniappLog('bottom_bar_exit_error', { platform: 'tg', error: String(err) });
}
}
if (hasMaxContext && hasMaxWebApp) {
try {
if (typeof maxWebApp.close === 'function') {
miniappLog('bottom_bar_exit_close', { platform: 'max' });
maxWebApp.close();
return;
}
if (typeof maxWebApp.postEvent === 'function') {
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
maxWebApp.postEvent('web_app_close');
return;
}
} catch (err) {
miniappLog('bottom_bar_exit_error', { platform: 'max', error: String(err) });
}
}
// Когда контакт не дан, initData может быть пустым — пробуем закрыть по наличию объекта WebApp (без требования initData)
if (hasTgWebApp && !hasMaxWebApp) {
try {
miniappLog('bottom_bar_exit_close', { platform: 'tg_no_init', note: 'close without initData' });
tgWebApp.close();
return;
} catch (_) {}
}
if (hasMaxWebApp && !hasTgWebApp) {
try {
if (typeof maxWebApp.close === 'function') {
miniappLog('bottom_bar_exit_close', { platform: 'max_no_init', note: 'close without initData' });
maxWebApp.close();
return;
}
if (typeof maxWebApp.postEvent === 'function') {
maxWebApp.postEvent('web_app_close');
return;
}
} catch (_) {}
}
// Fallback: переход на главную
miniappLog('bottom_bar_exit_fallback', {});
window.location.href = '/hello';
};
return (
<nav
className={`app-bottom-bar${keyboardOpen || inputFocused || supportChatMode ? ' app-bottom-bar--hidden' : ''}`}
aria-label="Навигация"
>
{!isHome && !isProfile && (
<button
type="button"
className="app-bar-item"
onClick={handleBack}
disabled={!backEnabled}
aria-label="Назад"
>
<ArrowLeft size={24} strokeWidth={1.8} />
<span>Назад</span>
</button>
)}
<a
href="/hello"
className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}
onClick={(e) => {
if (onNavigate && !isHome) {
e.preventDefault();
onNavigate('/hello');
}
}}
>
<Home size={24} strokeWidth={1.8} />
<span>Домой</span>
</a>
<a
href="/profile"
className={`app-bar-item ${isProfile ? 'app-bar-item--active' : ''}`}
onClick={(e) => {
if (onNavigate && !isProfile) {
e.preventDefault();
onNavigate('/profile');
}
}}
aria-label={profileNeedsAttention ? 'Профиль — требуется подтверждение данных' : 'Профиль'}
>
<span className="app-bar-item-icon-wrap">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="app-bar-avatar" />
) : (
<User size={24} strokeWidth={1.8} />
)}
{profileNeedsAttention && <span className="app-bar-profile-badge" aria-hidden>!</span>}
</span>
<span>Профиль</span>
</a>
<a
href="/support"
className={`app-bar-item ${isSupport ? 'app-bar-item--active' : ''}`}
onClick={(e) => {
if (onNavigate && currentPath !== '/support') {
e.preventDefault();
onNavigate('/support');
}
}}
aria-label={supportUnreadCount > 0 ? `Поддержка: ${supportUnreadCount} непрочитанных` : 'Поддержка'}
>
<span className="app-bar-item-icon-wrap">
<Headphones size={24} strokeWidth={1.8} />
{supportUnreadCount > 0 && (
<span className="app-bar-support-badge" aria-hidden>
{supportUnreadCount > 99 ? '99+' : supportUnreadCount}
</span>
)}
</span>
<span>Поддержка</span>
</a>
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">
<LogOut size={24} strokeWidth={1.8} />
<span>Выход</span>
</button>
</nav>
);
}

View File

@@ -0,0 +1,479 @@
/**
* SupportChat — диалог поддержки: список сообщений + ввод.
* Новые ответы приходят по SSE (Postgres NOTIFY), один канал на пользователя.
* Если треда ещё нет — показывается форма первого сообщения; после отправки — чат.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Form, Input, message, Spin, Typography } from 'antd';
import { Paperclip, X } from 'lucide-react';
const { TextArea } = Input;
export interface SupportMessage {
id: string;
direction: 'user' | 'support';
body: string;
attachments: Array<{ filename?: string; url?: string }>;
created_at: string;
}
export interface SupportThreadResponse {
thread_id: string | null;
messages: SupportMessage[];
ticket_id: string | null;
}
export interface SupportChatProps {
claimId?: string;
source?: 'bar' | 'complaint_card';
compact?: boolean;
onSuccess?: () => void;
hideClaimLabel?: boolean;
}
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
function buildThreadUrl(claimId?: string): string {
const token = getSessionToken();
const params = new URLSearchParams();
if (token) params.set('session_token', token);
if (claimId) params.set('claim_id', claimId);
return `/api/v1/support/thread?${params.toString()}`;
}
function buildStreamUrl(): string {
const token = getSessionToken();
if (!token) return '';
const params = new URLSearchParams();
params.set('session_token', token);
return `/api/v1/support/stream?${params.toString()}`;
}
export default function SupportChat({
claimId,
source = 'bar',
compact = false,
onSuccess,
hideClaimLabel = false,
}: SupportChatProps) {
const [threadId, setThreadId] = useState<string | null>(null);
const [messages, setMessages] = useState<SupportMessage[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const [files, setFiles] = useState<File[]>([]);
const [fileInputKey, setFileInputKey] = useState(0);
const [keyboardInset, setKeyboardInset] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputBarRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const threadIdRef = useRef<string | null>(null);
threadIdRef.current = threadId;
// При фокусе: в TG/MAX запрашиваем expand(); затем прокручиваем поле ввода в видимую зону (над клавиатурой)
const scrollInputIntoView = useCallback(() => {
const win = typeof window !== 'undefined' ? window : null;
const tg = (win as unknown as { Telegram?: { WebApp?: { expand?: () => void } } })?.Telegram?.WebApp;
const max = (win as unknown as { WebApp?: { expand?: () => void } })?.WebApp;
if (tg?.expand) tg.expand();
if (max?.expand) max.expand();
const scroll = () => inputBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
const t1 = window.setTimeout(scroll, 350);
const t2 = window.setTimeout(scroll, 700);
return () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
};
}, []);
const markRead = useCallback((tid: string) => {
const token = getSessionToken();
if (!token) return;
const params = new URLSearchParams();
params.set('session_token', token);
params.set('thread_id', tid);
fetch(`/api/v1/support/read?${params.toString()}`, { method: 'POST' }).catch(() => {});
}, []);
const fetchThread = useCallback(async () => {
const token = getSessionToken();
if (!token) return;
try {
const res = await fetch(buildThreadUrl(claimId));
if (!res.ok) return;
const data: SupportThreadResponse = await res.json();
setThreadId(data.thread_id || null);
setMessages(data.messages || []);
if (data.thread_id) markRead(data.thread_id);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [claimId, markRead]);
useEffect(() => {
fetchThread();
}, [fetchThread]);
// SSE: один поток на пользователя, новые сообщения от поддержки приходят по Postgres NOTIFY
useEffect(() => {
const url = buildStreamUrl();
if (!url) return;
const es = new EventSource(url);
eventSourceRef.current = es;
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (data.event !== 'support_message' || !data.message || !data.thread_id) return;
if (data.thread_id !== threadIdRef.current) return;
const msg = data.message as SupportMessage;
const created_at =
typeof msg.created_at === 'string'
? msg.created_at
: (msg.created_at as unknown as { isoformat?: () => string })?.isoformat?.() ?? new Date().toISOString();
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, { ...msg, created_at, attachments: msg.attachments || [] }];
});
} catch {
// ignore
}
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
};
return () => {
es.close();
eventSourceRef.current = null;
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
setKeyboardInset(inset);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
const handleSend = async () => {
const values = await form.getFieldsValue();
const text = (values.message || '').trim();
if (!text) return;
const token = getSessionToken();
if (!token) return;
const fd = new FormData();
fd.append('message', text);
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
if (threadId) fd.append('thread_id', threadId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const detail = err.detail || res.statusText;
if (res.status === 503) {
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
} else {
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить сообщение.');
}
return;
}
const data = await res.json();
if (data.thread_id) setThreadId(data.thread_id);
await fetchThread();
form.setFieldValue('message', '');
setFiles([]);
setFileInputKey((k) => k + 1);
} catch (e) {
console.error(e);
message.error('Ошибка соединения. Попробуйте ещё раз.');
} finally {
setSubmitting(false);
}
};
const handleFirstMessage = async () => {
const values = await form.validateFields().catch(() => null);
if (!values?.message?.trim()) return;
const token = getSessionToken();
if (!token) return;
const fd = new FormData();
fd.append('message', values.message.trim());
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const detail = err.detail || res.statusText;
if (res.status === 503) {
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
} else {
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить обращение.');
}
return;
}
const data = await res.json();
if (data.thread_id) setThreadId(data.thread_id);
await fetchThread();
form.resetFields();
setFiles([]);
setFileInputKey((k) => k + 1);
onSuccess?.();
} catch (e) {
console.error(e);
message.error('Ошибка соединения. Попробуйте ещё раз.');
} finally {
setSubmitting(false);
}
};
const addFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
setFiles((prev) => [...prev, ...selected]);
setFileInputKey((k) => k + 1);
e.target.value = '';
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin />
</div>
);
}
const showChat = threadId && messages.length > 0;
if (!showChat) {
return (
<div
className={compact ? 'support-chat support-chat--compact' : 'support-chat'}
style={{ paddingBottom: keyboardInset ? keyboardInset + 8 : 8 }}
>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<Form form={form} layout="vertical" onFinish={handleFirstMessage}>
<Form.Item
name="message"
label="Сообщение"
rules={[{ required: true, message: 'Введите текст' }]}
>
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос..." maxLength={5000} showCount onFocus={scrollInputIntoView} />
</Form.Item>
<Form.Item name="subject" label="Тема (необязательно)">
<Input placeholder="Краткая тема" maxLength={200} />
</Form.Item>
<Form.Item label="Прикрепить файлы">
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-chat-files"
onChange={addFile}
/>
<Button
type="button"
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
onClick={() => document.getElementById('support-chat-files')?.click()}
>
Прикрепить
</Button>
{files.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{files.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
<button
type="button"
aria-label="Удалить"
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
Отправить
</Button>
</Form.Item>
</Form>
</div>
);
}
return (
<div className={compact ? 'support-chat support-chat--compact' : 'support-chat'} style={{ display: 'flex', flexDirection: 'column', minHeight: compact ? 320 : 400 }}>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 0',
display: 'flex',
flexDirection: 'column',
gap: 12,
paddingBottom: keyboardInset ? keyboardInset + 8 : 8,
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
alignSelf: msg.direction === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
padding: '10px 14px',
borderRadius: 12,
background: msg.direction === 'user' ? '#e3f2fd' : '#f5f5f5',
border: `1px solid ${msg.direction === 'user' ? '#90caf9' : '#e0e0e0'}`,
}}
>
<Typography.Text style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{msg.body}
</Typography.Text>
{msg.attachments?.length > 0 && (
<div style={{ marginTop: 6, fontSize: 12, color: '#666' }}>
{msg.attachments.map((a, i) => (
<div key={i}>{a.filename || a.url || 'Файл'}</div>
))}
</div>
)}
<div style={{ marginTop: 4, fontSize: 11, color: '#999' }}>
{new Date(msg.created_at).toLocaleString('ru-RU')}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<Form form={form} onFinish={handleSend} style={{ flexShrink: 0 }}>
<div
ref={inputBarRef}
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
paddingTop: 8,
borderTop: '1px solid #f0f0f0',
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
paddingBottom: keyboardInset ? keyboardInset : 0,
background: '#fff',
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<Form.Item name="message" style={{ flex: 1, marginBottom: 0 }}>
<TextArea
placeholder="Сообщение..."
autoSize={{ minRows: 2, maxRows: 6 }}
maxLength={5000}
onFocus={scrollInputIntoView}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
form.submit();
}
}}
/>
</Form.Item>
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-chat-files-chat"
onChange={addFile}
/>
<Button
type="button"
icon={<Paperclip size={18} />}
size="large"
onClick={() => document.getElementById('support-chat-files-chat')?.click()}
/>
<Button type="primary" htmlType="submit" loading={submitting} size="large">
Отправить
</Button>
</div>
{files.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{files.map((f, i) => (
<span
key={i}
style={{
fontSize: 12,
padding: '2px 8px',
background: '#f0f0f0',
borderRadius: 4,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
{f.name}
<button
type="button"
aria-label="Удалить"
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,214 @@
/**
* SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы).
* Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits.
*/
import { useEffect, useState } from 'react';
import { Button, Form, Input, message as antMessage } from 'antd';
import { Paperclip, X } from 'lucide-react';
const { TextArea } = Input;
export interface SupportLimits {
max_count: number;
max_size_per_file: number;
allowed_types: string;
unlimited: boolean;
}
export interface SupportFormProps {
/** Привязка к обращению (из карточки жалобы) */
claimId?: string;
/** bar | complaint_card */
source?: 'bar' | 'complaint_card';
/** После успешной отправки */
onSuccess?: () => void;
/** Компактный вид (модалка) */
compact?: boolean;
/** Скрыть заголовок «По обращению №…» когда передан claimId */
hideClaimLabel?: boolean;
}
function getSessionToken(): string | null {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s) return s;
}
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('session_token');
}
return null;
}
export default function SupportForm({
claimId,
source = 'bar',
onSuccess,
compact = false,
hideClaimLabel = false,
}: SupportFormProps) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [limits, setLimits] = useState<SupportLimits | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [fileInputKey, setFileInputKey] = useState(0);
useEffect(() => {
fetch('/api/v1/support/limits')
.then((res) => (res.ok ? res.json() : null))
.then((data: SupportLimits | null) => {
if (data) setLimits(data);
})
.catch(() => {});
}, []);
const canAddFile = (): boolean => {
if (!limits || limits.unlimited) return true;
return files.length < limits.max_count;
};
const isFileSizeOk = (file: File): boolean => {
if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true;
return file.size <= limits.max_size_per_file;
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (!limits?.unlimited && limits && limits.max_count > 0) {
const remaining = limits.max_count - files.length;
if (selected.length > remaining) {
antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`);
setFileInputKey((k) => k + 1);
return;
}
}
const ok: File[] = [];
for (const f of selected) {
if (!isFileSizeOk(f)) {
antMessage.warning(`Файл «${f.name}» превышает допустимый размер`);
continue;
}
ok.push(f);
}
setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999)));
setFileInputKey((k) => k + 1);
e.target.value = '';
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
const values = await form.validateFields().catch(() => null);
if (!values || !values.message?.trim()) return;
const token = getSessionToken();
if (!token) {
antMessage.error('Сессия не найдена. Войдите снова.');
return;
}
const fd = new FormData();
fd.append('message', values.message.trim());
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
fd.append('source', source);
fd.append('session_token', token);
if (claimId) fd.append('claim_id', claimId);
files.forEach((file, i) => {
fd.append(`attachments[${i}]`, file, file.name);
});
setSubmitting(true);
try {
const res = await fetch('/api/v1/support', {
method: 'POST',
body: fd,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText || 'Ошибка отправки');
}
antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.');
form.resetFields();
setFiles([]);
setFileInputKey((k) => k + 1);
onSuccess?.();
} catch (err) {
antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.');
} finally {
setSubmitting(false);
}
};
const limitHint =
limits && !limits.unlimited
? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}`
: null;
return (
<div className={compact ? 'support-form support-form--compact' : 'support-form'}>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="message"
label="Сообщение"
rules={[{ required: true, message: 'Введите текст обращения' }]}
>
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос или проблему..." maxLength={5000} showCount />
</Form.Item>
<Form.Item name="subject" label="Тема (необязательно)">
<Input placeholder="Краткая тема" maxLength={200} />
</Form.Item>
<Form.Item label="Прикрепить файлы">
{limitHint && <p style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>{limitHint}</p>}
<input
key={fileInputKey}
type="file"
multiple
style={{ display: 'none' }}
id="support-attachments-input"
onChange={handleFileChange}
/>
<label htmlFor="support-attachments-input">
<Button
type="button"
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
disabled={!canAddFile()}
onClick={() => document.getElementById('support-attachments-input')?.click()}
>
Прикрепить файлы
</Button>
</label>
{files.length > 0 && (
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
{files.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
<button
type="button"
aria-label="Удалить"
onClick={() => removeFile(i)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={14} />
</button>
</li>
))}
</ul>
)}
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
Отправить
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Form, Input, Button, message, Space } from 'antd';
import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons';
import { Form, Input, Button, message, Space, Modal } from 'antd';
import { PhoneOutlined, SafetyOutlined, CopyOutlined } from '@ant-design/icons';
interface Props {
formData: any;
@@ -23,6 +23,8 @@ export default function Step1Phone({
const [codeSent, setCodeSent] = useState(false);
const [loading, setLoading] = useState(false);
const [verifyLoading, setVerifyLoading] = useState(false);
const [debugCode, setDebugCode] = useState<string | null>(null);
const [showDebugModal, setShowDebugModal] = useState(false);
const sendCode = async () => {
try {
@@ -49,7 +51,13 @@ export default function Step1Phone({
message.success('Код отправлен на ваш телефон');
setCodeSent(true);
updateFormData({ phone });
// DEBUG код не показываем в продакшене
// 🔧 DEV MODE: показываем debug код в модалке (только в development)
// В production debug_code не приходит с сервера, поэтому модалка не покажется
if (result.debug_code && import.meta.env.MODE === 'development') {
setDebugCode(result.debug_code);
setShowDebugModal(true);
}
} else {
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
message.error(result.detail || 'Ошибка отправки кода');
@@ -334,7 +342,60 @@ export default function Step1Phone({
)}
</Form.Item>
{/* DEV MODE секция удалена для продакшена */}
{/* 🔧 DEV MODE: Модалка с SMS кодом (только в development) */}
{import.meta.env.MODE === 'development' && (
<Modal
title="🔧 DEV MODE - SMS Код"
open={showDebugModal}
onCancel={() => setShowDebugModal(false)}
footer={[
<Button
key="copy"
icon={<CopyOutlined />}
onClick={() => {
if (debugCode) {
// Fallback для HTTP (clipboard API требует HTTPS)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(debugCode);
} else {
// Fallback: копируем через textarea
const textArea = document.createElement('textarea');
textArea.value = debugCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
message.success('Код скопирован!');
}
}}
>
Скопировать
</Button>,
<Button key="close" type="primary" onClick={() => setShowDebugModal(false)}>
Закрыть
</Button>
]}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<p style={{ marginBottom: 16, color: '#666' }}>
Это DEV режим. SMS не отправляется реально.
</p>
<div style={{
fontSize: 32,
fontWeight: 'bold',
fontFamily: 'monospace',
background: '#f5f5f5',
padding: '16px 32px',
borderRadius: 8,
display: 'inline-block',
letterSpacing: 8
}}>
{debugCode}
</div>
</div>
</Modal>
)}
</Form>
);
}

View File

@@ -3,7 +3,8 @@ import { Form, Input, Button, AutoComplete, message, Space, Divider } from 'antd
import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
const NSPK_BANKS_API = 'http://212.193.27.93/api/payouts/dictionaries/nspk-banks';
// API для получения списка банков СБП через backend (избегаем Mixed Content ошибок)
const NSPK_BANKS_API = `${API_BASE_URL}/api/v1/banks/nspk`;
interface Bank {
bankid: string;
@@ -51,10 +52,23 @@ export default function Step3Payment({
throw new Error(`HTTP ${response.status}`);
}
const banksData: Bank[] = await response.json();
// Наш API возвращает формат: [{"bankId":"...","bankName":"..."}]
let banksData: Bank[] = await response.json();
// Преобразуем формат нашего API в наш внутренний формат
banksData = banksData
.filter((bank: any) => bank && bank.bankName && typeof bank.bankName === 'string')
.map((bank: any) => ({
bankid: bank.bankId || '',
bankname: bank.bankName
}));
// Сортируем по названию для удобства
banksData.sort((a, b) => a.bankname.localeCompare(b.bankname, 'ru'));
banksData.sort((a, b) => {
const nameA = (a.bankname || '').toString();
const nameB = (b.bankname || '').toString();
return nameA.localeCompare(nameB, 'ru');
});
setBanks(banksData);
addDebugEvent?.('banks', 'success', `✅ Загружено ${banksData.length} банков`, { count: banksData.length });
@@ -62,29 +76,31 @@ export default function Step3Payment({
// Если есть сохранённый bankName или bankId - восстанавливаем значения
if (formData.bankName) {
const foundBank = banksData.find(b =>
b && b.bankname && (
b.bankname.toLowerCase() === formData.bankName.toLowerCase() ||
b.bankname.toLowerCase().includes(formData.bankName.toLowerCase())
)
);
if (foundBank) {
if (foundBank && foundBank.bankname) {
updateFormData({
bankId: foundBank.bankid,
bankId: foundBank.bankid || '',
bankName: foundBank.bankname
});
form.setFieldsValue({
bankId: foundBank.bankid,
bankId: foundBank.bankid || '',
bankName: foundBank.bankname
});
}
} else if (formData.bankId) {
// Если есть только bankId, находим по ID
const foundBank = banksData.find(b => b.bankid === formData.bankId);
if (foundBank) {
const foundBank = banksData.find(b => b && b.bankid === formData.bankId);
if (foundBank && foundBank.bankname) {
updateFormData({
bankId: foundBank.bankid,
bankId: foundBank.bankid || '',
bankName: foundBank.bankname
});
form.setFieldsValue({
bankId: foundBank.bankid,
bankId: foundBank.bankid || '',
bankName: foundBank.bankname
});
}
@@ -414,7 +430,7 @@ export default function Step3Payment({
return Promise.resolve();
}
const foundBank = banks.find(b =>
b.bankname.toLowerCase() === value.toLowerCase()
b && b.bankname && b.bankname.toLowerCase() === value.toLowerCase()
);
if (!foundBank) {
return Promise.reject(new Error('Выберите банк из списка'));
@@ -429,7 +445,9 @@ export default function Step3Payment({
size="large"
loading={banksLoading}
notFoundContent={banksLoading ? "Загрузка..." : "Банк не найден. Попробуйте ввести другое название"}
options={banks.map((bank) => ({
options={banks
.filter(bank => bank && bank.bankname)
.map((bank) => ({
value: bank.bankname,
label: bank.bankname,
}))}
@@ -439,28 +457,28 @@ export default function Step3Payment({
}}
onSelect={(value) => {
// При выборе из списка находим банк и сохраняем оба поля
const selectedBank = banks.find(b => b.bankname === value);
if (selectedBank) {
const selectedBank = banks.find(b => b && b.bankname && b.bankname === value);
if (selectedBank && selectedBank.bankname) {
updateFormData({
bankId: selectedBank.bankid,
bankId: selectedBank.bankid || '',
bankName: selectedBank.bankname
});
// Устанавливаем bankId в скрытое поле
form.setFieldsValue({ bankId: selectedBank.bankid });
form.setFieldsValue({ bankId: selectedBank.bankid || '' });
}
}}
onChange={(value) => {
// При вводе текста ищем точное совпадение по названию
if (typeof value === 'string') {
const foundBank = banks.find(b =>
b.bankname.toLowerCase() === value.toLowerCase()
b && b.bankname && b.bankname.toLowerCase() === value.toLowerCase()
);
if (foundBank) {
if (foundBank && foundBank.bankname) {
updateFormData({
bankId: foundBank.bankid,
bankId: foundBank.bankid || '',
bankName: foundBank.bankname
});
form.setFieldsValue({ bankId: foundBank.bankid });
form.setFieldsValue({ bankId: foundBank.bankid || '' });
} else if (value === '') {
// Если поле очищено, очищаем и bankId
updateFormData({ bankId: undefined, bankName: undefined });

View File

@@ -98,7 +98,8 @@ export default function StepClaimConfirmation({
false;
// Генерируем HTML форму здесь, на нашей стороне
const html = generateConfirmationFormHTML(formData, contact_data_confirmed);
const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://aiform.clientright.ru';
const html = generateConfirmationFormHTML(formData, contact_data_confirmed, apiBaseUrl);
setHtmlContent(html);
setLoading(false);
}, [claimPlanData]);

View File

@@ -0,0 +1,37 @@
/* Карточки дашборда — в стиле экрана hello: тень и подъём при наведении, одинаковая высота */
.dashboard-tile {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
min-height: 88px;
height: 100%;
}
.dashboard-tile:hover {
transform: translateY(-6px);
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}
.dashboard-tile .ant-card-body {
padding: 14px;
}
/* чтобы все плитки в ряду были одной высоты */
.dashboard-tile-row .ant-col {
display: flex;
}
.dashboard-tile-row .ant-col .dashboard-tile {
width: 100%;
}
/* заголовок плитки — фиксированная высота под 2 строки, чтобы «Приняты к работе» не делал карточку выше */
.dashboard-tile-title {
min-height: 2.5em;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@@ -0,0 +1,330 @@
/**
* StepComplaintsDashboard.tsx
*
* Экран «Мои обращения»: плитки по статусам + кнопка «Подать жалобу».
* Показывается после нажатия «Мои обращения» на приветственном экране.
*/
import { useEffect, useState } from 'react';
import { Button, Card, Row, Col, Typography, Spin } from 'antd';
import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle, MessageCircle } from 'lucide-react';
import './StepComplaintsDashboard.css';
const { Title, Text } = Typography;
// Признак элемента из CRM (проект/тикет)
function isFromCrm(d: DraftItem): boolean {
const p = (d as any).payload;
return (d as any).type_code === 'external_case' || p?.source === 'CRM' || (p && 'projectid' in p);
}
// Тикет из CRM (для карточки «Консультации»)
function isCrmTicket(d: DraftItem): boolean {
if (!isFromCrm(d)) return false;
const p = (d as any).payload;
return p?.ticketid != null || p?.ticket_no != null || (d as any).type_code === 'consultation';
}
// Статус CRM: resolved | rejected | in_work (по status_code или payload.projectstatus)
function getCrmStatus(d: DraftItem): 'resolved' | 'rejected' | 'in_work' {
const code = ((d as any).status_code || '').toLowerCase();
const p = (d as any).payload;
const projectStatus = (p?.projectstatus || p?.status || '').toString().toLowerCase();
if (code === 'completed' || code === 'submitted' || projectStatus.includes('завершено') || projectStatus === 'completed') return 'resolved';
if (code === 'rejected' || projectStatus.includes('отклонен')) return 'rejected';
return 'in_work';
}
interface DraftItem {
claim_id?: string;
id?: string;
status_code?: string;
payload?: Record<string, unknown>;
type_code?: string;
}
interface Counts {
consultations: number;
pending: number;
inWork: number;
resolved: number;
rejected: number;
total: number;
}
// Правила: Консультации = тикеты из CRM; В работе = проекты из CRM не завершено + черновики; Решены/Отклонены = из CRM; В ожидании = все из Postgres
function countByStatus(drafts: DraftItem[]): Counts {
let consultations = 0;
let pending = 0;
let inWork = 0;
let resolved = 0;
let rejected = 0;
for (const d of drafts) {
if (isFromCrm(d)) {
if (isCrmTicket(d)) consultations += 1;
const crmStatus = getCrmStatus(d);
if (crmStatus === 'resolved') resolved += 1;
else if (crmStatus === 'rejected') rejected += 1;
else inWork += 1;
} else {
// Всё из Postgres → «В ожидании»
pending += 1;
}
}
return {
consultations,
pending,
inWork,
resolved,
rejected,
total: drafts.length,
};
}
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
interface StepComplaintsDashboardProps {
unified_id?: string;
phone?: string;
session_id?: string;
/** Канал входа: telegram | max | web */
entry_channel?: string;
/** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */
drafts?: DraftItem[];
loading?: boolean;
onGoToList: (filter: DraftsListFilter) => void;
onNewClaim: () => void;
onNavigate?: (path: string) => void;
}
export default function StepComplaintsDashboard({
unified_id,
phone,
session_id,
entry_channel,
drafts: draftsFromProps,
loading: loadingFromProps,
onGoToList,
onNewClaim,
onNavigate,
}: StepComplaintsDashboardProps) {
const [counts, setCounts] = useState<Counts>({ consultations: 0, pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 });
const [localLoading, setLocalLoading] = useState(true);
const loading = loadingFromProps ?? localLoading;
useEffect(() => {
if (draftsFromProps !== undefined) {
setCounts(countByStatus(Array.isArray(draftsFromProps) ? draftsFromProps : []));
setLocalLoading(false);
return;
}
if (!unified_id && !phone && !session_id) {
setLocalLoading(false);
return;
}
let cancelled = false;
const params = new URLSearchParams();
if (unified_id) params.append('unified_id', unified_id);
if (phone) params.append('phone', phone);
if (session_id) params.append('session_id', session_id);
params.append('entry_channel', (entry_channel || 'web').trim() || 'web');
fetch(`/api/v1/claims/drafts/list?${params.toString()}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить список'))))
.then((data) => {
if (cancelled) return;
setCounts(countByStatus(data.drafts || []));
})
.catch(() => { if (!cancelled) setCounts((c) => ({ ...c, consultations: 0, pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 })); })
.finally(() => { if (!cancelled) setLocalLoading(false); });
return () => { cancelled = true; };
}, [draftsFromProps, unified_id, phone, session_id, entry_channel]);
const tiles = [
{
key: 'pending' as const,
title: 'В ожидании',
count: counts.pending,
label: counts.pending === 1 ? '1 дело' : counts.pending < 5 ? `${counts.pending} дела` : `${counts.pending} дел`,
color: '#3B82F6',
bg: '#EFF6FF',
icon: Clock,
},
{
key: 'in_work' as const,
title: 'Приняты к работе',
count: counts.inWork,
label: counts.inWork === 1 ? '1 дело' : counts.inWork < 5 ? `${counts.inWork} дела` : `${counts.inWork} дел`,
color: '#EA580C',
bg: '#FFF7ED',
icon: Briefcase,
},
{
key: 'resolved' as const,
title: 'Решены',
count: counts.resolved,
label: counts.resolved === 1 ? '1 дело' : counts.resolved < 5 ? `${counts.resolved} дела` : `${counts.resolved} дел`,
color: '#16A34A',
bg: '#F0FDF4',
icon: CheckCircle,
},
{
key: 'rejected' as const,
title: 'Отклонены',
count: counts.rejected,
label: counts.rejected === 1 ? '1 дело' : counts.rejected < 5 ? `${counts.rejected} дела` : `${counts.rejected} дел`,
color: '#DC2626',
bg: '#FEF2F2',
icon: XCircle,
},
];
const handleTileClick = (key: DraftsListFilter) => {
onGoToList(key);
};
return (
<div style={{ padding: '16px', paddingBottom: 24 }}>
<Title level={2} style={{ marginBottom: 4, color: '#111827', fontSize: 22 }}>
Мои обращения
</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 20 }}>
Выберите категорию
</Text>
{loading ? (
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<Spin size="large" />
</div>
) : (
<>
{/* Плитка «Консультации» — в самом верху; данные из CRM по вебхуку */}
{onNavigate && (
<Card
size="small"
className="dashboard-tile"
style={{ background: '#F5F3FF', marginBottom: 12 }}
onClick={() => onNavigate('/consultations')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#8B5CF6',
}}
>
<MessageCircle size={24} strokeWidth={1.8} />
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
Консультации
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{counts.consultations === 0
? 'Тикеты из CRM'
: counts.consultations === 1
? '1 тикет'
: counts.consultations < 5
? `${counts.consultations} тикета`
: `${counts.consultations} тикетов`}
</Text>
</div>
</div>
</Card>
)}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }} className="dashboard-tile-row">
{tiles.map((t) => {
const Icon = t.icon;
return (
<Col xs={12} key={t.key}>
<Card
size="small"
className="dashboard-tile"
style={{ background: t.bg }}
onClick={() => handleTileClick(t.key)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: t.color,
}}
>
<Icon size={24} strokeWidth={1.8} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }} className="dashboard-tile-title">
{t.title}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{t.label}
</Text>
</div>
</div>
</Card>
</Col>
);
})}
</Row>
<Card
size="small"
className="dashboard-tile"
style={{ background: '#F9FAFB', marginBottom: 12 }}
onClick={() => handleTileClick('all' as const)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#6366F1',
}}
>
<FileSearch size={24} strokeWidth={1.8} />
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
Все обращения
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{counts.total === 1 ? '1 дело всего' : counts.total < 5 ? `${counts.total} дела всего` : `${counts.total} дел всего`}
</Text>
</div>
</div>
</Card>
<Button
type="primary"
size="large"
block
icon={<PlusCircle size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />}
onClick={onNewClaim}
style={{ height: 48, fontSize: 16, borderRadius: 12 }}
>
Подать жалобу
</Button>
</>
)}
</div>
);
}

View File

@@ -15,7 +15,7 @@ interface Props {
export default function StepDescription({
formData,
updateFormData,
onPrev,
onPrev: _onPrev,
onNext,
}: Props) {
const [form] = Form.useForm();
@@ -75,14 +75,18 @@ export default function StepDescription({
return;
}
const entryChannel =
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
: 'web';
console.log('📝 Отправка описания проблемы на сервер:', {
session_id: formData.session_id,
phone: formData.phone,
email: formData.email,
unified_id: formData.unified_id,
contact_id: formData.contact_id,
entry_channel: entryChannel,
description_length: safeDescription.length,
description_preview: safeDescription.substring(0, 100),
});
const response = await fetch('/api/v1/claims/description', {
@@ -92,9 +96,10 @@ export default function StepDescription({
session_id: formData.session_id,
phone: formData.phone,
email: formData.email,
unified_id: formData.unified_id, // ✅ Unified ID пользователя
contact_id: formData.contact_id, // ✅ Contact ID пользователя
unified_id: formData.unified_id,
contact_id: formData.contact_id,
problem_description: safeDescription,
entry_channel: entryChannel, // telegram | max | web — для роутинга в n8n
}),
});
@@ -114,6 +119,10 @@ export default function StepDescription({
const responseData = await response.json();
console.log('✅ Описание успешно отправлено:', responseData);
console.log('📥 Ответ n8n (description):', responseData);
if (responseData && typeof responseData === 'object') {
console.log('📥 Ключи ответа n8n:', Object.keys(responseData));
}
message.success('Описание отправлено, подбираем рекомендации...');
updateFormData({
@@ -135,13 +144,9 @@ export default function StepDescription({
return (
<div style={{ marginTop: 24 }}>
<Button onClick={onPrev} size="large">
Назад
</Button>
<div
style={{
marginTop: 24,
marginTop: 0,
padding: 24,
background: '#f6f8fa',
borderRadius: 8,

View File

@@ -0,0 +1,12 @@
/* Карточки списка обращений — как на hello: тень и подъём при наведении */
.draft-list-card {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.draft-list-card:hover {
transform: translateY(-6px);
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}

View File

@@ -14,11 +14,10 @@
*/
import { useEffect, useState } from 'react';
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
import { Button, Card, Modal, Typography, Space, Empty, message, Spin, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
@@ -26,10 +25,57 @@ import {
UploadOutlined,
FileSearchOutlined,
MobileOutlined,
ExclamationCircleOutlined
ExclamationCircleOutlined,
FolderOpenOutlined
} from '@ant-design/icons';
import './StepDraftSelection.css';
import {
Package,
Wrench,
Wallet,
ShoppingCart,
Truck,
Plane,
GraduationCap,
Wifi,
Home,
Hammer,
HeartPulse,
Car,
Building,
Shield,
Ticket,
Headphones,
type LucideIcon,
} from 'lucide-react';
import SupportChat from '../SupportChat';
const { Title, Text, Paragraph } = Typography;
const { Title, Text } = Typography;
// Иконки по направлениям (категориям) для плиток
const DIRECTION_ICONS: Record<string, LucideIcon> = {
'товары': Package,
'услуги': Wrench,
'финансы и платежи': Wallet,
'интернет-торговля и маркетплейсы': ShoppingCart,
'доставка и логистика': Truck,
'туризм и путешествия': Plane,
'образование и онлайн-курсы': GraduationCap,
'связь и интернет': Wifi,
'жкх и коммунальные услуги': Home,
'строительство и ремонт': Hammer,
'медицина и платные клиники': HeartPulse,
'транспорт и перевозки': Car,
'недвижимость и аренда': Building,
'страхование': Shield,
'развлечения и мероприятия': Ticket,
};
function getDirectionIcon(directionOrCategory: string | undefined): LucideIcon | null {
if (!directionOrCategory || typeof directionOrCategory !== 'string') return null;
const key = directionOrCategory.trim().toLowerCase();
return DIRECTION_ICONS[key] || null;
}
// Форматирование даты
const formatDate = (dateStr: string) => {
@@ -46,6 +92,58 @@ const formatDate = (dateStr: string) => {
}
};
// Короткая дата для карточек списка: "12 апреля 2024"
const formatDateShort = (dateStr: string) => {
try {
const date = new Date(dateStr);
const day = date.getDate();
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
const year = date.getFullYear();
return `${day} ${month} ${year}`;
} catch {
return dateStr;
}
};
// Маппинг status_code → категория дашборда (как в StepComplaintsDashboard)
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
const IN_WORK_CODE = 'in_work';
const RESOLVED_CODES = ['completed', 'submitted'];
const REJECTED_CODE = 'rejected';
function getDraftCategory(statusCode: string): 'pending' | 'in_work' | 'resolved' | 'rejected' {
const code = (statusCode || '').toLowerCase();
if (code === IN_WORK_CODE) return 'in_work';
if (code === REJECTED_CODE) return 'rejected';
if (RESOLVED_CODES.includes(code)) return 'resolved';
return 'pending';
}
/** Признак элемента из CRM (проект/тикет) — как в StepComplaintsDashboard */
function isFromCrm(d: { payload?: Record<string, unknown>; type_code?: string }): boolean {
const p = d.payload;
return d.type_code === 'external_case' || (p as any)?.source === 'CRM' || (p && 'projectid' in (p || {}));
}
/** Категория для фильтра и плитки: Postgres по status_code, CRM по status_code (active→in_work, completed→resolved, rejected→rejected) */
function getItemCategory(draft: { status_code?: string; payload?: Record<string, unknown>; type_code?: string }): 'pending' | 'in_work' | 'resolved' | 'rejected' {
if (isFromCrm(draft)) {
const code = (draft.status_code || '').toLowerCase();
if (code === 'completed' || (draft.payload as any)?.projectstatus === 'completed') return 'resolved';
if (code === 'rejected') return 'rejected';
return 'in_work'; // active и всё остальное (тикеты, черновики CRM и т.д.)
}
return getDraftCategory(draft.status_code || '');
}
const CATEGORY_LABELS: Record<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected', string> = {
all: 'Все обращения',
pending: 'В ожидании',
in_work: 'Приняты к работе',
resolved: 'Решены',
rejected: 'Отклонены',
};
// Относительное время
const getRelativeTime = (dateStr: string) => {
try {
@@ -83,6 +181,8 @@ interface Draft {
problem_title?: string; // Краткое описание (заголовок)
problem_description?: string;
category?: string; // Категория проблемы
direction?: string; // Направление (для иконки плитки)
facts_short?: string; // Краткие факты от AI — заголовок плитки
wizard_plan: boolean;
wizard_answers: boolean;
has_documents: boolean;
@@ -96,13 +196,29 @@ interface Draft {
is_legacy?: boolean; // Старый формат без documents_required
}
/** Фильтр списка по категории (с дашборда) */
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
interface Props {
phone?: string;
session_id?: string;
unified_id?: string;
isTelegramMiniApp?: boolean;
entry_channel?: string;
/** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */
drafts?: Draft[] | any[];
loading?: boolean;
/** Вызов после удаления черновика, чтобы родитель перезапросил список */
onRefreshDrafts?: () => void;
/** ID черновика, открытого для просмотра описания (управляется из ClaimForm, чтобы не терять при пересчёте steps) */
draftDetailClaimId?: string | null;
/** Показывать только обращения этой категории (с дашборда) */
categoryFilter?: DraftsListFilter;
onOpenDraftDetail?: (claimId: string) => void;
onCloseDraftDetail?: () => void;
onSelectDraft: (claimId: string) => void;
onNewClaim: () => void;
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
onRestartDraft?: (claimId: string, description: string) => void;
}
// === Конфиг статусов ===
@@ -162,6 +278,27 @@ const STATUS_CONFIG: Record<string, {
description: 'Заявка на рассмотрении',
action: 'Просмотреть',
},
active: {
color: 'cyan',
icon: <FileSearchOutlined />,
label: 'В работе',
description: 'Дело из CRM',
action: 'Просмотреть',
},
completed: {
color: 'green',
icon: <CheckCircleOutlined />,
label: 'Решено',
description: 'Дело завершено',
action: 'Просмотреть',
},
rejected: {
color: 'red',
icon: <ExclamationCircleOutlined />,
label: 'Отклонено',
description: 'Дело отклонено',
action: 'Просмотреть',
},
legacy: {
color: 'warning',
icon: <ExclamationCircleOutlined />,
@@ -171,71 +308,81 @@ const STATUS_CONFIG: Record<string, {
},
};
function processDraftsFromApi(raw: any[]): Draft[] {
return (raw || []).map((draft: Draft) => {
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
return { ...draft, is_legacy: isLegacy };
});
}
export default function StepDraftSelection({
phone,
session_id,
unified_id,
isTelegramMiniApp,
entry_channel,
drafts: draftsFromProps,
loading: loadingFromProps,
onRefreshDrafts,
draftDetailClaimId = null,
categoryFilter = 'all',
onOpenDraftDetail,
onCloseDraftDetail,
onSelectDraft,
onNewClaim,
onRestartDraft,
}: Props) {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [loading, setLoading] = useState(true);
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
const [localLoading, setLocalLoading] = useState(true);
const drafts = draftsFromProps !== undefined ? processDraftsFromApi(Array.isArray(draftsFromProps) ? draftsFromProps : []) : localDrafts;
const loading = loadingFromProps !== undefined ? loadingFromProps : localLoading;
const [supportModalClaimId, setSupportModalClaimId] = useState<string | null>(null);
/** Список отфильтрован по категории с дашборда (учёт и Postgres, и CRM) */
const filteredDrafts =
categoryFilter === 'all'
? drafts
: drafts.filter((d) => getItemCategory(d) === categoryFilter);
const [deletingId, setDeletingId] = useState<string | null>(null);
/** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */
const [detailDraftPayload, setDetailDraftPayload] = useState<{ claimId: string; payload: Record<string, unknown> } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
/** Черновик для экрана описания: из пропа draftDetailClaimId + список drafts */
const selectedDraft = draftDetailClaimId
? (drafts.find((d) => (d.claim_id || d.id) === draftDetailClaimId) ?? null)
: null;
const loadDrafts = async () => {
if (draftsFromProps !== undefined) return;
try {
setLoading(true);
setLocalLoading(true);
if (!unified_id && !phone && !session_id) {
setLocalLoading(false);
return;
}
const params = new URLSearchParams();
if (unified_id) {
params.append('unified_id', unified_id);
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
} else if (phone) {
params.append('phone', phone);
console.log('🔍 StepDraftSelection: загружаем черновики по phone:', phone);
} else if (session_id) {
params.append('session_id', session_id);
console.log('🔍 StepDraftSelection: загружаем черновики по session_id:', session_id);
}
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
console.log('🔍 StepDraftSelection: запрос:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Не удалось загрузить черновики');
}
const data = await response.json();
console.log('🔍 StepDraftSelection: ответ API:', data);
// Определяем legacy черновики (без documents_required в payload)
const processedDrafts = (data.drafts || []).map((draft: Draft) => {
// Legacy только если:
// 1. Статус 'draft' (старый формат) ИЛИ
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
// И есть wizard_plan (старый формат)
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
return {
...draft,
is_legacy: isLegacy,
};
});
setDrafts(processedDrafts);
if (unified_id) params.append('unified_id', unified_id);
if (phone) params.append('phone', phone);
if (session_id) params.append('session_id', session_id);
params.append('entry_channel', (entry_channel || 'web').trim() || 'web');
const res = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
if (!res.ok) throw new Error('Не удалось загрузить черновики');
const data = await res.json();
setLocalDrafts(processDraftsFromApi(data.drafts || []));
} catch (error) {
console.error('Ошибка загрузки черновиков:', error);
message.error('Не удалось загрузить список черновиков');
} finally {
setLoading(false);
setLocalLoading(false);
}
};
useEffect(() => {
if (draftsFromProps !== undefined) return;
loadDrafts();
}, [phone, session_id, unified_id]);
}, [phone, unified_id, entry_channel, draftsFromProps]);
const handleDelete = async (claimId: string) => {
try {
@@ -249,7 +396,7 @@ export default function StepDraftSelection({
}
message.success('Черновик удален');
await loadDrafts();
if (onRefreshDrafts) await onRefreshDrafts(); else await loadDrafts();
} catch (error) {
console.error('Ошибка удаления черновика:', error);
message.error('Не удалось удалить черновик');
@@ -263,6 +410,10 @@ export default function StepDraftSelection({
if (draft.is_legacy) {
return STATUS_CONFIG.legacy;
}
if (isFromCrm(draft)) {
const code = (draft.status_code || 'active').toLowerCase();
return STATUS_CONFIG[code] || STATUS_CONFIG.active;
}
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
};
@@ -276,6 +427,38 @@ export default function StepDraftSelection({
return { uploaded, skipped, total, percent };
};
// Открыть экран полного описания (загрузка payload — в useEffect по draftDetailClaimId)
const openDraftDetail = (draft: Draft) => {
const draftId = draft.claim_id || draft.id;
onOpenDraftDetail?.(draftId);
setDetailDraftPayload(null);
setDetailLoading(true);
};
const closeDraftDetail = () => {
onCloseDraftDetail?.();
setDetailDraftPayload(null);
};
// Загрузка payload при открытии по draftDetailClaimId (клик по карточке или восстановление после пересчёта steps)
useEffect(() => {
if (!draftDetailClaimId) return;
if (detailDraftPayload?.claimId === draftDetailClaimId) return;
setDetailLoading(true);
setDetailDraftPayload(null);
const claimId = draftDetailClaimId;
fetch(`/api/v1/claims/drafts/${claimId}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить черновик'))))
.then((data) => {
const payload = data?.claim?.payload;
if (payload && typeof payload === 'object') {
setDetailDraftPayload({ claimId, payload });
}
})
.catch(() => {})
.finally(() => setDetailLoading(false));
}, [draftDetailClaimId]);
// Обработка клика на черновик
const handleDraftAction = (draft: Draft) => {
const draftId = draft.claim_id || draft.id;
@@ -291,6 +474,27 @@ export default function StepDraftSelection({
// Кнопка действия
const getActionButton = (draft: Draft) => {
// Для заявок "В работе"
if (draft.status_code === 'in_work') {
// ✅ В веб-версии показываем кнопку "Просмотреть в Telegram"
if (!isTelegramMiniApp) {
return (
<Button
type="primary"
icon={<FileSearchOutlined />}
onClick={() => {
// Открываем Telegram бота
window.open('https://t.me/klientprav_bot', '_blank');
}}
>
Просмотреть в Telegram
</Button>
);
}
// ✅ В Telegram Mini App не показываем (но этот код не выполнится, т.к. заявки отфильтрованы)
return null;
}
const config = getStatusConfig(draft);
return (
@@ -304,270 +508,229 @@ export default function StepDraftSelection({
);
};
// Экран полного описания черновика (draftDetailClaimId открыт; selectedDraft может быть null пока список не подгрузился)
if (draftDetailClaimId) {
const draftId = draftDetailClaimId;
const payload = detailDraftPayload?.claimId === draftId ? detailDraftPayload.payload : null;
const fromPayload =
(payload && (payload.problem_description ?? payload.description ?? payload.chatInput)) ?? '';
const fromDraft = selectedDraft
? (selectedDraft.problem_description ||
selectedDraft.facts_short ||
selectedDraft.problem_title ||
'')
: '';
const fullText = String(fromPayload || fromDraft || '').trim();
const displayText = fullText || 'Описание не сохранено';
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0, width: '100%', boxSizing: 'border-box' }}>
<Card
bodyStyle={{ padding: '16px 20px' }}
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff', width: '100%', boxSizing: 'border-box' }}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Title level={4} style={{ marginBottom: 8, color: '#111827' }}>
Обращение
</Title>
<div
style={{
padding: '16px',
background: '#f8fafc',
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fff',
border: '1px solid #e2e8f0',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: 80,
maxHeight: 320,
overflow: 'auto',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
📋 Ваши заявки
</Title>
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
Выберите заявку для продолжения или создайте новую.
</Paragraph>
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
</div>
{/* Кнопка создания новой заявки - всегда вверху */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{selectedDraft?.is_legacy && onRestartDraft ? (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewClaim}
size="large"
style={{ width: '100%' }}
icon={<ReloadOutlined />}
onClick={() => {
onRestartDraft(draftId, selectedDraft.problem_description || '');
closeDraftDetail();
}}
>
Создать новую заявку
Начать заново
</Button>
) : (
<Button
type="primary"
size="large"
icon={<FolderOpenOutlined />}
onClick={() => {
onSelectDraft(draftId);
closeDraftDetail();
}}
>
К документам
</Button>
)}
<Button
type="default"
size="middle"
icon={<Headphones size={16} style={{ verticalAlign: 'middle' }} />}
onClick={() => setSupportModalClaimId(draftId)}
>
Написать в поддержку
</Button>
</div>
</Space>
</Card>
<Modal
title="Написать в поддержку"
open={supportModalClaimId === draftId}
onCancel={() => setSupportModalClaimId(null)}
footer={null}
width={480}
destroyOnClose
mask={false}
>
<SupportChat
claimId={draftId}
source="complaint_card"
compact
onSuccess={() => {
setSupportModalClaimId(null);
message.success('Запрос отправлен.');
}}
/>
</Modal>
</div>
);
}
// Цвет точки статуса по категории (как на макете — зелёный для «Приняты к работе»)
const statusDotColor: Record<string, string> = {
pending: '#1890ff',
in_work: '#52c41a',
resolved: '#52c41a',
rejected: '#ff4d4f',
};
return (
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0, width: '100%', boxSizing: 'border-box' }}>
{/* Шапка: заголовок + подзаголовок категории */}
<div style={{ marginBottom: 16, padding: '16px 0 8px' }}>
<Title level={3} style={{ margin: 0, color: '#111827', fontWeight: 700 }}>
Мои обращения
</Title>
<Text type="secondary" style={{ fontSize: 14, marginTop: 4, display: 'block' }}>
{CATEGORY_LABELS[categoryFilter]}
</Text>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : drafts.length === 0 ? (
) : filteredDrafts.length === 0 ? (
<Empty
description="У вас пока нет незавершенных заявок"
description={categoryFilter === 'all' ? 'У вас пока нет обращений' : `Нет обращений в категории «${CATEGORY_LABELS[categoryFilter]}»`}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<>
<List
dataSource={drafts}
renderItem={(draft) => {
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{filteredDrafts.map((draft) => {
const config = getStatusConfig(draft);
const docsProgress = getDocsProgress(draft);
const tileTitle = draft.facts_short
|| draft.problem_title
|| (draft.problem_description
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
: 'Обращение');
const category = getItemCategory(draft);
const dotColor = statusDotColor[category] || '#8c8c8c';
return (
<List.Item
style={{
padding: '16px',
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
borderRadius: 12,
marginBottom: 16,
background: draft.is_legacy ? '#fffbe6' : '#fff',
overflow: 'hidden',
display: 'block', // Вертикальный layout
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
<Card
key={draft.claim_id || draft.id}
className="draft-list-card"
hoverable
style={{ background: '#fff', cursor: 'pointer' }}
bodyStyle={{ padding: '14px 16px' }}
onClick={() => openDraftDetail(draft)}
>
<List.Item.Meta
avatar={
<div style={{
width: 40,
height: 40,
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Text strong style={{ fontSize: 15, color: '#111827', lineHeight: 1.35 }}>
{tileTitle}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: draft.is_legacy ? '#faad14' : '#595959',
background: dotColor,
flexShrink: 0,
}}>
{config.icon}
</div>
}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
{draft.category && (
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
)}
</div>
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{/* Заголовок - краткое описание проблемы */}
{draft.problem_title && (
<Text strong style={{
fontSize: 15,
color: '#1a1a1a',
display: 'block',
marginBottom: 4,
}}>
{draft.problem_title}
</Text>
)}
{/* Полное описание проблемы */}
{draft.problem_description && (
<div
style={{
fontSize: 13,
lineHeight: 1.6,
color: '#262626',
background: '#f5f5f5',
padding: '10px 14px',
borderRadius: 8,
borderLeft: '4px solid #1890ff',
marginTop: 4,
wordBreak: 'break-word',
}}
title={draft.problem_description}
>
{draft.problem_description.length > 250
? draft.problem_description.substring(0, 250) + '...'
: draft.problem_description
}
</div>
)}
{/* Время обновления */}
<Space size="small">
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Tooltip title={formatDate(draft.updated_at)}>
<Text type="secondary" style={{ fontSize: 12 }}>
{getRelativeTime(draft.updated_at)}
</Text>
</Tooltip>
</Space>
{/* Legacy предупреждение */}
{draft.is_legacy && (
<Alert
message="Черновик в старом формате. Нажмите 'Начать заново'."
type="warning"
showIcon
style={{ fontSize: 12, padding: '4px 8px' }}
/>
)}
{/* Список документов со статусами */}
{draft.documents_list && draft.documents_list.length > 0 && (
<div style={{
marginTop: 8,
background: '#fafafa',
borderRadius: 8,
padding: '8px 12px',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}>
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
📄 Документы
</Text>
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
</Text>
<Text style={{ fontSize: 13, color: dotColor }}>{config.label}</Text>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{draft.documents_list.map((doc, idx) => (
<div key={idx} style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 12,
}}>
{doc.uploaded ? (
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
) : (
<span style={{
width: 14,
height: 14,
borderRadius: '50%',
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
display: 'inline-block',
}} />
)}
<span style={{
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
textDecoration: doc.uploaded ? 'none' : 'none',
}}>
{doc.name}
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
</span>
</div>
))}
</div>
</div>
)}
{/* Прогрессбар (если нет списка) */}
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
<div style={{ marginTop: 4 }}>
<Progress
percent={docsProgress.percent}
size="small"
showInfo={false}
strokeColor={{
'0%': '#1890ff',
'100%': '#52c41a',
}}
trailColor="#f0f0f0"
/>
</div>
)}
{/* Описание статуса */}
<Text type="secondary" style={{ fontSize: 12 }}>
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.4 }}>
{config.description}
</Text>
{/* Кнопки действий */}
<div style={{
display: 'flex',
gap: 12,
marginTop: 12,
paddingTop: 12,
borderTop: '1px solid #f0f0f0',
}}>
{getActionButton(draft)}
<Popconfirm
title="Удалить заявку?"
description="Это действие нельзя отменить"
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
okText="Да, удалить"
cancelText="Отмена"
>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(draft.updated_at)}
</Text>
<Button
danger
icon={<DeleteOutlined />}
loading={deletingId === (draft.claim_id || draft.id)}
disabled={deletingId === (draft.claim_id || draft.id)}
>
Удалить
</Button>
</Popconfirm>
</div>
</Space>
}
/>
</List.Item>
);
type="link"
size="small"
style={{ padding: 0, height: 'auto', marginTop: 4 }}
icon={<Headphones size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />}
onClick={(e) => {
e.stopPropagation();
setSupportModalClaimId(draft.claim_id || draft.id || '');
}}
/>
>
Поддержка
</Button>
</div>
</Card>
);
})}
<div style={{ textAlign: 'center', marginTop: 16 }}>
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Button
type="link"
icon={<ReloadOutlined />}
onClick={loadDrafts}
onClick={() => onRefreshDrafts ? onRefreshDrafts() : loadDrafts()}
loading={loading}
>
Обновить список
</Button>
</div>
</>
)}
</Space>
</Card>
)}
<Modal
title="Написать в поддержку"
open={!!supportModalClaimId}
onCancel={() => setSupportModalClaimId(null)}
footer={null}
width={480}
destroyOnClose
mask={false}
>
{supportModalClaimId && (
<SupportChat
claimId={supportModalClaimId}
source="complaint_card"
compact
onSuccess={() => {
setSupportModalClaimId(null);
message.success('Запрос отправлен.');
}}
/>
)}
</Modal>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { Button, Card, Checkbox, Form, Input, Modal, Radio, Result, Row, Col, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
import { getDocTypeStyle, STATUS_UPLOADED, STATUS_NEEDED, STATUS_NOT_AVAILABLE, STATUS_OPTIONAL } from './documentsScreenMaps';
import AiWorkingIllustration from '../../assets/ai-working.svg';
import type { UploadFile } from 'antd/es/upload/interface';
@@ -50,6 +51,8 @@ interface Props {
updateFormData: (data: any) => void;
onNext: () => void;
onPrev: () => void;
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
onNewClaim?: () => void; // ✅ Переход на форму нового обращения (шаг «Описание»)
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
@@ -92,6 +95,21 @@ const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
const YES_VALUES = ['да', 'yes', 'true', '1'];
/** Единое событие от бэкенда: тип + текст (+ data для consumer_complaint) */
type DisplayEventType = 'trash_message' | 'out_of_scope' | 'consumer_consultation' | 'consumer_complaint';
interface ResponseEvent {
event_type: DisplayEventType;
message: string;
data?: Record<string, any>;
suggested_actions?: any[];
}
const DISPLAY_STYLE: Record<DisplayEventType, { bg: string; border: string; title: string }> = {
trash_message: { bg: '#fff2f0', border: '#ffccc7', title: 'Не по тематике' },
out_of_scope: { bg: '#fff7e6', border: '#ffd591', title: 'Вне нашей компетенции' },
consumer_consultation: { bg: '#e6f7ff', border: '#91d5ff', title: 'Консультация' },
consumer_complaint: { bg: '#f6ffed', border: '#b7eb8f', title: 'Обращение принято' },
};
const isAffirmative = (value: any) => {
if (typeof value === 'boolean') {
return value;
@@ -110,6 +128,8 @@ export default function StepWizardPlan({
updateFormData,
onNext,
onPrev,
backToDraftsList,
onNewClaim,
addDebugEvent,
}: Props) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
@@ -120,6 +140,8 @@ export default function StepWizardPlan({
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
/** Единое событие от бэка: тип + текст — одно окошко с цветом по типу */
const [responseEvent, setResponseEvent] = useState<ResponseEvent | null>(null);
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
@@ -462,83 +484,59 @@ export default function StepWizardPlan({
payload_preview: JSON.stringify(payload).substring(0, 200),
});
// ❌ OUT OF SCOPE: Вопрос не связан с защитой прав потребителей
if (eventType === 'out_of_scope') {
debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос вне скоупа', {
session_id: sessionId,
message: payload.message,
suggested_actions: payload.suggested_actions,
});
setIsWaiting(false);
setOutOfScopeData(payload); // Сохраняем полные данные
setConnectionError(null); // Не используем connectionError
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
source.close();
eventSourceRef.current = null;
// Не показывать служебное сообщение подключения SSE как ответ пользователю
if (payload.status === 'connected' && payload.message === 'Подключено к событиям') {
return;
}
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
if (eventType === 'documents_list_ready') {
const documentsRequired = payload.documents_required || [];
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
session_id: sessionId,
documents_count: documentsRequired.length,
documents: documentsRequired.map((d: any) => d.name),
});
console.log('📋 documents_list_ready:', {
claim_id: payload.claim_id,
documents_required: documentsRequired,
});
// Сохраняем в formData для нового флоу
updateFormData({
documents_required: documentsRequired,
claim_id: payload.claim_id,
wizardPlanStatus: 'documents_ready', // Новый статус
});
// Единый формат от бэка: event_type + message (тип и текст)
const displayTypes: DisplayEventType[] = ['trash_message', 'out_of_scope', 'consumer_consultation', 'consumer_complaint'];
let isDisplayEvent = payload.event_type && displayTypes.includes(payload.event_type as DisplayEventType) && payload.message != null;
// Fallback: пришло только message без event_type — показываем как out_of_scope (но не служебное "Подключено к событиям")
if (!isDisplayEvent && payload.message != null && String(payload.message).trim() && payload.message !== 'Подключено к событиям') {
payload.event_type = payload.event_type || 'out_of_scope';
payload.event_type = displayTypes.includes(payload.event_type as DisplayEventType) ? payload.event_type : 'out_of_scope';
isDisplayEvent = true;
}
if (isDisplayEvent) {
const ev: ResponseEvent = {
event_type: payload.event_type as DisplayEventType,
message: payload.message || 'Ответ получен',
data: payload.data,
suggested_actions: payload.suggested_actions,
};
setResponseEvent(ev);
setIsWaiting(false);
setConnectionError(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Пока показываем alert для теста, потом переход к StepDocumentsNew
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
// TODO: onNext() для перехода к StepDocumentsNew
return;
}
const wizardPayload = extractWizardPayload(payload);
const hasWizardPlan = Boolean(wizardPayload);
if (eventType?.includes('wizard') || hasWizardPlan) {
const wizardPlan = wizardPayload?.wizard_plan;
const answersPrefill = wizardPayload?.answers_prefill;
const coverageReport = wizardPayload?.coverage_report;
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
// consumer_complaint с data: список документов или план — обновляем formData и при необходимости план
if (ev.event_type === 'consumer_complaint' && ev.data) {
const docs = ev.data.documents_required ?? payload.documents_required;
if (docs && Array.isArray(docs)) {
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
session_id: sessionId,
questions: wizardPlan?.questions?.length || 0,
documents_count: docs.length,
});
updateFormData({
documents_required: docs,
claim_id: ev.data.claim_id || payload.claim_id,
wizardPlanStatus: 'documents_ready',
});
message.success(`Получен список документов: ${docs.length} шт.`);
}
const wizardPlan = ev.data.wizard_plan ?? extractWizardPayload(payload)?.wizard_plan;
if (wizardPlan) {
const wizardPayload = extractWizardPayload(payload) || { wizard_plan: wizardPlan, answers_prefill: ev.data.answers_prefill, coverage_report: ev.data.coverage_report };
const answersPrefill = wizardPayload.answers_prefill ?? ev.data.answers_prefill;
const coverageReport = wizardPayload.coverage_report ?? ev.data.coverage_report;
const prefill = buildPrefillMap(answersPrefill);
setPlan(wizardPlan);
setPrefillMap(prefill);
setIsWaiting(false);
setConnectionError(null);
updateFormData({
wizardPlan: wizardPlan,
wizardPrefill: prefill,
@@ -546,11 +544,91 @@ export default function StepWizardPlan({
wizardCoverageReport: coverageReport,
wizardPlanStatus: 'ready',
});
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
source.close();
eventSourceRef.current = null;
}
}
// Для trash и out_of_scope закрываем SSE
if (ev.event_type === 'trash_message' || ev.event_type === 'out_of_scope') {
source.close();
eventSourceRef.current = null;
}
return;
}
// Обратная совместимость: старый формат без нормализации (out_of_scope, trash_message, documents_list_ready, wizard)
if (eventType === 'out_of_scope') {
setResponseEvent({
event_type: 'out_of_scope',
message: payload.message || 'К сожалению, мы не можем помочь с этим вопросом.',
suggested_actions: payload.suggested_actions,
});
setOutOfScopeData(payload);
setIsWaiting(false);
setConnectionError(null);
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
source.close();
eventSourceRef.current = null;
return;
}
if (eventType === 'trash_message' || payload?.payload?.intent === 'trash') {
const msg = payload?.payload?.message || payload?.message || 'К сожалению, это обращение не по тематике защиты прав потребителей.';
setResponseEvent({
event_type: 'trash_message',
message: msg,
suggested_actions: payload?.payload?.suggested_actions || payload?.suggested_actions,
});
setIsWaiting(false);
setConnectionError(null);
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
source.close();
eventSourceRef.current = null;
return;
}
if (eventType === 'documents_list_ready') {
const documentsRequired = payload.documents_required || [];
setResponseEvent({
event_type: 'consumer_complaint',
message: `Подготовлен список документов: ${documentsRequired.length} шт.`,
data: { documents_required: documentsRequired, claim_id: payload.claim_id },
});
updateFormData({
documents_required: documentsRequired,
claim_id: payload.claim_id,
wizardPlanStatus: 'documents_ready',
});
setIsWaiting(false);
setConnectionError(null);
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
return;
}
const wizardPayload = extractWizardPayload(payload);
const hasWizardPlan = Boolean(wizardPayload);
if (eventType?.includes('wizard') || hasWizardPlan) {
const wizardPlan = wizardPayload?.wizard_plan;
const answersPrefill = wizardPayload?.answers_prefill;
const coverageReport = wizardPayload?.coverage_report;
setResponseEvent({
event_type: 'consumer_complaint',
message: payload.message || 'План готов.',
data: { wizard_plan: wizardPlan, answers_prefill: answersPrefill, coverage_report: coverageReport },
});
const prefill = buildPrefillMap(answersPrefill);
setPlan(wizardPlan);
setPrefillMap(prefill);
setIsWaiting(false);
setConnectionError(null);
updateFormData({
wizardPlan: wizardPlan,
wizardPrefill: prefill,
wizardPrefillArray: answersPrefill,
wizardCoverageReport: coverageReport,
wizardPlanStatus: 'ready',
});
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
source.close();
eventSourceRef.current = null;
}
@@ -857,6 +935,11 @@ export default function StepWizardPlan({
parsed = null;
}
console.log('📥 Ответ n8n (wizard):', parsed);
if (parsed && typeof parsed === 'object') {
console.log('📥 Ключи ответа n8n:', Object.keys(parsed));
}
if (!response.ok) {
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
@@ -1437,7 +1520,6 @@ export default function StepWizardPlan({
})}
<Space style={{ marginTop: 24 }}>
<Button onClick={onPrev}> Назад</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Сохранить и продолжить
</Button>
@@ -1454,7 +1536,6 @@ export default function StepWizardPlan({
status="warning"
title="Нет session_id"
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);
}
@@ -1585,8 +1666,10 @@ export default function StepWizardPlan({
}
}, [currentDocIndex, documentsRequired.length, uploadedDocs, skippedDocs, findFirstUnprocessedDoc, updateFormData]);
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload');
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]);
const [selectedDocIndex, setSelectedDocIndex] = useState<number | null>(null); // Плиточный стиль: какая плитка открыта в модалке
const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы»
// Текущий документ для загрузки
const currentDoc = documentsRequired[currentDocIndex];
@@ -2158,148 +2241,288 @@ export default function StepWizardPlan({
}
};
const showDocumentsOnly = hasNewFlowDocs && documentsRequired.length > 0;
const stepContent = (
<>
{/* ✅ Экран «Загрузка документов» по дизайн-спецификации */}
{hasNewFlowDocs && !allDocsProcessed && documentsRequired.length > 0 ? (
<div style={{ background: '#f5f7fb', margin: '-1px -1px 0', borderRadius: '16px 16px 0 0', overflow: 'hidden', minHeight: 360 }}>
{/* Шапка: градиент синий, заголовок */}
<div style={{ background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', padding: '16px 16px', textAlign: 'center' }}>
<Typography.Text strong style={{ color: '#fff', fontSize: 18 }}>Загрузка документов</Typography.Text>
</div>
<div style={{ padding: '16px 16px 100px' }}>
<Row gutter={[12, 12]} style={{ marginBottom: 80 }}>
{documentsRequired.map((doc: any, index: number) => {
const docId = doc.id || doc.name;
const isUploaded = uploadedDocs.includes(docId);
const isSkipped = skippedDocs.includes(docId);
const fileCount = (formData.documents_uploaded || []).filter((d: any) => (d.type || d.id) === docId).length;
const { Icon: DocIcon, color: docColor } = getDocTypeStyle(docId);
const isSelected = selectedDocIndex === index;
const status = isUploaded ? STATUS_UPLOADED : isSkipped ? STATUS_NOT_AVAILABLE : (doc.required ? STATUS_NEEDED : STATUS_OPTIONAL);
const StatusIcon = status.Icon;
const statusLabel = isUploaded ? (fileCount > 0 ? `${status.label} (${fileCount})` : status.label) : status.label;
const tileBg = isUploaded ? '#ECFDF5' : isSkipped ? '#F3F4F6' : '#FFFBEB';
const tileBorder = isSelected ? '#2563eb' : isUploaded ? '#22C55E' : isSkipped ? '#9ca3af' : '#F59E0B';
return (
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Button onClick={onPrev}> Назад</Button>
{plan && !hasNewFlowDocs && (
<Button type="link" onClick={handleRefreshPlan}>
Обновить рекомендации
</Button>
)}
</div>
<Col xs={12} key={docId}>
<Card
hoverable
bordered
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
borderRadius: 18,
border: `1px solid ${tileBorder}`,
background: tileBg,
boxShadow: isSelected ? '0 0 0 2px rgba(37,99,235,0.25)' : '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
onClick={() => { setCurrentDocIndex(index); setDocChoice(isSkipped ? 'none' : 'upload'); setCurrentUploadedFiles([]); setSelectedDocIndex(index); }}
>
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
{hasNewFlowDocs && !allDocsProcessed && currentDocIndex < documentsRequired.length && currentDoc ? (
<div style={{ padding: '24px 0' }}>
{/* Прогресс */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
<Text type="secondary">{Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}% завершено</Text>
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${docColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: docColor }}>
<DocIcon size={28} strokeWidth={1.8} />
</div>
<Progress
percent={Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}
showInfo={false}
strokeColor="#595959"
/>
</div>
{/* Заголовок документа */}
<Title level={4} style={{ marginBottom: 8 }}>
📄 {currentDoc.name}
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
</Title>
{currentDoc.hints && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
{currentDoc.hints}
</Paragraph>
)}
{/* Радио-кнопки выбора */}
<Radio.Group
value={docChoice}
onChange={(e) => {
setDocChoice(e.target.value);
if (e.target.value === 'none') {
setCurrentUploadedFiles([]);
}
}}
style={{ marginBottom: 16, display: 'block' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="upload" style={{ fontSize: 16 }}>
📎 Загрузить документ
</Radio>
<Radio value="none" style={{ fontSize: 16 }}>
У меня нет этого документа
</Radio>
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{doc.name}</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Space size={6} style={{ fontSize: 12, color: status.color }}>
<StatusIcon size={14} strokeWidth={2} />
<span>{statusLabel}</span>
</Space>
</Radio.Group>
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
{docChoice === 'upload' && (
<Dragger
multiple={true}
beforeUpload={() => false}
fileList={currentUploadedFiles}
onChange={({ fileList }) => handleFilesChange(fileList)}
onRemove={(file) => {
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
return true;
}}
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
disabled={submitting}
style={{ marginBottom: 24 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
</p>
<p className="ant-upload-text">
Перетащите файлы или нажмите для выбора
</p>
<p className="ant-upload-hint">
📌 Можно загрузить несколько файлов (все страницы документа)
<br />
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
</p>
</Dragger>
)}
{/* Предупреждение если "нет документа" для важного */}
{docChoice === 'none' && currentDoc.required && (
<div style={{
padding: 12,
background: '#fff7e6',
border: '1px solid #ffd591',
borderRadius: 8,
marginBottom: 16
}}>
<Text type="warning">
Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
</Text>
{'subLabel' in status && isSkipped && <Text type="secondary" style={{ fontSize: 11 }}>{(status as { subLabel?: string }).subLabel}</Text>}
</div>
)}
{/* Кнопки */}
<Space style={{ marginTop: 16 }}>
<Button onClick={onPrev}> К списку заявок</Button>
</Card>
</Col>
);
})}
{/* Плитка: произвольные группы документов (название от пользователя при одной группе) */}
<Col xs={12} key="__custom_docs__">
<Card
hoverable
bordered
style={{
borderRadius: 18,
border: `1px solid #e5e7eb`,
background: '#fff',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
onClick={() => setCustomDocsModalOpen(true)}
>
{(() => {
const { Icon: CustomIcon, color: customColor } = getDocTypeStyle('__custom_docs__');
const StatusIcon = customFileBlocks.length > 0 ? STATUS_UPLOADED.Icon : CustomIcon;
const statusColor = customFileBlocks.length > 0 ? STATUS_UPLOADED.color : '#8c8c8c';
const hasGroups = customFileBlocks.length > 0;
const titleText = hasGroups && customFileBlocks.length === 1 && customFileBlocks[0].description?.trim()
? (customFileBlocks[0].description.trim().length > 25 ? customFileBlocks[0].description.trim().slice(0, 22) + '…' : customFileBlocks[0].description.trim())
: 'Свои документы';
return (
<>
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${customColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: customColor }}>
<CustomIcon size={28} strokeWidth={1.8} />
</div>
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{titleText}</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Space size={6} style={{ fontSize: 12, color: statusColor }}>
<StatusIcon size={14} strokeWidth={2} />
<span>{hasGroups ? `Загружено (${customFileBlocks.length} ${customFileBlocks.length === 1 ? 'группа' : 'группы'})` : 'Добавить'}</span>
</Space>
</div>
</>
);
})()}
</Card>
</Col>
{/* Плитка «Добавить ещё группу» — серая до загрузки, цветная после */}
<Col xs={12} key="__custom_docs_add__">
<Card
hoverable
bordered
style={{
borderRadius: 18,
border: '1px solid #e5e7eb',
background: customFileBlocks.length > 0 ? '#f5f3ff' : '#fafafa',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 8 }}
onClick={() => setCustomDocsModalOpen(true)}
>
{(() => {
const { Icon: AddIcon, color: addColor } = getDocTypeStyle('__custom_docs__');
const isColored = customFileBlocks.length > 0;
const iconColor = isColored ? addColor : '#9ca3af';
const bgColor = isColored ? `${addColor}18` : '#f3f4f6';
return (
<>
<div style={{ width: 48, height: 48, borderRadius: 14, background: bgColor, display: 'flex', alignItems: 'center', justifyContent: 'center', color: iconColor }}>
<AddIcon size={26} strokeWidth={1.8} />
</div>
<Text style={{ fontSize: 13, color: isColored ? '#374151' : '#9ca3af', lineHeight: 1.3 }}>
Добавить ещё группу
</Text>
</>
);
})()}
</Card>
</Col>
</Row>
{/* Кнопка «Отправить» внизу экрана с плитками (bottom: 90px — выше футера) */}
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '24px 0 0', marginTop: 8 }}>
<Button
type="primary"
onClick={handleDocContinue}
disabled={!canContinue || submitting}
loading={submitting}
size="large"
block
onClick={handleAllDocsComplete}
disabled={!allDocsProcessed}
title={!allDocsProcessed ? `Сначала отметьте все документы (${uploadedDocs.length + skippedDocs.length}/${documentsRequired.length})` : undefined}
style={{
background: allDocsProcessed ? 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' : undefined,
border: 'none',
borderRadius: 28,
height: 52,
fontSize: 16,
fontWeight: 600,
}}
>
{submitting ? 'Загружаем...' : 'Продолжить →'}
Отправить
</Button>
</div>
</div>
<Modal
title={currentDoc ? `📄 ${currentDoc.name}` : 'Документ'}
open={selectedDocIndex !== null && !!documentsRequired[selectedDocIndex]}
onCancel={() => setSelectedDocIndex(null)}
footer={null}
width={520}
destroyOnClose
>
{selectedDocIndex !== null && documentsRequired[selectedDocIndex] && (() => {
const doc = documentsRequired[selectedDocIndex];
return (
<div style={{ padding: '8px 0' }}>
{doc.hints && <Paragraph type="secondary" style={{ marginBottom: 16 }}>{doc.hints}</Paragraph>}
<Radio.Group value={docChoice} onChange={(e) => { setDocChoice(e.target.value); if (e.target.value === 'none') setCurrentUploadedFiles([]); }} style={{ marginBottom: 16, display: 'block' }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="upload" style={{ fontSize: 15 }}>📎 Загрузить документ</Radio>
<Radio value="none" style={{ fontSize: 15 }}> У меня нет этого документа</Radio>
</Space>
{/* Уже загруженные */}
{uploadedDocs.length > 0 && (
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
<Text strong> Загружено:</Text>
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
{/* Убираем дубликаты и используем уникальные ключи */}
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
const doc = documentsRequired.find((d: any) => d.id === docId);
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
})}
</ul>
</Radio.Group>
{docChoice === 'upload' && (
<Dragger multiple beforeUpload={() => false} fileList={currentUploadedFiles} onChange={({ fileList }) => handleFilesChange(fileList)} onRemove={(file) => { setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid)); return true; }} accept={doc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'} disabled={submitting} style={{ marginBottom: 16 }}>
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 32 }} /></p>
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
<p className="ant-upload-hint">Форматы: {doc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ)</p>
</Dragger>
)}
{docChoice === 'none' && doc.required && (
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, marginBottom: 16 }}>
<Text type="warning"> Документ важен для рассмотрения. Постарайтесь найти его позже.</Text>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setSelectedDocIndex(null)}>Отмена</Button>
<Button type="primary" onClick={async () => { await handleDocContinue(); setSelectedDocIndex(null); }} disabled={!canContinue || submitting} loading={submitting}>{submitting ? 'Загружаем...' : 'Готово'}</Button>
</div>
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? (
</div>
);
})()}
</Modal>
{/* Модалка «Свои документы» — произвольные группы документов */}
<Modal
title="Дополнительные документы"
open={customDocsModalOpen}
onCancel={() => setCustomDocsModalOpen(false)}
footer={null}
width={560}
destroyOnClose={false}
>
<div style={{ padding: '8px 0' }}>
{customFileBlocks.length === 0 && (
<div style={{ marginBottom: 16, padding: 16, background: '#fafafa', borderRadius: 8 }}>
<Paragraph style={{ marginBottom: 8 }}>
<Text strong>Есть ещё документы, которые могут помочь?</Text>
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Добавьте группу документов с названием (например: «Переписка в мессенджере», «Скриншоты»).
В каждой группе своё название и файлы.
</Paragraph>
<Button type="dashed" icon={<PlusOutlined />} onClick={addCustomBlock} block size="large">
Добавить группу документов
</Button>
</div>
)}
<Space direction="vertical" style={{ width: '100%' }}>
{customFileBlocks.map((block, idx) => (
<Card
key={block.id}
size="small"
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
title={<span><FileTextOutlined style={{ color: '#595959', marginRight: 8 }} />Группа документов #{idx + 1}</span>}
extra={<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>Удалить</Button>}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Название группы <Text type="danger">*</Text></Text>
<Input
placeholder="Например: Переписка в WhatsApp с менеджером"
value={block.description}
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
maxLength={500}
showCount
style={{ marginBottom: 12 }}
status={block.files.length > 0 && !block.description?.trim() ? 'error' : ''}
/>
{block.files.length > 0 && !block.description?.trim() && (
<Text type="danger" style={{ fontSize: 12 }}>Укажите название группы</Text>
)}
</div>
<div>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Категория (необязательно)</Text>
<Select
value={block.category}
placeholder="Выберите или оставьте пустым"
onChange={(value) => updateCustomBlock(block.id, { category: value })}
allowClear
style={{ width: '100%' }}
>
{customCategoryOptions.map((opt) => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</div>
<Dragger
multiple
beforeUpload={() => false}
fileList={block.files}
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
style={{ marginTop: 8 }}
>
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 24 }} /></p>
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
</Dragger>
</Space>
</Card>
))}
</Space>
{customFileBlocks.length > 0 && (
<Button type="dashed" onClick={addCustomBlock} icon={<PlusOutlined />} block style={{ marginTop: 12 }}>
Добавить ещё группу
</Button>
)}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Button type="primary" onClick={() => setCustomDocsModalOpen(false)}>Готово</Button>
</div>
</div>
</Modal>
</div>
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length && documentsRequired.length > 0 ? (
<div style={{ padding: '24px 0', textAlign: 'center' }}>
<Text type="warning">
Ошибка: индекс документа ({currentDocIndex}) выходит за границы массива ({documentsRequired.length}).
Ошибка: индекс документа ({currentDocIndex}) выходит за границы ({documentsRequired.length}).
<br />
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
</Text>
@@ -2391,15 +2614,52 @@ export default function StepWizardPlan({
{/* ✅ Дополнительные документы */}
{renderCustomUploads()}
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
Продолжить
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '20px 0', background: '#f5f7fb', marginTop: 24 }}>
<Button
type="primary"
size="large"
block
onClick={handleAllDocsComplete}
style={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
border: 'none',
borderRadius: 28,
height: 52,
fontSize: 16,
fontWeight: 600,
}}
>
Отправить
</Button>
</div>
</>
);
})()}
</>
);
return showDocumentsOnly ? (
<div style={{ marginTop: 0 }}>{stepContent}</div>
) : (
<div style={{ marginTop: 24 }}>
{plan && !hasNewFlowDocs && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Button type="link" onClick={handleRefreshPlan}>
Обновить рекомендации
</Button>
</div>
)}
<Card
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
}}
>
{stepContent}
{(
<>
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
{!hasNewFlowDocs && isWaiting && !outOfScopeData && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
@@ -2433,8 +2693,104 @@ export default function StepWizardPlan({
</div>
)}
{/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
{outOfScopeData && (
{/* Единое окошко: тип + текст, цвет по event_type */}
{responseEvent && (
<div style={{ textAlign: 'center', padding: 24 }}>
<div style={{
background: DISPLAY_STYLE[responseEvent.event_type].bg,
border: `1px solid ${DISPLAY_STYLE[responseEvent.event_type].border}`,
borderRadius: 12,
padding: 24,
maxWidth: 600,
margin: '0 auto',
}}>
<Title level={4} style={{ marginBottom: 16 }}>
{responseEvent.event_type === 'trash_message' && '❌ '}
{responseEvent.event_type === 'out_of_scope' && '⚠️ '}
{responseEvent.event_type === 'consumer_consultation' && ' '}
{responseEvent.event_type === 'consumer_complaint' && '✅ '}
{DISPLAY_STYLE[responseEvent.event_type].title}
</Title>
<Paragraph style={{ fontSize: 16, marginBottom: 16 }}>
{responseEvent.message}
</Paragraph>
{responseEvent.suggested_actions && responseEvent.suggested_actions.length > 0 && (
<div style={{ marginTop: 24 }}>
<Paragraph strong style={{ marginBottom: 12 }}>Что можно сделать:</Paragraph>
<Space direction="vertical" style={{ width: '100%' }}>
{responseEvent.suggested_actions.map((action: any, index: number) => (
<Card key={index} size="small" style={{ textAlign: 'left', background: '#fafafa' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{action.title}</div>
<div style={{ color: '#666', fontSize: 14 }}>{action.description}</div>
{action.actionType === 'external_link' && action.url && (
<a href={action.url} target="_blank" rel="noopener noreferrer" style={{ marginTop: 8, display: 'inline-block' }}>
{action.urlText || 'Перейти →'}
</a>
)}
{action.actionType === 'contact_support' && (
<Button
type="link"
style={{ marginTop: 8, padding: 0 }}
onClick={async () => {
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
if (!sessionToken) {
message.error('Сессия не найдена. Войдите снова.');
return;
}
try {
message.loading('Отправляем запрос в поддержку...', 0);
const fd = new FormData();
fd.append('message', responseEvent.message || '');
fd.append('source', 'complaint_card');
fd.append('session_token', sessionToken);
if (formData.claim_id) fd.append('claim_id', formData.claim_id);
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
message.destroy();
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
setTimeout(() => window.location.reload(), 2000);
} catch (error) {
message.destroy();
message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.');
}
}}
>
Связаться с поддержкой
</Button>
)}
</Card>
))}
</Space>
</div>
)}
{(responseEvent.event_type === 'trash_message' || responseEvent.event_type === 'out_of_scope') && (
<div style={{ marginTop: 24 }}>
<Button
type="primary"
onClick={() => {
setResponseEvent(null);
setOutOfScopeData(null);
if (onNewClaim) onNewClaim();
else {
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
window.history.pushState({}, '', '/new');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}}
>
Новое обращение
</Button>
</div>
)}
</div>
</div>
)}
{/* OUT OF SCOPE (старый формат, если пришло без event_type/message) */}
{!responseEvent && outOfScopeData && (
<div style={{ textAlign: 'center', padding: 24 }}>
<div style={{
background: '#fff7e6',
@@ -2485,33 +2841,29 @@ export default function StepWizardPlan({
type="link"
style={{ marginTop: 8, padding: 0 }}
onClick={async () => {
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
if (!sessionToken) {
message.error('Сессия не найдена. Войдите снова.');
return;
}
try {
message.loading('Отправляем запрос в поддержку...', 0);
await fetch('https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: formData.session_id,
phone: formData.phone,
email: formData.email,
unified_id: formData.unified_id,
ticket_number: outOfScopeData.ticket_number,
ticket: outOfScopeData.ticket,
reason: outOfScopeData.reason,
message: outOfScopeData.message,
action: 'contact_support',
timestamp: new Date().toISOString(),
}),
});
const fd = new FormData();
fd.append('message', outOfScopeData.message || outOfScopeData.reason || '');
fd.append('source', 'complaint_card');
fd.append('session_token', sessionToken);
if (formData.claim_id) fd.append('claim_id', formData.claim_id);
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
message.destroy();
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
// Возвращаемся на главную через перезагрузку
setTimeout(() => {
window.location.reload();
}, 2000);
setTimeout(() => window.location.reload(), 2000);
} catch (error) {
message.destroy();
message.error('Не удалось отправить запрос. Попробуйте позже.');
message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.');
}
}}
>
@@ -2525,17 +2877,15 @@ export default function StepWizardPlan({
)}
<div style={{ marginTop: 24 }}>
<Button onClick={onPrev} style={{ marginRight: 12 }}>
Изменить описание
</Button>
<Button type="primary" onClick={() => {
// Сбрасываем состояние и возвращаемся на первый экран
updateFormData({
wizardPlan: null,
wizardPlanStatus: null,
problemDescription: '',
});
window.location.href = '/';
setOutOfScopeData(null);
if (onNewClaim) {
onNewClaim(); // переход на форму «Описание проблемы», без дашборда
} else {
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
window.history.pushState({}, '', '/new');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}}>
Новое обращение
</Button>
@@ -2614,6 +2964,8 @@ export default function StepWizardPlan({
{renderQuestions()}
</div>
)}
</>
)}
</Card>
</div>
);

View File

@@ -0,0 +1,44 @@
/**
* Маппинг типов документов и статусов для экрана «Загрузка документов».
* Спецификация: дизайн «Документы кейса», Lucide-иконки.
*/
import {
FileSignature,
Receipt,
ClipboardList,
MessagesSquare,
FileWarning,
FolderOpen,
FolderPlus,
FileText,
CheckCircle2,
AlertTriangle,
Clock3,
Ban,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
export const DOC_TYPE_MAP: Record<string, { Icon: LucideIcon; color: string }> = {
contract: { Icon: FileSignature, color: '#1890ff' },
payment: { Icon: Receipt, color: '#52c41a' },
receipt: { Icon: Receipt, color: '#52c41a' },
cheque: { Icon: Receipt, color: '#52c41a' },
correspondence: { Icon: MessagesSquare, color: '#722ed1' },
acts: { Icon: ClipboardList, color: '#fa8c16' },
claim: { Icon: FileWarning, color: '#ff4d4f' },
other: { Icon: FolderOpen, color: '#595959' },
/** Плитка «Свои документы» — произвольные группы документов */
__custom_docs__: { Icon: FolderPlus, color: '#722ed1' },
};
export function getDocTypeStyle(docId: string): { Icon: LucideIcon; color: string } {
const key = (docId || '').toLowerCase().replace(/\s+/g, '_');
return DOC_TYPE_MAP[key] ?? { Icon: FileText, color: '#1890ff' };
}
/** Цвета и иконки статусов по спецификации */
export const STATUS_UPLOADED = { Icon: CheckCircle2, color: '#22C55E', label: 'Загружено' };
export const STATUS_NEEDED = { Icon: AlertTriangle, color: '#F59E0B', label: 'Нужно' };
export const STATUS_EXPECTED = { Icon: Clock3, color: '#F59E0B', label: 'Ожидаем завтра' };
export const STATUS_NOT_AVAILABLE = { Icon: Ban, color: '#8c8c8c', label: 'Не будет', subLabel: 'Утеряно' };
export const STATUS_OPTIONAL = { Icon: Clock3, color: '#8c8c8c', label: 'По желанию' };

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