Compare commits

..

15 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
70 changed files with 9264 additions and 964 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.
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».

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. Оставлены для истории/других окружений.

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,
)

View File

@@ -14,13 +14,10 @@ router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
@router.get("/nspk")
async def get_nspk_banks():
"""
Получить список банков СБП из внешнего API
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
Получить список банков из внешнего 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:
# URL внешнего API
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(external_api_url)

View File

@@ -14,6 +14,7 @@ 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
@@ -23,7 +24,10 @@ 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")
@@ -59,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]},
@@ -121,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"},
)
@@ -373,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 []
@@ -418,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,
@@ -445,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)
@@ -658,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)
@@ -868,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,
@@ -944,12 +983,44 @@ async def load_wizard_data(claim_id: str):
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
# Актуальный 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():
"""
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
"""
try:
if not settings.n8n_description_webhook:
description_webhook_url = _get_description_webhook_url()
if not description_webhook_url:
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
return
@@ -980,7 +1051,7 @@ async def _send_buffered_messages_to_webhook():
]
response = await client.post(
settings.n8n_description_webhook,
description_webhook_url,
json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
)
@@ -1041,27 +1112,53 @@ async def publish_ticket_form_description(
background_tasks: BackgroundTasks
):
"""
Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub)
Отправляет описание проблемы в 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:
if not settings.n8n_description_webhook:
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"
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(),
}
@@ -1074,13 +1171,11 @@ async def publish_ticket_form_description(
]
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,
},
@@ -1096,23 +1191,44 @@ async def publish_ticket_form_description(
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
extra={"session_id": payload.session_id}
)
# #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(
settings.n8n_description_webhook,
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(
f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
extra={
"session_id": payload.session_id,
"status_code": response.status_code,
}
"✅ Описание успешно отправлено в 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},
)
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,

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

@@ -21,6 +21,7 @@ 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")
@@ -286,6 +287,54 @@ async def proxy_telegram_auth(request: Request):
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(

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

@@ -31,11 +31,12 @@ class TelegramAuthRequest(BaseModel):
class TelegramAuthResponse(BaseModel):
success: bool
session_token: str
unified_id: 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
def _generate_session_token() -> str:
@@ -114,15 +115,35 @@ async def telegram_auth(request: TelegramAuthRequest):
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
# Ожидаем от n8n как минимум unified_id
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
has_drafts = n8n_data.get("has_drafts")
# Логируем сырой ответ 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.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
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 телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
@@ -132,6 +153,7 @@ async def telegram_auth(request: TelegramAuthRequest):
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
chat_id=str(telegram_user_id) if telegram_user_id else None,
)
try:

View File

@@ -2,6 +2,7 @@
Конфигурация приложения
"""
import os
import json
from pathlib import Path
from pydantic_settings import BaseSettings
from typing import List, Optional
@@ -62,20 +63,33 @@ 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
redis_password: str = "CRM_Redis_Pass_2025_Secure!"
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
@@ -125,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)
@@ -184,14 +206,72 @@ class Settings(BaseSettings):
n8n_file_upload_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.pro/webhook/aiform_description" # Webhook для обработки описания проблемы
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
# ============================================

View File

@@ -2,10 +2,16 @@
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
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
@@ -13,14 +19,97 @@ 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, banks, telegram_auth
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,12 +127,23 @@ 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
@@ -68,6 +168,14 @@ async def lifespan(app: FastAPI):
s3_service.connect()
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!")
@@ -75,9 +183,18 @@ async def lifespan(app: FastAPI):
# 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()
@@ -107,6 +224,30 @@ 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)
app.include_router(claims.router)
@@ -119,6 +260,13 @@ 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("/")
@@ -217,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

@@ -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 'Когда пользователь последний раз «прочитал» тред (открыл чат)';

View File

@@ -7,12 +7,12 @@ version: '3.8'
services:
ticket_form_frontend_prod:
container_name: ticket_form_frontend_prod
container_name: miniapp_front
build:
context: ./frontend
dockerfile: Dockerfile.prod
ports:
- "5176:3000"
- "4176:3000"
environment:
- VITE_API_URL=https://aiform.clientright.ru
- NODE_ENV=production
@@ -28,7 +28,7 @@ services:
retries: 3
ticket_form_backend_prod:
container_name: ticket_form_backend_prod
container_name: miniapp_back
build:
context: ./backend
dockerfile: Dockerfile
@@ -45,7 +45,7 @@ services:
labels:
- "environment=production"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
test: ["CMD", "curl", "-f", "http://localhost:4200/health"]
interval: 30s
timeout: 10s
retries: 3

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,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,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,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,9 +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>
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
<!-- Подключаем только скрипт текущей платформы, иначе в 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

@@ -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 (
<div className="App">
<ClaimForm />
</div>
)
<DraftsProvider>
<div className="App">
{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

@@ -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,14 +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; // ✅ Флаг Telegram Mini App
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;
}
// === Конфиг статусов ===
@@ -163,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 />,
@@ -172,78 +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)
let 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,
};
});
// ✅ В Telegram Mini App скрываем заявки "В работе"
if (isTelegramMiniApp) {
processedDrafts = processedDrafts.filter((draft: Draft) => draft.status_code !== 'in_work');
console.log('🔍 Telegram Mini App: заявки "В работе" скрыты');
}
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 {
@@ -257,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('Не удалось удалить черновик');
@@ -271,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;
};
@@ -284,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;
@@ -333,273 +508,229 @@ export default function StepDraftSelection({
);
};
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
<Card
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fff',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
📋 Ваши заявки
// Экран полного описания черновика (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={{ 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>
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
Выберите заявку для продолжения или создайте новую.
</Paragraph>
</div>
{/* Кнопка создания новой заявки - всегда вверху */}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewClaim}
size="large"
style={{ width: '100%' }}
>
Создать новую заявку
</Button>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
<div
style={{
padding: '16px',
background: '#f8fafc',
borderRadius: 8,
border: '1px solid #e2e8f0',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: 80,
maxHeight: 320,
overflow: 'auto',
}}
>
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
</div>
) : drafts.length === 0 ? (
<Empty
description="У вас пока нет незавершенных заявок"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<>
<List
dataSource={drafts}
renderItem={(draft) => {
const config = getStatusConfig(draft);
const docsProgress = getDocsProgress(draft);
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)',
}}
>
<List.Item.Meta
avatar={
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: draft.is_legacy ? '#faad14' : '#595959',
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>
</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 }}>
{config.description}
</Text>
{/* Кнопки действий */}
<div className="draft-actions" style={{
display: 'flex',
gap: 12,
marginTop: 12,
paddingTop: 12,
borderTop: '1px solid #f0f0f0',
}}>
{getActionButton(draft)}
{/* Скрываем кнопку "Удалить" для заявок "В работе" */}
{draft.status_code !== 'in_work' && (
<Popconfirm
title="Удалить заявку?"
description="Это действие нельзя отменить"
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
okText="Да, удалить"
cancelText="Отмена"
>
<Button
danger
icon={<DeleteOutlined />}
loading={deletingId === (draft.claim_id || draft.id)}
disabled={deletingId === (draft.claim_id || draft.id)}
>
Удалить
</Button>
</Popconfirm>
)}
</div>
</Space>
}
/>
</List.Item>
);
}}
/>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{selectedDraft?.is_legacy && onRestartDraft ? (
<Button
type="link"
type="primary"
size="large"
icon={<ReloadOutlined />}
onClick={loadDrafts}
loading={loading}
onClick={() => {
onRestartDraft(draftId, selectedDraft.problem_description || '');
closeDraftDetail();
}}
>
Обновить список
Начать заново
</Button>
</div>
</>
)}
) : (
<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>
) : filteredDrafts.length === 0 ? (
<Empty
description={categoryFilter === 'all' ? 'У вас пока нет обращений' : `Нет обращений в категории «${CATEGORY_LABELS[categoryFilter]}»`}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{filteredDrafts.map((draft) => {
const config = getStatusConfig(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 (
<Card
key={draft.claim_id || draft.id}
className="draft-list-card"
hoverable
style={{ background: '#fff', cursor: 'pointer' }}
bodyStyle={{ padding: '14px 16px' }}
onClick={() => openDraftDetail(draft)}
>
<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: dotColor,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 13, color: dotColor }}>{config.label}</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.4 }}>
{config.description}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(draft.updated_at)}
</Text>
<Button
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', padding: '8px 0' }}>
<Button
type="link"
icon={<ReloadOutlined />}
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';
@@ -51,6 +52,7 @@ interface Props {
onNext: () => void;
onPrev: () => void;
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
onNewClaim?: () => void; // ✅ Переход на форму нового обращения (шаг «Описание»)
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
@@ -93,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;
@@ -112,6 +129,7 @@ export default function StepWizardPlan({
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');
@@ -122,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)
@@ -464,83 +484,143 @@ 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,
// Не показывать служебное сообщение подключения SSE как ответ пользователю
if (payload.status === 'connected' && payload.message === 'Подключено к событиям') {
return;
}
// Единый формат от бэка: 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);
setOutOfScopeData(payload); // Сохраняем полные данные
setConnectionError(null); // Не используем connectionError
setConnectionError(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// 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,
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);
updateFormData({
wizardPlan: wizardPlan,
wizardPrefill: prefill,
wizardPrefillArray: answersPrefill,
wizardCoverageReport: coverageReport,
wizardPlanStatus: 'ready',
});
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 || [];
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
session_id: sessionId,
documents_count: documentsRequired.length,
documents: documentsRequired.map((d: any) => d.name),
setResponseEvent({
event_type: 'consumer_complaint',
message: `Подготовлен список документов: ${documentsRequired.length} шт.`,
data: { documents_required: documentsRequired, claim_id: payload.claim_id },
});
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', // Новый статус
wizardPlanStatus: 'documents_ready',
});
setIsWaiting(false);
setConnectionError(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Пока показываем alert для теста, потом переход к StepDocumentsNew
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
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', '✨ Получен план вопросов', {
session_id: sessionId,
questions: wizardPlan?.questions?.length || 0,
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,
@@ -548,11 +628,7 @@ export default function StepWizardPlan({
wizardCoverageReport: coverageReport,
wizardPlanStatus: 'ready',
});
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
source.close();
eventSourceRef.current = null;
}
@@ -859,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', {
@@ -1439,7 +1520,6 @@ export default function StepWizardPlan({
})}
<Space style={{ marginTop: 24 }}>
<Button onClick={onPrev}> Назад</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Сохранить и продолжить
</Button>
@@ -1456,7 +1536,6 @@ export default function StepWizardPlan({
status="warning"
title="Нет session_id"
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);
}
@@ -1587,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];
@@ -2160,148 +2241,288 @@ export default function StepWizardPlan({
}
};
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>
<Card
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
}}
>
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
{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>
<Progress
percent={Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}
showInfo={false}
strokeColor="#595959"
/>
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>
{/* Заголовок документа */}
<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' }}
<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 (
<Col xs={12} key={docId}>
<Card
hoverable
bordered
style={{
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); }}
>
<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>
<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>
{'subLabel' in status && isSkipped && <Text type="secondary" style={{ fontSize: 11 }}>{(status as { subLabel?: string }).subLabel}</Text>}
</div>
</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"
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,
}}
>
Отправить
</Button>
</div>
</div>
<Modal
title={currentDoc ? `📄 ${currentDoc.name}` : 'Документ'}
open={selectedDocIndex !== null && !!documentsRequired[selectedDocIndex]}
onCancel={() => setSelectedDocIndex(null)}
footer={null}
width={520}
destroyOnClose
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="upload" style={{ fontSize: 16 }}>
📎 Загрузить документ
</Radio>
<Radio value="none" style={{ fontSize: 16 }}>
У меня нет этого документа
</Radio>
</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>
{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>
</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>
</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>
)}
{/* Кнопки */}
<Space style={{ marginTop: 16 }}>
<Button onClick={backToDraftsList || onPrev}> К списку заявок</Button>
<Button
type="primary"
onClick={handleDocContinue}
disabled={!canContinue || submitting}
loading={submitting}
>
{submitting ? 'Загружаем...' : 'Продолжить →'}
</Button>
</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>
</div>
)}
</Modal>
</div>
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? (
) : 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>
@@ -2393,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' }}>
@@ -2435,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',
@@ -2487,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 : 'Не удалось отправить запрос. Попробуйте позже.');
}
}}
>
@@ -2527,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>
@@ -2616,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: 'По желанию' };

View File

@@ -0,0 +1,50 @@
/**
* Общий список обращений (черновики + CRM), загруженный при открытии «Мои обращения».
* Используется на странице «Консультации» — показываем только тикеты из этого списка, без отдельного запроса.
*/
import { createContext, useContext, useState, type ReactNode } from 'react';
export interface DraftItem {
id?: string;
claim_id?: string | null;
type_code?: string;
payload?: Record<string, unknown>;
status_code?: string;
problem_title?: string;
problem_description?: string;
created_at?: string | null;
updated_at?: string | null;
[key: string]: unknown;
}
type SetDrafts = (drafts: DraftItem[] | ((prev: DraftItem[]) => DraftItem[])) => void;
const DraftsContext = createContext<{
drafts: DraftItem[];
setDrafts: SetDrafts;
} | null>(null);
export function DraftsProvider({ children }: { children: ReactNode }) {
const [drafts, setDrafts] = useState<DraftItem[]>([]);
return (
<DraftsContext.Provider value={{ drafts, setDrafts }}>
{children}
</DraftsContext.Provider>
);
}
export function useDrafts() {
const ctx = useContext(DraftsContext);
return ctx ?? { drafts: [], setDrafts: () => {} };
}
/** Только тикеты (консультации) из drafts */
export function useConsultationItems(): DraftItem[] {
const { drafts } = useDrafts();
return drafts.filter(
(d) =>
d.type_code === 'consultation' ||
(d.payload && (d.payload.ticketid != null || d.payload.ticket_no != null))
);
}

View File

@@ -4,15 +4,26 @@
box-sizing: border-box;
}
html {
overflow-x: hidden;
max-width: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #ffffff;
overflow-x: hidden;
max-width: 100%;
position: relative;
overflow-wrap: break-word;
}
#root {
min-height: 100vh;
overflow-x: hidden;
max-width: 100%;
}

View File

@@ -2,6 +2,71 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { miniappLog, miniappSendLogs } from './utils/miniappLogger'
// #region agent log (build tag)
// В прод-сборке это будет URL текущего JS-бандла (/assets/index-XXXX.js)
(window as any).__MINIAPP_BUILD__ = (import.meta as any).url;
// #endregion agent log
// Логирование при загрузке — по нему видно, какой фронт отдаётся и куда идут запросы
const bootLog = {
ts: new Date().toISOString(),
href: window.location.href,
origin: window.location.origin,
pathname: window.location.pathname,
host: window.location.host,
search: window.location.search,
hash: window.location.hash,
marker: 'MINIAPP_AIFORM_PROD',
// #region agent log (bundle identity)
moduleUrl: (import.meta as any).url,
scriptSrc:
document
.querySelector('script[type="module"][src*="/assets/index-"]')
?.getAttribute('src') || undefined,
build: (window as any).__MINIAPP_BUILD__,
// #endregion agent log
};
console.log('[MINIAPP] Boot', bootLog);
miniappLog('boot', bootLog);
// Логирование всех запросов к /api — куда реально уходят запросы (относительный URL = текущий origin)
const _fetch = window.fetch;
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api') || url.startsWith('/api')) {
const full = url.startsWith('http') ? url : window.location.origin + (url.startsWith('/') ? url : '/' + url);
console.log('[MINIAPP] API request', { url, full, method: init?.method || 'GET' });
miniappLog('api_request', { url, full, method: init?.method || 'GET' });
}
return _fetch.apply(this, arguments as any);
};
// Ловим JS-ошибки и отправляем дамп на бэк
window.addEventListener('error', (e) => {
const ev = e as ErrorEvent;
miniappLog('window_error', {
message: ev.message,
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
name: (ev.error && (ev.error as any).name) || undefined,
stack: (ev.error && (ev.error as any).stack) || undefined,
});
void miniappSendLogs('window_error');
});
window.addEventListener('unhandledrejection', (e) => {
const ev = e as PromiseRejectionEvent;
const reason = ev.reason;
miniappLog('unhandledrejection', {
reason: reason ? String(reason) : '(empty)',
name: reason && (reason as any).name ? String((reason as any).name) : undefined,
stack: reason && (reason as any).stack ? String((reason as any).stack) : undefined,
});
void miniappSendLogs('unhandledrejection');
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -1,7 +1,7 @@
/* ========== ВЕБ (дефолт): как в aiform_dev ========== */
.claim-form-container {
min-height: 100vh;
padding: 40px 20px;
padding: 40px 0;
background: #ffffff;
display: flex;
justify-content: center;
@@ -9,11 +9,15 @@
}
.claim-form-card {
max-width: 800px;
max-width: 100%;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
border: 1px solid #d9d9d9;
box-shadow: none;
border-radius: 0;
border: none;
}
.claim-form-card .ant-card-body {
padding: 16px 0;
}
.claim-form-card .ant-card-head {
@@ -35,12 +39,13 @@
.steps-content {
min-height: 400px;
padding: 20px;
padding: 20px 0;
overflow-y: auto;
}
@media (max-width: 768px) {
.claim-form-container {
padding: 20px 10px;
padding: 20px 0;
}
.claim-form-card {
@@ -48,7 +53,7 @@
}
.steps-content {
padding: 10px;
padding: 10px 0;
}
}
@@ -56,7 +61,7 @@
.claim-form-container.telegram-mini-app {
min-height: 100vh;
min-height: 100dvh;
padding: 12px 10px max(16px, env(safe-area-inset-bottom));
padding: 12px 0 max(16px, env(safe-area-inset-bottom));
align-items: flex-start;
justify-content: flex-start;
}
@@ -64,8 +69,8 @@
.claim-form-container.telegram-mini-app .claim-form-card {
max-width: 100%;
box-shadow: none;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0;
border: none;
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {
@@ -81,7 +86,7 @@
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-body {
padding: 12px;
padding: 12px 0;
}
.claim-form-container.telegram-mini-app .steps {
@@ -99,7 +104,7 @@
.claim-form-container.telegram-mini-app .steps-content {
min-height: 280px;
padding: 8px 4px 12px;
padding: 8px 0 12px;
}
.claim-form-container.telegram-mini-app .ant-btn {

View File

@@ -1,8 +1,9 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
import { Card, message, Row, Col, Spin, Button } from 'antd';
import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription';
// Step1Policy убран - старый ERV флоу
import StepComplaintsDashboard from '../components/form/StepComplaintsDashboard';
import StepDraftSelection from '../components/form/StepDraftSelection';
import StepWizardPlan from '../components/form/StepWizardPlan';
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
@@ -11,11 +12,11 @@ import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
import DebugPanel from '../components/DebugPanel';
// getDocumentsForEventType убран - старый ERV флоу
import './ClaimForm.css';
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
import { useDrafts } from '../context/DraftsContext';
// Используем относительные пути - Vite proxy перенаправит на backend
const { Step } = Steps;
/**
* Генерация UUID v4
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
@@ -81,22 +82,45 @@ interface FormData {
accountNumber?: string;
}
export default function ClaimForm() {
interface ClaimFormProps {
/** Открыта страница «Подать жалобу» (/new) — не показывать список черновиков */
forceNewClaim?: boolean;
/** Навигация по приложению (для перехода на /support и т.д.) */
onNavigate?: (path: string) => void;
}
function getInitialSessionToken(): string | undefined {
if (typeof sessionStorage !== 'undefined') {
const s = sessionStorage.getItem('session_token');
if (s && s.trim()) return s.trim();
}
if (typeof localStorage !== 'undefined') {
const s = localStorage.getItem('session_token');
if (s && s.trim()) return s.trim();
}
return undefined;
}
export default function ClaimForm({ forceNewClaim = false, onNavigate }: ClaimFormProps) {
// ✅ claim_id будет создан n8n в Step1Phone после SMS верификации
// Не генерируем его локально!
// session_id будет получен от n8n при создании контакта
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
// session_id: при заходе из мини-аппа берём сохранённый session_token, иначе — временный sess-xxx для веба
const fallbackSessionId = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const initialSessionToken = getInitialSessionToken();
const sessionIdRef = useRef(initialSessionToken || fallbackSessionId);
const autoLoadedClaimIdRef = useRef<string | null>(null);
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Защита от «ghost click» после навигации с /hello: игнорируем back первые ~1500мс
const barBackIgnoreUntilRef = useRef<number>(Date.now() + 1500);
const [currentStep, setCurrentStep] = useState(0);
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
const [formData, setFormData] = useState<FormData>({
voucher: '',
claim_id: undefined, // ✅ Будет заполнен n8n в Step1Phone
session_id: sessionIdRef.current,
session_id: initialSessionToken || sessionIdRef.current,
paymentMethod: 'sbp',
});
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
@@ -104,12 +128,84 @@ export default function ClaimForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [showDraftSelection, setShowDraftSelection] = useState(false);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
/** ID черновика, открытого для просмотра описания (состояние в родителе, чтобы не терять при пересчёте steps) */
const [draftDetailClaimId, setDraftDetailClaimId] = useState<string | null>(null);
/** Фильтр списка обращений при переходе с дашборда: по какой категории показывать (all = все) */
const [draftsListFilter, setDraftsListFilter] = useState<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected'>('all');
const [hasDrafts, setHasDrafts] = useState(false);
/** Список обращений — один раз грузим в родителе, передаём в дашборд и список (один запрос в n8n) */
const [draftsList, setDraftsList] = useState<any[]>([]);
const [draftsListLoading, setDraftsListLoading] = useState(false);
const { setDrafts } = useDrafts();
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
const [tgDebug, setTgDebug] = useState<string>('');
/** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
/** Заход через MAX Mini App. */
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
/** Платформа определена (TG/MAX/веб) — до этого шаг «Вход» не показываем, чтобы в MAX не мелькал экран телефона. */
const [platformChecked, setPlatformChecked] = useState(false);
const forceNewClaimRef = useRef(false);
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении.
// 1) По URL (TG iframe приходит с tgWebAppData/tgWebAppVersion). 2) По initData. 3) По наличию SDK (WebApp / Telegram.WebApp).
useEffect(() => {
const url = typeof window !== 'undefined' ? window.location.href || '' : '';
if (url.indexOf('tgWebAppData') !== -1 || url.indexOf('tgWebAppVersion') !== -1) {
setIsTelegramMiniApp(true);
setPlatformChecked(true);
return;
}
const detect = () => {
const tgInitData = (window as any).Telegram?.WebApp?.initData;
const maxInitData = (window as any).WebApp?.initData;
if (tgInitData && typeof tgInitData === 'string' && tgInitData.length > 0) {
setIsTelegramMiniApp(true);
setPlatformChecked(true);
return true;
}
if (maxInitData && typeof maxInitData === 'string' && maxInitData.length > 0) {
setIsMaxMiniApp(true);
setPlatformChecked(true);
return true;
}
// В MAX подключается только max-web-app.js → есть window.WebApp; в TG — telegram-web-app.js → есть Telegram.WebApp.
if (typeof (window as any).Telegram?.WebApp === 'object') {
setIsTelegramMiniApp(true);
setPlatformChecked(true);
return true;
}
if (typeof (window as any).WebApp === 'object') {
setIsMaxMiniApp(true);
setPlatformChecked(true);
return true;
}
return false;
};
if (detect()) return;
const interval = setInterval(() => {
if (detect()) clearInterval(interval);
}, 50);
const timeout = setTimeout(() => {
clearInterval(interval);
setPlatformChecked(true);
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, []);
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
useEffect(() => {
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
if (isNewPage) {
forceNewClaimRef.current = true;
setShowDraftSelection(false);
setHasDrafts(false);
}
}, [forceNewClaim]);
useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
@@ -155,113 +251,10 @@ export default function ClaimForm() {
}
}, []);
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
// Авторизация выполняется на /hello (универсальный auth). Здесь только помечаем, что платформа проверена.
useEffect(() => {
const tryTelegramAuth = async () => {
try {
// Только window: parent недоступен из-за cross-origin (iframe Telegram)
const getTg = () => (window as any).Telegram;
// Ждём появления initData: скрипт Telegram может подгрузиться с задержкой
const maxWaitMs = 2500;
const intervalMs = 150;
let webApp: TelegramWebApp | null = null;
let attempts = 0;
while (attempts * intervalMs < maxWaitMs) {
const tg = getTg();
webApp = tg?.WebApp ?? null;
if (webApp?.initData) {
console.log('[TG] initData появился через', attempts * intervalMs, 'ms, длина=', webApp.initData.length);
break;
}
attempts++;
await new Promise((r) => setTimeout(r, intervalMs));
}
if (!webApp?.initData) {
const tg = getTg();
console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth');
setTelegramAuthChecked(true);
return;
}
// Логирование для отладки
if (webApp.initDataUnsafe?.user) {
const u = webApp.initDataUnsafe.user;
console.log('[TG] initDataUnsafe.user:', { id: u.id, username: u.username, first_name: u.first_name });
}
// Если сессия уже есть в localStorage — ничего не делаем, дальше сработает обычное restoreSession
const existingToken = localStorage.getItem('session_token');
if (existingToken) {
setTgDebug('TG: session_token уже есть → tg/auth не вызываем');
console.log('[TG] session_token уже в localStorage → tg/auth не вызываем');
setTelegramAuthChecked(true);
return;
}
setTgDebug('TG: POST /api/v1/tg/auth...');
console.log('[TG] Вызываем POST /api/v1/tg/auth, initData длина=', webApp.initData.length);
const response = await fetch('/api/v1/tg/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
init_data: webApp.initData,
}),
});
const data = await response.json();
console.log('[TG] /api/v1/tg/auth ответ: status=', response.status, 'ok=', response.ok, 'data=', data);
if (!response.ok || !data.success) {
console.warn('[TG] Telegram auth не успешен → показываем экран телефона/SMS. detail=', data.detail || data);
setTelegramAuthChecked(true);
return;
}
const sessionToken = data.session_token;
// Сохраняем session_token так же, как после SMS-логина
if (sessionToken) {
localStorage.setItem('session_token', sessionToken);
sessionIdRef.current = sessionToken;
}
// Сохраняем базовые данные пользователя (phone может быть пустым)
setFormData((prev) => ({
...prev,
unified_id: data.unified_id,
phone: data.phone,
contact_id: data.contact_id,
session_id: sessionToken,
}));
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
setIsPhoneVerified(true);
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
if (data.has_drafts) {
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
// Иначе переходим сразу к описанию проблемы
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
setCurrentStep(1);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
setTgDebug(`TG: ошибка: ${msg}`);
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
} finally {
setTelegramAuthChecked(true);
}
};
tryTelegramAuth();
setTelegramAuthChecked(true);
setPlatformChecked(true);
}, []);
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
@@ -274,13 +267,9 @@ export default function ClaimForm() {
const restoreSession = async () => {
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
console.log('🔑 Значения всех ключей:', JSON.stringify(localStorage));
const savedSessionToken = localStorage.getItem('session_token');
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|| localStorage.getItem('session_token');
if (!savedSessionToken) {
console.log('❌ Session token NOT found in localStorage');
setSessionRestored(true);
return;
}
@@ -295,14 +284,15 @@ export default function ClaimForm() {
body: JSON.stringify({ session_token: savedSessionToken })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
let data: any = null;
try {
data = await response.json();
} catch (_) {
data = null;
}
console.log('🔑 Session verify response:', { ok: response.ok, status: response.status, data });
const data = await response.json();
console.log('🔑 Session verify response:', data);
if (data.success && data.valid) {
if (response.ok && data?.success && data?.valid) {
// Сессия валидна! Восстанавливаем состояние
console.log('✅ Session valid! Restoring user data:', {
unified_id: data.unified_id,
@@ -324,39 +314,48 @@ export default function ClaimForm() {
// Помечаем телефон как верифицированный
setIsPhoneVerified(true);
// На странице /new («Подать жалобу») не показываем черновики
if (forceNewClaimRef.current) {
// После refresh в TG initData может быть пустым/поздним, поэтому нельзя
// выбирать шаг по наличию initData: иначе попадаем сразу в StepWizardPlan (крутилка).
const isWebFlow = platformChecked && !isTelegramMiniApp && !isMaxMiniApp;
setCurrentStep(isWebFlow ? 1 : 0);
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
message.success('Добро пожаловать!');
}
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
return;
}
// Проверяем черновики
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
if (hasDraftsResult) {
// Есть черновики - показываем список
setShowDraftSelection(true);
setHasDrafts(true);
// Переходим к шагу выбора черновика
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setCurrentStep(0);
});
});
setShowDraftSelection(!!hasDraftsResult);
setHasDrafts(!!hasDraftsResult);
setCurrentStep(0); // дашборд «Мои обращения»
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
} else {
// Нет черновиков - переходим к описанию
setCurrentStep(1);
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
}
} else {
// Сессия невалидна - удаляем из localStorage
console.log('❌ Session invalid or expired, removing from localStorage');
addDebugEvent('session', 'success', hasDraftsResult ? '✅ Сессия восстановлена, найдены черновики' : '✅ Сессия восстановлена');
}
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
if (response.ok && data?.success && data?.valid === false) {
try { sessionStorage.removeItem('session_token'); } catch (_) {}
localStorage.removeItem('session_token');
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
return;
}
// Сетевые/серверные проблемы — токен не трогаем (иначе “порой разлогин”).
if (!response.ok || !data?.success) {
console.warn('⚠️ Session verify failed (token kept)', { ok: response.ok, status: response.status });
addDebugEvent('session', 'warning', '⚠️ Не удалось проверить сессию (токен сохранён)');
}
} catch (error) {
console.error('❌ Error verifying session:', error);
localStorage.removeItem('session_token');
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
// Не удаляем session_token на сетевых ошибках — это вызывает “рандомный разлогин”
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии (токен сохранён)');
} finally {
setSessionRestored(true);
}
@@ -1033,33 +1032,29 @@ export default function ClaimForm() {
}
// ✅ Определяем шаг для перехода на основе данных черновика
// Приоритет: если есть wizard_plan → переходим к визарду (даже если нет problem_description)
// После выбора черновика showDraftSelection = false, поэтому:
// - Шаг 0 = Step1Phone (но мы его пропускаем, т.к. телефон уже верифицирован)
// - Шаг 1 = StepDescription
// - Шаг 2 = StepWizardPlan
let targetStep = 1; // По умолчанию - описание (шаг 1)
// При forceNewClaim (/new) шаги только [StepDescription(0), StepWizardPlan(1)].
// Без forceNewClaim: [Dashboard(0), DraftSelection(1), StepDescription(2), StepWizardPlan(3)] (или + Step1Phone для веба).
const isForceNew = forceNewClaimRef.current;
const stepDescription = isForceNew ? 0 : 2;
const stepWizard = isForceNew ? 1 : 3;
let targetStep = stepDescription; // По умолчанию — экран «Обращение» (описание)
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
if (documentsRequired.length > 0) {
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
targetStep = stepWizard;
console.log('✅ Переходим к StepWizardPlan - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
console.log('✅ documents_required:', documentsRequired.length, 'документов');
} else if (wizardPlan) {
// ✅ СТАРЫЙ ФЛОУ: Если есть wizard_plan - переходим к визарду (шаг 2)
// Пользователь уже описывал проблему, и есть план вопросов
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - СТАРЫЙ ФЛОУ: есть wizard_plan');
targetStep = stepWizard;
console.log('✅ Переходим к StepWizardPlan - СТАРЫЙ ФЛОУ: есть wizard_plan');
console.log('✅ answers в черновике:', answers ? 'есть (показываем заполненную форму)' : 'нет (показываем пустую форму)');
} else if (problemDescription) {
// Если есть описание, но нет плана - переходим к визарду (шаг 2), чтобы получить план
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть описание, план будет получен через SSE');
targetStep = stepWizard;
console.log('✅ Переходим к StepWizardPlan - есть описание, план будет получен через SSE');
} else {
// Если нет ничего - переходим к описанию (шаг 1)
targetStep = 1;
console.log('✅ Переходим к StepDescription (шаг 1) - нет описания и плана');
targetStep = stepDescription;
console.log('✅ Переходим к StepDescription (индекс', stepDescription, ') - нет описания и плана');
}
console.log('🔍 Устанавливаем currentStep:', targetStep);
@@ -1072,6 +1067,81 @@ export default function ClaimForm() {
}
}, [formData, updateFormData]);
// Нормализовать start_param: MAX может отдавать строку или объект WebAppStartParam
const startParamToString = useCallback((v: unknown): string | null => {
if (v == null) return null;
if (typeof v === 'string') return v;
if (typeof v === 'object' && v !== null) {
const o = v as Record<string, unknown>;
if (typeof o.value === 'string') return o.value;
if (typeof o.payload === 'string') return o.payload;
if (typeof o.start_param === 'string') return o.start_param;
if (typeof o.data === 'string') return o.data;
return JSON.stringify(o);
}
return String(v);
}, []);
// Извлечь claim_id из строки startapp/start_param (форматы: claim_id=uuid, claim_id_uuid, или голый uuid)
const parseClaimIdFromStartParam = useCallback((startParam: string | Record<string, unknown> | null | undefined): string | null => {
const s = startParamToString(startParam);
if (!s) return null;
const decoded = decodeURIComponent(s.trim());
let m = decoded.match(/(?:^|[?&])claim_id=([^&]+)/i) || decoded.match(/(?:^|[?&])claim_id_([0-9a-f-]{36})/i);
if (!m) m = decoded.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return m ? decodeURIComponent(m[1]) : null;
}, [startParamToString]);
// Автозагрузка черновика из URL или из MAX WebApp start_param после восстановления сессии
useEffect(() => {
if (!sessionRestored) return;
(async () => {
try {
const params = new URLSearchParams(window.location.search);
// claim_id может прийти как UUID или как claim_id_<uuid> (после редиректа из /hello?WebAppStartParam=...)
let claimFromUrl = parseClaimIdFromStartParam(params.get('claim_id') || '') || params.get('claim_id');
// Query: startapp=... или WebAppStartParam=... (MAX подставляет при открытии по диплинку)
if (!claimFromUrl) claimFromUrl = parseClaimIdFromStartParam(params.get('startapp') || params.get('WebAppStartParam') || '');
// Hash (MAX иногда кладёт параметры в #)
if (!claimFromUrl && window.location.hash) {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const fromHash = parseClaimIdFromStartParam(hashParams.get('claim_id') || '') || hashParams.get('claim_id');
claimFromUrl = fromHash || parseClaimIdFromStartParam(hashParams.get('startapp') || hashParams.get('WebAppStartParam') || '');
}
// MAX WebApp: initDataUnsafe.start_param (появляется после загрузки скрипта st.max.ru)
if (!claimFromUrl) {
const wa = (window as any).WebApp;
const startParam = wa?.initDataUnsafe?.start_param;
if (startParam) {
claimFromUrl = parseClaimIdFromStartParam(startParam);
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param:', claimFromUrl);
}
}
// Повторная проверка через 1.2s на случай, если MAX bridge подставил start_param с задержкой
if (!claimFromUrl) {
await new Promise((r) => setTimeout(r, 1200));
const wa = (window as any).WebApp;
const startParam = wa?.initDataUnsafe?.start_param;
if (startParam) {
claimFromUrl = parseClaimIdFromStartParam(startParam);
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param (отложенно):', claimFromUrl);
}
}
if (claimFromUrl) {
if (autoLoadedClaimIdRef.current === claimFromUrl) return;
autoLoadedClaimIdRef.current = claimFromUrl;
// Сразу помечаем черновик как выбранный и скрываем список — чтобы не показывать шаг «Черновики», сразу перейти к документам
setSelectedDraftId(claimFromUrl);
setShowDraftSelection(false);
console.log('🔗 Автозагрузка черновика из URL claim_id=', claimFromUrl, '(сразу на документы)');
await loadDraft(claimFromUrl);
}
} catch (e) {
console.error('❌ Ошибка автозагрузки черновика из URL:', e);
}
})();
}, [sessionRestored, loadDraft, parseClaimIdFromStartParam]);
// Обработчик выбора черновика
const handleSelectDraft = useCallback((claimId: string) => {
loadDraft(claimId);
@@ -1079,6 +1149,10 @@ export default function ClaimForm() {
// Проверка наличия черновиков
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
if (forceNewClaimRef.current) {
console.log('🔍 forceNewClaim: пропускаем проверку черновиков');
return false;
}
try {
console.log('🔍 ========== checkDrafts вызван ==========');
console.log('🔍 Параметры:', { unified_id, phone, sessionId });
@@ -1130,12 +1204,49 @@ export default function ClaimForm() {
}
}, []);
/** Один раз грузим список обращений в родителе — один запрос в n8n, данные передаём в дашборд и список */
const loadDraftsList = useCallback(async () => {
const uid = formData.unified_id;
const ph = formData.phone || '';
const sid = sessionIdRef.current;
if (!uid && !ph && !sid) return;
const entryChannel = isTelegramMiniApp ? 'telegram' : isMaxMiniApp ? 'max' : 'web';
const params = new URLSearchParams();
if (uid) params.append('unified_id', uid);
if (ph) params.append('phone', ph);
if (sid) params.append('session_id', sid);
params.append('entry_channel', entryChannel);
setDraftsListLoading(true);
try {
const res = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
if (!res.ok) return;
const data = await res.json();
const list = data.drafts || data.items || [];
setDraftsList(Array.isArray(list) ? list : []);
} catch {
setDraftsList([]);
} finally {
setDraftsListLoading(false);
}
}, [formData.unified_id, formData.phone, isTelegramMiniApp, isMaxMiniApp]);
useEffect(() => {
if (forceNewClaimRef.current || !formData.unified_id) return;
loadDraftsList();
}, [formData.unified_id]);
// Синхронизируем список обращений в контекст для страницы «Консультации»
useEffect(() => {
setDrafts(Array.isArray(draftsList) ? draftsList : []);
}, [draftsList, setDrafts]);
// Обработчик создания новой заявки
const handleNewClaim = useCallback(() => {
console.log('🆕 Начинаем новое обращение');
console.log('🆕 Текущий currentStep:', currentStep);
console.log('🆕 isPhoneVerified:', isPhoneVerified);
// ✅ Режим «новая жалоба»: без дашборда/черновиков, первый шаг = форма «Описание»
forceNewClaimRef.current = true;
window.history.pushState({}, '', '/new');
// ✅ Генерируем НОВУЮ сессию для новой жалобы
const newSessionId = 'sess_' + generateUUIDv4();
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
@@ -1144,8 +1255,7 @@ export default function ClaimForm() {
// ✅ Обновляем sessionIdRef на новую сессию
sessionIdRef.current = newSessionId;
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
const savedSessionToken = localStorage.getItem('session_token');
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
@@ -1171,14 +1281,10 @@ export default function ClaimForm() {
});
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
// ✅ Переходим к шагу описания проблемы
// После сброса флагов черновиков, steps будут:
// Шаг 0 - Phone (уже верифицирован, но в массиве есть)
// Шаг 1 - Description (сюда переходим)
// Шаг 2 - WizardPlan
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
// После refresh в TG initData может быть пустым, поэтому индекс определяем по флагам платформы, а не по initData.
const isWebFlow = platformChecked && !isTelegramMiniApp && !isMaxMiniApp;
setCurrentStep(isWebFlow ? 1 : 0);
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone, platformChecked, isTelegramMiniApp, isMaxMiniApp]);
// ✅ Автоматический редирект на экран черновиков после успешной отправки
useEffect(() => {
@@ -1296,10 +1402,29 @@ export default function ClaimForm() {
const steps = useMemo(() => {
const stepsArray: any[] = [];
// Шаг 0: Выбор черновика (показывается только если есть черновики)
// ✅ unified_id уже означает, что телефон верифицирован
// Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts
if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
// Шаги «Мои обращения»: дашборд с плитками + список черновиков — для любого авторизованного (unified_id) на главной
// Не показываем на странице «Подать жалобу» (/new)
if (!forceNewClaimRef.current && formData.unified_id && !selectedDraftId) {
stepsArray.push({
title: 'Мои обращения',
description: 'Ваши обращения',
content: (
<StepComplaintsDashboard
unified_id={formData.unified_id}
phone={formData.phone || ''}
session_id={sessionIdRef.current}
entry_channel={isTelegramMiniApp ? 'telegram' : isMaxMiniApp ? 'max' : 'web'}
drafts={draftsList}
loading={draftsListLoading}
onGoToList={(filter) => {
setDraftsListFilter(filter);
nextStep();
}}
onNewClaim={handleNewClaim}
onNavigate={onNavigate}
/>
),
});
stepsArray.push({
title: 'Черновики',
description: 'Выбор заявки',
@@ -1307,8 +1432,16 @@ export default function ClaimForm() {
<StepDraftSelection
phone={formData.phone || ''}
session_id={sessionIdRef.current}
unified_id={formData.unified_id} // ✅ Передаём unified_id
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
unified_id={formData.unified_id}
isTelegramMiniApp={isTelegramMiniApp}
entry_channel={isTelegramMiniApp ? 'telegram' : isMaxMiniApp ? 'max' : 'web'}
drafts={draftsList}
loading={draftsListLoading}
onRefreshDrafts={loadDraftsList}
draftDetailClaimId={draftDetailClaimId}
categoryFilter={draftsListFilter}
onOpenDraftDetail={setDraftDetailClaimId}
onCloseDraftDetail={() => setDraftDetailClaimId(null)}
onSelectDraft={handleSelectDraft}
onNewClaim={handleNewClaim}
/>
@@ -1316,12 +1449,13 @@ export default function ClaimForm() {
});
}
// Шаг 1: Phone (телефон + SMS верификация)
stepsArray.push({
title: 'Вход',
description: 'Подтверждение телефона',
content: (
<Step1Phone
// Шаг «Вход» (телефон + SMS) только для обычного веба и только после определения платформы (в MAX/TG не показываем, и пока не проверили — тоже не показываем).
if (platformChecked && !isTelegramMiniApp && !isMaxMiniApp) {
stepsArray.push({
title: 'Вход',
description: 'Подтверждение телефона',
content: (
<Step1Phone
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
updateFormData={(data: any) => {
updateFormData(data);
@@ -1345,7 +1479,7 @@ export default function ClaimForm() {
// ✅ Если передан unified_id, значит телефон уже верифицирован (даже если isPhoneVerified ещё false)
// Проверяем черновики, если есть unified_id или телефон верифицирован
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
const shouldCheckDrafts = (finalUnifiedId || (formData.phone && isPhoneVerified)) && !forceNewClaimRef.current;
if (shouldCheckDrafts && !selectedDraftId) {
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone, 'sessionId:', sessionIdRef.current);
@@ -1399,6 +1533,7 @@ export default function ClaimForm() {
/>
),
});
}
// Шаг 2: свободное описание
stepsArray.push({
@@ -1425,6 +1560,7 @@ export default function ClaimForm() {
onPrev={prevStep}
onNext={nextStep}
backToDraftsList={backToDraftsList}
onNewClaim={handleNewClaim}
addDebugEvent={addDebugEvent}
/>
),
@@ -1450,7 +1586,75 @@ export default function ClaimForm() {
// Step3Payment убран - не используется
return stepsArray;
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked, draftsList, draftsListLoading, loadDraftsList]);
// Синхронизация currentStep при выходе за границы (например после смены списка шагов в TG/MAX)
useEffect(() => {
if (steps.length === 0) return;
const safe = Math.min(currentStep, steps.length - 1);
if (currentStep < 0 || currentStep >= steps.length || currentStep !== safe) {
setCurrentStep(Math.max(0, safe));
}
}, [steps.length, currentStep]);
// Кнопка «Назад» в нижнем баре: обработка через событие (вместо кнопок в контенте)
// ВАЖНО: держим effect ниже prevStep и steps (иначе TDZ/стейл шаги).
useEffect(() => {
const onGoBack = () => {
const now = Date.now();
const currentTitle = steps[currentStep]?.title;
const prevTitle = currentStep > 0 ? steps[currentStep - 1]?.title : null;
const isAuthed = !!formData.unified_id || isPhoneVerified || !!(typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || !!localStorage.getItem('session_token');
miniappLog('claim_form_go_back_event', {
currentStep,
currentTitle,
prevTitle,
isAuthed,
hasUnifiedId: !!formData.unified_id,
isPhoneVerified,
forceNewClaim: forceNewClaimRef.current,
ignoreUntil: barBackIgnoreUntilRef.current,
now,
});
if (now < barBackIgnoreUntilRef.current) return;
// Если открыта деталка черновика — закрываем её
if (draftDetailClaimId) {
miniappLog('claim_form_go_back_action', { action: 'close_draft_detail' });
setDraftDetailClaimId(null);
return;
}
// Если “назад” ведёт на шаг «Вход», но мы уже авторизованы — не показываем телефон (это выглядит как разлогин)
if (isAuthed && prevTitle === 'Вход') {
const hasDashboard = steps[0]?.title === 'Мои обращения';
if (hasDashboard) {
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_dashboard' });
setCurrentStep(0);
return;
}
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_hello' });
void miniappSendLogs('go_back_skip_phone');
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
return;
}
if (currentStep === 0) {
miniappLog('claim_form_go_back_action', { action: 'to_hello' });
void miniappSendLogs('go_back_to_hello');
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
return;
}
miniappLog('claim_form_go_back_action', { action: 'prev_step' });
prevStep();
};
window.addEventListener('miniapp:goBack', onGoBack);
return () => window.removeEventListener('miniapp:goBack', onGoBack);
}, [currentStep, draftDetailClaimId, formData.unified_id, isPhoneVerified, prevStep, steps]);
const handleReset = () => {
console.log('🔄 Начать заново - возврат к списку черновиков');
@@ -1515,8 +1719,7 @@ export default function ClaimForm() {
}
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
// Получаем session_token из localStorage
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token') || formData.session_id;
if (sessionToken) {
try {
@@ -1536,7 +1739,7 @@ export default function ClaimForm() {
}
}
// Удаляем session_token из localStorage
try { sessionStorage.removeItem('session_token'); } catch (_) {}
localStorage.removeItem('session_token');
// Полный сброс: очищаем все данные авторизации и черновиков
@@ -1575,60 +1778,39 @@ export default function ClaimForm() {
// ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии
if (!telegramAuthChecked || !sessionRestored) {
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
// Пустой список шагов — не показывать форму с «Загрузка шага», показать общий loader
if (steps.length === 0) {
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" tip="Подготовка формы..." />
</div>
);
}
// Если currentStep вышел за границы — показываем последний валидный шаг; всегда есть steps[0]
const safeStepIndex = Math.min(currentStep, Math.max(0, steps.length - 1));
const stepToShow = steps[safeStepIndex] ?? steps[0];
const isDocumentsStep = stepToShow?.title === 'Документы';
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
<Row gutter={16}>
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
<Row gutter={[0, 16]}>
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
<Card
title="Подать обращение о защите прав потребителя"
className="claim-form-card"
extra={
!isSubmitted && (
<Space>
{/* Кнопка "Выход" - показываем если телефон верифицирован */}
{isPhoneVerified && (
<button
onClick={handleExitToList}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #ff4d4f',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#ff4d4f'
}}
>
🚪 Выход
</button>
)}
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
{currentStep > 0 && (
<button
onClick={handleReset}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
🔄 Начать заново
</button>
)}
</Space>
)
}
>
{isDocumentsStep ? (
<div className="steps-content" style={{ marginTop: 0 }}>
{stepToShow ? stepToShow.content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
)}
</div>
) : (
<Card title={null} className="claim-form-card" bordered={false}>
{isSubmitted ? (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
@@ -1637,26 +1819,16 @@ export default function ClaimForm() {
</p>
</div>
) : (
<>
<Steps current={currentStep} className="steps">
{steps.map((item, index) => (
<Step
key={`step-${index}`}
title={item.title}
description={item.description}
/>
))}
</Steps>
<div className="steps-content">
{steps[currentStep] ? steps[currentStep].content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<p>Загрузка шага...</p>
</div>
)}
</div>
</>
<div className="steps-content">
{stepToShow ? stepToShow.content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<p>Загрузка шага...</p>
</div>
)}
</div>
)}
</Card>
)}
</Col>
{/* Правая часть - Debug консоль (только в dev режиме) */}

View File

@@ -0,0 +1,266 @@
/* Консультации — список и экран «Подробнее» */
.consultations-page {
padding: 20px 16px 100px;
max-width: 560px;
margin: 0 auto;
min-height: 100vh;
}
.consultations-back {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
padding: 6px 0;
color: #6366f1;
font-size: 15px;
font-weight: 500;
background: none;
border: none;
cursor: pointer;
transition: color 0.15s ease;
}
.consultations-back:hover {
color: #4f46e5;
}
.consultations-header {
margin-bottom: 24px;
}
.consultations-header h1 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
color: #111827;
letter-spacing: -0.02em;
line-height: 1.25;
}
.consultations-header .subtitle {
margin: 0;
font-size: 14px;
color: #64748b;
line-height: 1.4;
}
/* Список тикетов */
.consultations-list {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
}
.consultations-list-item {
padding: 16px 18px;
background: #fff;
border-radius: 14px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.consultations-list-item:hover {
border-color: #c7d2fe;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.12);
transform: translateY(-1px);
}
.consultations-list-item:active {
transform: translateY(0);
}
.consultations-list-item .item-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: #111827;
line-height: 1.35;
}
.consultations-list-item .item-date {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.3;
}
/* Пустой список */
.consultations-empty {
padding: 48px 24px;
text-align: center;
background: #f8fafc;
border-radius: 14px;
border: 1px dashed #e2e8f0;
}
.consultations-empty p {
margin: 0;
font-size: 14px;
color: #64748b;
line-height: 1.5;
}
/* Экран «Подробнее» */
.consultations-detail {
margin-top: 8px;
}
.consultations-detail .detail-title {
margin: 0 0 16px;
font-size: 20px;
font-weight: 700;
color: #111827;
letter-spacing: -0.02em;
line-height: 1.3;
}
.consultations-detail-card {
margin-top: 16px;
padding: 0;
background: #fff;
border-radius: 16px;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.consultations-detail-card .card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 20px 12px;
border-bottom: 1px solid #f1f5f9;
}
.consultations-detail-card .card-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #111827;
line-height: 1.35;
flex: 1;
min-width: 0;
}
.consultations-detail-card .card-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
color: #166534;
background: #dcfce7;
border-radius: 20px;
line-height: 1.2;
}
.consultations-detail-card .card-status-pill svg {
color: #16a34a;
}
.consultations-detail-card .card-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 20px 16px;
border-bottom: 1px solid #f1f5f9;
}
.consultations-detail-card .card-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
border-radius: 8px;
line-height: 1.3;
}
.consultations-detail-card .card-tag-category {
color: #1e40af;
background: #dbeafe;
border: 1px solid #93c5fd;
}
.consultations-detail-card .card-tag-priority {
color: #475569;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
.consultations-detail-card .card-tag svg {
flex-shrink: 0;
opacity: 0.9;
}
.consultations-detail-card .card-section {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
}
.consultations-detail-card .card-section:last-child {
border-bottom: none;
}
.consultations-detail-card .card-section-title {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
line-height: 1.3;
}
.consultations-detail-card .card-section-body {
margin: 0;
font-size: 15px;
color: #334155;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.consultations-detail-card .card-section-body:empty {
display: none;
}
.consultations-detail-error {
margin-top: 16px;
padding: 16px 18px;
background: #fef2f2;
border-radius: 12px;
border: 1px solid #fecaca;
font-size: 14px;
color: #b91c1c;
line-height: 1.45;
}
.consultations-detail-empty {
margin-top: 24px;
padding: 32px 24px;
text-align: center;
background: #f8fafc;
border-radius: 14px;
font-size: 14px;
color: #64748b;
}
.consultations-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 48px 0;
}

View File

@@ -0,0 +1,276 @@
/**
* Страница «Консультации» — тикеты из общего списка обращений (контекст, без отдельного эндпоинта).
* Клик по тикету → POST /api/v1/consultations/ticket-detail (N8N_TICKET_FORM_PODROBNEE_WEBHOOK).
* Ответ вебхука только в формате: [{ title, status, category, entity_description, solution, priority }].
*/
import { useState, useEffect } from 'react';
import { Spin } from 'antd';
import { Compass, Zap, Circle } from 'lucide-react';
import { useConsultationItems } from '../context/DraftsContext';
import type { DraftItem } from '../context/DraftsContext';
import './Consultations.css';
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 formatConsultationDate(raw: unknown): string {
if (raw == null) return '';
const s = String(raw).trim();
if (!s) return '';
try {
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${day}.${month}.${year} ${h}:${m}`;
} catch {
return s;
}
}
function getItemTitle(item: DraftItem): string {
const payload = item?.payload;
const title =
(item?.problem_title as string)?.trim() ||
(item?.title as string)?.trim() ||
(item?.subject as string)?.trim() ||
(item?.name as string)?.trim() ||
(payload?.ticket_no as string)?.trim() ||
(item?.ticket_no as string)?.trim();
return title || 'Тикет';
}
function getItemDate(item: DraftItem): string {
const raw = item?.created_at ?? item?.createdtime ?? item?.date;
return formatConsultationDate(raw);
}
/** Достать ticket_id для вебхука: payload.ticketid или из id вида crm_ticket_520630 */
function getTicketId(item: DraftItem): string | number | null {
const p = item?.payload;
if (p && (p.ticketid != null || p.ticketid === 0)) return p.ticketid as number;
if (p && p.ticket_no != null) return String(p.ticket_no);
const id = item?.id ?? item?.claim_id;
if (typeof id === 'string' && id.startsWith('crm_ticket_')) {
const num = id.replace('crm_ticket_', '');
return /^\d+$/.test(num) ? parseInt(num, 10) : num;
}
return id != null ? String(id) : null;
}
interface ConsultationsProps {
onNavigate?: (path: string) => void;
}
/** Формат ответа вебхука «подробнее» — только [{ title, status, category, entity_description, solution, priority }] */
interface TicketDetailShape {
title?: string;
status?: string;
category?: string;
entity_description?: string;
solution?: string;
priority?: string;
}
function parseTicketDetail(raw: unknown): TicketDetailShape | null {
if (raw == null) return null;
const arr = Array.isArray(raw) ? raw : (raw as any)?.data != null ? (Array.isArray((raw as any).data) ? (raw as any).data : [(raw as any).data]) : [raw];
const first = arr[0];
if (first && typeof first === 'object') {
return {
title: first.title != null ? String(first.title) : undefined,
status: first.status != null ? String(first.status) : undefined,
category: first.category != null ? String(first.category) : undefined,
entity_description: first.entity_description != null ? String(first.entity_description) : undefined,
solution: first.solution != null ? String(first.solution) : undefined,
priority: first.priority != null ? String(first.priority) : undefined,
};
}
return null;
}
const STATUS_RU: Record<string, string> = {
open: 'Открыт', opened: 'Открыт', closed: 'Закрыт', in_progress: 'В работе', 'in progress': 'В работе',
pending: 'В ожидании', resolved: 'Решён', completed: 'Завершён', rejected: 'Отклонён', cancelled: 'Отменён',
};
const PRIORITY_RU: Record<string, string> = {
low: 'Низкий', normal: 'Обычный', medium: 'Средний', high: 'Высокий', critical: 'Критический', urgent: 'Срочный',
};
function labelStatus(s: string): string { const k = s.trim().toLowerCase(); return STATUS_RU[k] ?? s; }
function labelPriority(s: string): string { const k = s.trim().toLowerCase(); return PRIORITY_RU[k] ?? s; }
export default function Consultations({ onNavigate }: ConsultationsProps) {
const listItems = useConsultationItems();
const [detailTicketId, setDetailTicketId] = useState<string | number | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailContent, setDetailContent] = useState<TicketDetailShape | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const handleItemClick = (item: DraftItem) => {
const ticketId = getTicketId(item);
if (ticketId == null) return;
const token = getSessionToken();
if (!token) {
setDetailError('Войдите в систему');
return;
}
setDetailError(null);
setDetailContent(null);
setDetailTicketId(ticketId);
setDetailLoading(true);
fetch('/api/v1/consultations/ticket-detail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: token,
ticket_id: typeof ticketId === 'number' ? ticketId : String(ticketId),
entry_channel: 'web',
}),
})
.then((res) => {
if (!res.ok) throw new Error(res.status === 401 ? 'Сессия истекла' : res.status === 502 ? 'Сервис недоступен' : 'Ошибка загрузки');
return res.json();
})
.then((json) => {
const parsed = parseTicketDetail(json);
setDetailContent(parsed);
setDetailError(parsed ? null : 'Неверный формат ответа');
})
.catch((e) => {
setDetailError(e.message || 'Не удалось загрузить');
setDetailContent(null);
})
.finally(() => setDetailLoading(false));
};
const showDetail = detailTicketId != null;
const ticketDetail = detailContent;
const handleBack = () => {
setDetailTicketId(null);
setDetailContent(null);
setDetailError(null);
};
useEffect(() => {
const onGoBack = () => {
if (showDetail) {
setDetailTicketId(null);
setDetailContent(null);
setDetailError(null);
} else {
onNavigate?.('/');
}
};
window.addEventListener('miniapp:goBack', onGoBack);
return () => window.removeEventListener('miniapp:goBack', onGoBack);
}, [showDetail, onNavigate]);
return (
<div className="consultations-page">
{!showDetail ? (
<>
<header className="consultations-header">
<h1>Консультации</h1>
</header>
{listItems.length === 0 ? (
<div className="consultations-empty">
<p>Нет тикетов. Откройте «Мои обращения» на главной, чтобы подгрузить список.</p>
</div>
) : (
<ul className="consultations-list">
{listItems.map((item: DraftItem, idx: number) => (
<li
key={String(item?.id ?? item?.ticketid ?? item?.ticket_id ?? idx)}
className="consultations-list-item"
onClick={() => handleItemClick(item)}
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleItemClick(item)}
>
<p className="item-title">{getItemTitle(item)}</p>
{getItemDate(item) && <p className="item-date">{getItemDate(item)}</p>}
</li>
))}
</ul>
)}
</>
) : (
<div className="consultations-detail">
<h2 className="detail-title">Подробнее</h2>
{detailLoading && (
<div className="consultations-loading">
<Spin size="large" />
</div>
)}
{!detailLoading && detailError && (
<div className="consultations-detail-error" role="alert">
{detailError}
</div>
)}
{!detailLoading && !detailError && ticketDetail != null && (
<article className="consultations-detail-card">
<div className="card-head">
{ticketDetail.title != null && <h3 className="card-title">{ticketDetail.title}</h3>}
{ticketDetail.status != null && (
<span className="card-status-pill">
<Circle size={8} fill="currentColor" stroke="none" />
{labelStatus(ticketDetail.status)}
</span>
)}
</div>
{(ticketDetail.category != null || ticketDetail.priority != null) && (
<div className="card-tags">
{ticketDetail.category != null && (
<span className="card-tag card-tag-category">
<Compass size={14} />
{ticketDetail.category}
</span>
)}
{ticketDetail.priority != null && (
<span className="card-tag card-tag-priority">
<Zap size={14} />
{labelPriority(ticketDetail.priority)}
</span>
)}
</div>
)}
{ticketDetail.entity_description != null && ticketDetail.entity_description !== '' && (
<div className="card-section">
<p className="card-section-title">Описание</p>
<p className="card-section-body">{ticketDetail.entity_description}</p>
</div>
)}
{ticketDetail.solution != null && ticketDetail.solution !== '' && (
<div className="card-section">
<p className="card-section-title">Решение</p>
<p className="card-section-body">{ticketDetail.solution}</p>
</div>
)}
</article>
)}
{!detailLoading && !detailError && ticketDetail == null && (
<div className="consultations-detail-empty">Нет данных</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,287 @@
.hello-page {
min-height: 100vh;
padding: 32px;
padding-bottom: 90px;
background: #f5f7fb;
--tile-h: 160px;
}
.hello-hero {
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
background: #ffffff;
margin-bottom: 24px;
}
.hello-hero-header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.hello-hero-profile {
display: flex;
align-items: center;
gap: 18px;
}
.hello-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.9);
}
.hello-avatar.placeholder {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(37, 99, 235, 0.4));
}
.hello-hero-title {
font-size: 24px;
font-weight: 600;
color: #111827;
}
.hello-hero-subtitle {
font-size: 14px;
color: #6b7280;
margin-top: 2px;
}
.hello-hero-body {
padding: 8px 20px 16px;
min-height: 120px;
display: flex;
align-items: flex-start;
justify-content: center;
}
.hello-hero-error {
color: #d4380d;
text-align: center;
}
.hello-hero-success {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.hello-hero-success-main {
flex: 1 1 auto;
font-size: 14px;
color: #374151;
}
.hello-hero-system {
flex: 0 0 260px;
max-width: 260px;
min-width: 0;
}
.hello-hero-system :where(.ant-alert) {
padding: 8px 12px;
width: 100%;
box-sizing: border-box;
}
.hello-hero-system :where(.ant-alert-content) {
min-width: 0;
flex: 1;
}
.hello-hero-system :where(.ant-alert-message) {
font-size: 13px;
font-weight: 600;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.hello-hero-system :where(.ant-alert-description) {
font-size: 12px;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
.hello-hero-system-carousel .hello-hero-system-slide {
width: 100%;
}
.hello-grid {
margin-top: 32px;
}
.tile-col,
.hello-grid .ant-col {
display: flex;
}
.tile-card {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
height: var(--tile-h);
width: 100%;
box-sizing: border-box;
transition: transform 0.2s ease, box-shadow 0.2s ease;
background: #ffffff;
text-align: center;
}
.tile-card :where(.ant-card-body) {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
padding: 18px 16px;
text-align: center;
}
.tile-card:hover {
transform: translateY(-6px);
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}
.tile-card--inactive {
cursor: default;
pointer-events: none;
}
.tile-card--inactive .tile-icon {
color: #9ca3af !important;
}
.tile-card--inactive .tile-title {
color: #9ca3af;
}
.tile-card--inactive:hover {
transform: none;
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
}
.tile-icon {
width: 44px;
height: 44px;
border-radius: 16px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
margin-left: 0;
margin-right: 0;
}
.tile-icon svg {
display: block; /* убирает baseline */
width: 28px;
height: 28px;
}
.tile-title {
font-size: 14px;
font-weight: 600;
color: #111827;
text-align: center;
line-height: 18px;
min-height: 36px;
width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Нижний таб-бар */
.hello-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
padding-bottom: env(safe-area-inset-bottom, 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;
}
.hello-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 16px;
color: #6b7280;
text-decoration: none;
font-size: 12px;
font-weight: 500;
transition: color 0.2s ease;
}
.hello-bar-item:hover {
color: #111827;
}
.hello-bar-item--active {
color: #2563EB;
font-weight: 600;
}
.hello-bar-item--active:hover {
color: #2563EB;
}
@media (max-width: 768px) {
.hello-page {
padding: 16px;
padding-bottom: 90px;
--tile-h: 140px;
}
.hello-hero-success {
flex-direction: column;
}
.hello-hero-system {
flex: 1 1 auto;
width: 100%;
max-width: 100%;
min-width: 0;
}
/* На узких экранах баннер на всю ширину, кнопка под текстом — текст не сжимается */
.hello-hero-system :where(.ant-alert) {
flex-wrap: wrap;
}
.hello-hero-system :where(.ant-alert-action) {
flex-basis: 100%;
order: 1;
margin-top: 8px;
margin-left: 0;
}
.hello-hero-system :where(.ant-alert-close-icon) {
order: 0;
align-self: flex-start;
}
.tile-card :where(.ant-card-body) {
padding: 16px 12px;
gap: 8px;
}
}

View File

@@ -0,0 +1,514 @@
import { useEffect, useState } from 'react';
import { Card, Button, Input, Space, Spin, message, Row, Col, Alert, Carousel } from 'antd';
import {
User,
IdCard,
Trophy,
ShieldCheck,
CalendarDays,
FileText,
HelpCircle,
Building2,
ClipboardList,
FileWarning,
MessageCircle,
Scale,
} from 'lucide-react';
import './HelloAuth.css';
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
type Status = 'idle' | 'loading' | 'success' | 'error';
interface HelloAuthProps {
onAvatarChange?: (url: string) => void;
onNavigate?: (path: string) => void;
onProfileNeedsAttentionChange?: (value: boolean) => void;
}
const INIT_DATA_WAIT_MS = 5500;
const INIT_DATA_POLL_MS = 200;
const INIT_DATA_BG_RECOVERY_MS = 15000;
const INIT_DATA_BG_TICK_MS = 250;
export default function HelloAuth({ onAvatarChange, onNavigate, onProfileNeedsAttentionChange }: HelloAuthProps) {
const [status, setStatus] = useState<Status>('idle');
const [greeting, setGreeting] = useState<string>('Привет!');
const [error, setError] = useState<string>('');
const [avatar, setAvatar] = useState<string>('');
const [profileNeedsAttention, setProfileNeedsAttention] = useState<boolean>(false);
const [profileBannerDismissed, setProfileBannerDismissed] = useState<boolean>(() => {
try {
return sessionStorage.getItem('profile_attention_banner_dismissed_v1') === '1';
} catch (_) {
return false;
}
});
const [phone, setPhone] = useState<string>('');
const [code, setCode] = useState<string>('');
const [codeSent, setCodeSent] = useState<boolean>(false);
const [noInitDataAfterTimeout, setNoInitDataAfterTimeout] = useState<boolean>(false);
useEffect(() => {
const isTelegramContext = () => {
const url = window.location.href;
const ref = document.referrer;
const ua = navigator.userAgent;
return (
url.includes('tgWebAppData') ||
url.includes('tgWebAppVersion') ||
ref.includes('telegram') ||
ua.includes('Telegram')
);
};
const getChannelAndInitData = (): { channel: 'telegram' | 'max'; initData: string } | null => {
const tg = (window as any).Telegram?.WebApp;
const max = (window as any).WebApp;
const tgData = typeof tg?.initData === 'string' && tg.initData.length > 0 ? tg.initData : null;
const maxData = typeof max?.initData === 'string' && max.initData.length > 0 ? max.initData : null;
if (tgData && isTelegramContext()) return { channel: 'telegram', initData: tgData };
if (maxData) return { channel: 'max', initData: maxData };
if (tgData) return { channel: 'telegram', initData: tgData };
return null;
};
const authWithInitData = async (channel: 'telegram' | 'max', initData: string): Promise<'success' | 'need_contact' | 'error'> => {
miniappLog('auth_start', { channel, initDataLen: initData?.length ?? 0 });
const res = await fetch('/api/v1/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, init_data: initData }),
});
const data: Record<string, unknown> = await res.json().catch((e) => {
miniappLog('auth_json_error', { err: String(e), status: res.status });
return {};
});
miniappLog('auth_response', {
status: res.status,
ok: res.ok,
keys: Object.keys(data),
success: data.success,
need_contact: data.need_contact,
message: data.message,
detail: data.detail,
});
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
if (needContact) {
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
const doClose = () => {
try {
if (typeof webApp?.close === 'function') webApp.close();
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
} catch (_) {}
};
doClose();
setTimeout(doClose, 200);
return 'need_contact';
}
if (res.ok && data.success) {
const needsAttentionRaw =
(data.profile_needs_attention as unknown) ??
(data.profileNeedsAttention as unknown) ??
(data.need_profile_confirm as unknown) ??
(data.needProfileConfirm as unknown);
const needsAttention = needsAttentionRaw === true || needsAttentionRaw === 1 || needsAttentionRaw === '1' || needsAttentionRaw === 'true';
setProfileNeedsAttention(needsAttention);
onProfileNeedsAttentionChange?.(needsAttention);
const token = data.session_token as string | undefined;
if (token) {
try {
sessionStorage.setItem('session_token', token);
localStorage.setItem('session_token', token); // запас для TG: WebView иногда теряет sessionStorage при переходах
} catch (_) {}
}
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
const avatarUrl = user?.photo_url || (data.avatar_url as string);
if (avatarUrl) {
setAvatar(avatarUrl);
localStorage.setItem('user_avatar_url', avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return 'success';
}
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
setStatus('error');
void miniappSendLogs('auth_error');
return 'error';
};
const tryAuth = async () => {
setStatus('loading');
setNoInitDataAfterTimeout(false);
setError('');
try {
if (isTelegramContext() && !(window as any).Telegram?.WebApp) {
await new Promise<void>((resolve) => {
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.appendChild(script);
});
}
// 1) Ждём появления initData (TG или MAX) с таймаутом
let channelInit = getChannelAndInitData();
if (!channelInit) {
const deadline = Date.now() + INIT_DATA_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, INIT_DATA_POLL_MS));
channelInit = getChannelAndInitData();
if (channelInit) break;
}
}
if (channelInit) {
const result = await authWithInitData(channelInit.channel, channelInit.initData);
if (result === 'success' || result === 'need_contact' || result === 'error') return;
return;
}
// 2) initData не появился за таймаут — пробуем восстановить сессию по session_token (после обновления страницы)
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
if (likelyMiniapp) {
let token: string | null = null;
try {
token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
} catch (_) {}
if (token) {
try {
const verifyRes = await fetch('/api/v1/session/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: token }),
});
const verifyData = await verifyRes.json().catch(() => ({}));
if (verifyData?.valid === true) {
const needsAttentionRaw =
verifyData.profile_needs_attention ??
verifyData.profileNeedsAttention ??
verifyData.need_profile_confirm ??
verifyData.needProfileConfirm;
const needsAttention = needsAttentionRaw === true || needsAttentionRaw === 1 || needsAttentionRaw === '1' || needsAttentionRaw === 'true';
setProfileNeedsAttention(needsAttention);
onProfileNeedsAttentionChange?.(needsAttention);
setGreeting('Привет!');
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
const user = tgUser || maxUser;
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
setStatus('success');
return;
}
} catch (_) {}
}
// 2.5) TG после refresh иногда отдаёт initData с задержкой.
// Фоном ждём ещё немного и тихо повторяем auth, не показывая сразу экран ошибки.
const bgStart = Date.now();
const bgDeadline = bgStart + INIT_DATA_BG_RECOVERY_MS;
while (Date.now() < bgDeadline) {
await new Promise((r) => setTimeout(r, INIT_DATA_BG_TICK_MS));
const lateChannelInit = getChannelAndInitData();
if (!lateChannelInit) continue;
miniappLog('hello_init_data_recovered_bg', {
waited_ms: Date.now() - bgStart,
channel: lateChannelInit.channel,
});
const result = await authWithInitData(lateChannelInit.channel, lateChannelInit.initData);
if (result === 'success' || result === 'need_contact' || result === 'error') return;
}
const ctx = {
url: window.location.href,
hasTgWebApp: !!(window as any).Telegram?.WebApp,
hasMaxWebApp: !!(window as any).WebApp,
tgInitDataLen: typeof (window as any).Telegram?.WebApp?.initData === 'string' ? (window as any).Telegram.WebApp.initData.length : 0,
maxInitDataLen: typeof (window as any).WebApp?.initData === 'string' ? (window as any).WebApp.initData.length : 0,
};
miniappLog('hello_no_init_data_after_timeout', ctx);
miniappSendLogs('no_init_data_after_timeout').catch(() => {});
setNoInitDataAfterTimeout(true);
setStatus('idle');
return;
}
// 3) Веб: claim_id в URL и SMS fallback
const params = new URLSearchParams(window.location.search);
let claimFromUrl: string | null = params.get('claim_id');
const parseStart = (s: string | null) => {
if (!s) return null;
const d = decodeURIComponent(s.trim());
const m = d.match(/claim_id=([^&]+)/i) || d.match(/claim_id_([0-9a-f-]{36})/i) || d.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return m ? decodeURIComponent(m[1]) : null;
};
const startParamToStr = (v: unknown): string | null => {
if (v == null) return null;
if (typeof v === 'string') return v;
if (typeof v === 'object' && v !== null) {
const o = v as Record<string, unknown>;
if (typeof o.value === 'string') return o.value;
if (typeof o.payload === 'string') return o.payload;
if (typeof o.start_param === 'string') return o.start_param;
return JSON.stringify(o);
}
return String(v);
};
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
if (!claimFromUrl && window.location.hash) {
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
}
const maxStartParam = (window as any).WebApp?.initDataUnsafe?.start_param;
if (!claimFromUrl && maxStartParam) claimFromUrl = parseStart(startParamToStr(maxStartParam));
if (claimFromUrl) {
try {
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
if (draftRes.ok) {
const draftData = await draftRes.json();
const st = draftData?.claim?.session_token;
if (st) localStorage.setItem('session_token', st);
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
return;
}
} catch (_) {}
}
setStatus('idle');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus('error');
}
};
tryAuth();
}, [onAvatarChange, onNavigate, onProfileNeedsAttentionChange]);
if (noInitDataAfterTimeout && status === 'idle') {
return (
<div className="hello-auth-page">
<Card className="hello-auth-card">
<h2>Не удалось определить приложение</h2>
<p style={{ marginBottom: 16 }}>Если вы открыли мини-приложение из Telegram или MAX обновите страницу.</p>
<Button type="primary" onClick={() => window.location.reload()}>
Обновите страницу
</Button>
</Card>
</div>
);
}
const sendCode = async () => {
try {
if (!phone || phone.length < 10) {
message.error('Введите номер телефона');
return;
}
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
const res = await fetch('/api/v1/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: normalized }),
});
const data = await res.json();
if (!res.ok) {
message.error(data.detail || 'Ошибка отправки кода');
return;
}
setCodeSent(true);
message.success('Код отправлен');
} catch (e) {
message.error('Ошибка соединения');
}
};
const verifyCode = async () => {
try {
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
const res = await fetch('/api/v1/auth2/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform: 'sms', phone: normalized, code }),
});
const data = await res.json();
if (res.ok && data.success) {
setGreeting(data.greeting || 'Привет!');
if (data.avatar_url) {
setAvatar(data.avatar_url);
localStorage.setItem('user_avatar_url', data.avatar_url);
onAvatarChange?.(data.avatar_url);
}
setStatus('success');
return;
}
message.error(data.detail || 'Неверный код');
} catch (e) {
message.error('Ошибка соединения');
}
};
const systemBanners: Array<{ key: string; node: JSX.Element }> = [];
if (status === 'success' && profileNeedsAttention && !profileBannerDismissed) {
systemBanners.push({
key: 'profile-not-complete',
node: (
<Alert
type="warning"
showIcon
closable
message="Профиль не заполнен"
description="Пожалуйста, заполните обязательные поля профиля, чтобы продолжить работу."
onClose={() => {
try {
sessionStorage.setItem('profile_attention_banner_dismissed_v1', '1');
} catch (_) {}
setProfileBannerDismissed(true);
}}
action={
<Button
size="small"
type="primary"
onClick={() => {
if (onNavigate) onNavigate('/profile');
else window.location.href = '/profile';
}}
>
Заполнить профиль
</Button>
}
/>
),
});
}
const hasSystemBanners = systemBanners.length > 0;
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
{ title: 'Экспертиза', icon: Scale, color: '#0EA5E9' },
{ title: 'Консультации', icon: MessageCircle, color: '#8B5CF6' },
{ title: 'Членство', icon: IdCard, color: '#10B981' },
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },
{ title: 'Общественный контроллер', icon: ShieldCheck, color: '#22C55E' },
{ title: 'Календарь мероприятий', icon: CalendarDays, color: '#4F46E5' },
{ title: 'Образцы документов', icon: FileText, color: '#1E40AF' },
{ title: 'FAQ', icon: HelpCircle, color: '#0EA5E9' },
{ title: 'Регистрация компании', icon: Building2, color: '#0F766E' },
];
return (
<div className="hello-page">
<Card className="hello-hero" bordered={false}>
<div className="hello-hero-header">
<div className="hello-hero-profile">
{avatar ? (
<img src={avatar} alt="avatar" className="hello-avatar" />
) : (
<div className="hello-avatar placeholder" />
)}
<div>
<div className="hello-hero-title">{greeting}</div>
<div className="hello-hero-subtitle">Добро пожаловать в кабинет</div>
</div>
</div>
</div>
<div className="hello-hero-body">
{status === 'loading' ? (
<Spin size="large" tip="Авторизация..." />
) : status === 'success' ? (
<div className={`hello-hero-success${hasSystemBanners ? ' hello-hero-success--with-banner' : ''}`}>
<div className="hello-hero-success-main">
<p>Теперь ты в системе можно продолжать</p>
</div>
{hasSystemBanners && (
<div className="hello-hero-system">
{systemBanners.length === 1 ? (
systemBanners[0].node
) : (
<Carousel
autoplay
dots
adaptiveHeight
className="hello-hero-system-carousel"
>
{systemBanners.map((banner) => (
<div key={banner.key} className="hello-hero-system-slide">
{banner.node}
</div>
))}
</Carousel>
)}
</div>
)}
</div>
) : status === 'error' ? (
<p className="hello-hero-error">{error}</p>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Input
placeholder="Телефон (10 цифр)"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))}
/>
<Button type="primary" onClick={sendCode} block>
Отправить код
</Button>
{codeSent && (
<>
<Input
placeholder="Код из SMS"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
<Button type="primary" onClick={verifyCode} block>
Проверить
</Button>
</>
)}
</Space>
)}
</div>
</Card>
<Row gutter={[16, 16]} className="hello-grid" align="stretch">
{tiles.map((tile) => {
const Icon = tile.icon;
const active = !!tile.href;
const card = (
<Card
className={`tile-card ${active ? '' : 'tile-card--inactive'}`}
hoverable={active}
bordered={false}
onClick={tile.href ? () => {
// В TG при полной перезагрузке теряется initData — переходим без reload (SPA)
if (onNavigate) {
onNavigate(tile.href!);
} else {
window.location.href = tile.href! + (window.location.search || '');
}
} : undefined}
style={tile.href ? { cursor: 'pointer' } : undefined}
>
<div className="tile-icon" style={{ color: tile.color }}>
<Icon size={28} strokeWidth={1.8} />
</div>
<div className="tile-title">{tile.title}</div>
</Card>
);
return (
<Col key={tile.title} xs={12} sm={8} md={6} className="tile-col">
{card}
</Col>
);
})}
</Row>
</div>
);
}

View File

@@ -0,0 +1,34 @@
.profile-page {
min-height: 100vh;
padding: 24px;
padding-bottom: 90px;
background: #f5f7fb;
}
.profile-card {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
background: #ffffff;
}
.profile-card .ant-card-head {
border-bottom: 1px solid #f0f0f0;
}
.profile-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.profile-card .ant-descriptions-item-label {
font-weight: 500;
color: #374151;
background: #f9fafb !important;
}
.profile-card .ant-descriptions-item-content {
color: #111827;
}

View File

@@ -0,0 +1,481 @@
import { useEffect, useState, useCallback } from 'react';
import { Button, Card, Checkbox, Descriptions, Form, Input, Select, DatePicker, AutoComplete, Spin, Typography, message } from 'antd';
import { User } from 'lucide-react';
import dayjs from 'dayjs';
import './Profile.css';
const { Title, Text } = Typography;
const DATE_FORMAT = 'DD.MM.YYYY';
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия', editable: true },
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя', editable: true },
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество', editable: true },
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения', editable: true },
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения', editable: true },
{ key: 'inn', keys: ['inn'], label: 'ИНН', editable: true },
{ key: 'email', keys: ['email'], label: 'Электронная почта', editable: true },
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации', editable: true },
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес', editable: true },
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения', editable: true },
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон', editable: false },
];
function getValue(obj: Record<string, unknown>, keys: string[]): string {
for (const k of keys) {
const v = obj[k];
if (v != null && String(v).trim() !== '') return String(v).trim();
}
return '';
}
/** Парсим дату из строки (DD.MM.YYYY, YYYY-MM-DD и т.д.) в dayjs или null */
function parseBirthDate(s: string): dayjs.Dayjs | null {
if (!s || typeof s !== 'string') return null;
const trimmed = s.trim();
if (!trimmed) return null;
const d = dayjs(trimmed, [DATE_FORMAT, 'YYYY-MM-DD', 'DD.MM.YYYY'], true);
return d.isValid() ? d : null;
}
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
function canEditProfile(contact: Record<string, unknown>): boolean {
const v = contact?.verification ?? contact?.Verification;
return v === '0' || v === 0;
}
interface BankOption {
id?: string;
name?: string;
bankid?: string;
bankname?: string;
value?: string;
label?: string;
}
interface ProfileProps {
onNavigate?: (path: string) => void;
}
export default function Profile({ onNavigate }: ProfileProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
const [sameAsRegistration, setSameAsRegistration] = useState(false);
const [banks, setBanks] = useState<BankOption[]>([]);
const [banksLoading, setBanksLoading] = useState(false);
const [addressOptionsReg, setAddressOptionsReg] = useState<{ value: string }[]>([]);
const [addressOptionsMail, setAddressOptionsMail] = useState<{ value: string }[]>([]);
const [dadataLoadingReg, setDadataLoadingReg] = useState(false);
const [dadataLoadingMail, setDadataLoadingMail] = useState(false);
const loadBanks = useCallback(async () => {
setBanksLoading(true);
try {
const res = await fetch('/api/v1/banks/nspk');
if (!res.ok) {
setBanks([]);
return;
}
const data = await res.json().catch(() => []);
const list = Array.isArray(data) ? data : (data?.data || data?.items || []);
const normalized: BankOption[] = list.map((b: Record<string, unknown> | string) => {
if (typeof b === 'string') return { value: b, label: b };
const name = (b?.bankName ?? b?.bankname ?? b?.name ?? b?.title ?? b?.value ?? '').toString().trim();
const id = (b?.bankId ?? b?.bankid ?? b?.id ?? b?.value ?? name).toString().trim();
return { bankid: id, bankname: name, value: name, label: name };
}).filter((b: BankOption) => b.value || b.label);
setBanks(normalized);
} catch {
setBanks([]);
} finally {
setBanksLoading(false);
}
}, []);
const searchAddress = useCallback(async (query: string, setOptions: (o: { value: string }[]) => void, setLoading: (v: boolean) => void) => {
if (!query || query.length < 2) {
setOptions([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/v1/profile/dadata/address?query=${encodeURIComponent(query)}&count=10`);
const data = await res.json().catch(() => ({}));
const suggestions = (data?.suggestions || []).map((s: { value?: string; unrestricted_value?: string }) => ({
value: (s.unrestricted_value || s.value || '').trim(),
})).filter((s: { value: string }) => s.value);
setOptions(suggestions);
} catch {
setOptions([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (canEditProfile(contact || {})) loadBanks();
}, [contact, loadBanks]);
useEffect(() => {
let cancelled = false;
const token = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|| localStorage.getItem('session_token');
if (!token) {
setLoading(false);
onNavigate?.('/hello');
return;
}
const entryChannel =
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
: 'web';
const chatId = (() => {
if (typeof window === 'undefined') return undefined;
const tg = (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id;
if (tg != null) return String(tg);
const max = (window as any).WebApp?.initDataUnsafe?.user?.id;
if (max != null) return String(max);
return undefined;
})();
setLoading(true);
setError(null);
const params = new URLSearchParams({ session_token: token, entry_channel: entryChannel });
if (chatId) params.set('chat_id', chatId);
fetch(`/api/v1/profile/contact?${params.toString()}`)
.then((res) => {
if (!res.ok) {
if (res.status === 401) {
try { sessionStorage.removeItem('session_token'); } catch (_) {}
localStorage.removeItem('session_token');
throw new Error('Сессия истекла');
}
throw new Error('Ошибка загрузки');
}
return res.json();
})
.then((data: { items?: unknown[] }) => {
if (cancelled) return;
const items = Array.isArray(data?.items) ? data.items : [];
const first = items.length > 0 && typeof items[0] === 'object' && items[0] !== null
? (items[0] as Record<string, unknown>)
: null;
setContact(first);
if (first && canEditProfile(first)) {
const initial: Record<string, string | dayjs.Dayjs | null> = {};
PROFILE_FIELDS.forEach(({ key, keys }) => {
const raw = getValue(first, keys) || '';
if (key === 'birth_date') {
initial[key] = parseBirthDate(raw) || (raw ? dayjs(raw) : null);
} else {
initial[key] = raw;
}
});
const regAddr = (initial.registration_address as string) || '';
const mailAddr = (initial.mailing_address as string) || '';
setSameAsRegistration(!!regAddr && regAddr === mailAddr);
form.setFieldsValue(initial);
}
})
.catch((e) => {
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [onNavigate, form]);
const handleSameAsRegistrationChange = (e: { target: { checked: boolean } }) => {
const checked = e.target.checked;
setSameAsRegistration(checked);
if (checked) {
const reg = form.getFieldValue('registration_address') || '';
form.setFieldsValue({ mailing_address: reg });
}
};
const handleRegistrationAddressChange = () => {
if (sameAsRegistration) {
const reg = form.getFieldValue('registration_address') || '';
form.setFieldsValue({ mailing_address: reg });
}
};
const handleSave = async () => {
if (!contact || !canEditProfile(contact)) return;
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
if (!token) {
message.error('Сессия истекла');
onNavigate?.('/hello');
return;
}
const entryChannel =
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
: 'web';
let values: Record<string, string>;
try {
values = await form.validateFields();
} catch {
return;
}
const birthDateVal = values.birth_date;
const birthDateStr = dayjs.isDayjs(birthDateVal) ? birthDateVal.format(DATE_FORMAT) : (birthDateVal && String(birthDateVal).trim()) || '';
setSaving(true);
try {
const res = await fetch('/api/v1/profile/contact/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: token,
entry_channel: entryChannel,
last_name: values.last_name ?? '',
first_name: values.first_name ?? '',
middle_name: values.middle_name ?? '',
birth_date: birthDateStr,
birth_place: values.birth_place ?? '',
inn: values.inn ?? '',
email: values.email ?? '',
registration_address: values.registration_address ?? '',
mailing_address: values.mailing_address ?? '',
bank_for_compensation: values.bank_for_compensation ?? '',
phone: getValue(contact, ['phone', 'mobile', 'mobile_phone']) || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = typeof data?.detail === 'string' ? data.detail : data?.detail?.[0]?.msg || 'Не удалось сохранить профиль';
message.error(detail);
return;
}
message.success('Профиль сохранён');
setContact({ ...contact, ...values, birth_date: birthDateStr });
} catch (e) {
message.error('Не удалось сохранить профиль, попробуйте позже');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="profile-page">
<Card className="profile-card">
<div className="profile-loading">
<Spin size="large" tip="Загрузка профиля..." />
</div>
</Card>
</div>
);
}
if (error) {
return (
<div className="profile-page">
<Card className="profile-card">
<Title level={4}>Профиль</Title>
<Text type="danger">{error}</Text>
<div style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => onNavigate?.('/hello')}>
Войти снова
</Button>
</div>
</Card>
</div>
);
}
if (!contact) {
return (
<div className="profile-page">
<Card className="profile-card">
<Title level={4}>Профиль</Title>
<Text type="secondary">Контактных данных пока нет. Они появятся после обработки ваших обращений.</Text>
</Card>
</div>
);
}
const canEdit = canEditProfile(contact);
if (canEdit) {
return (
<div className="profile-page">
<Card
className="profile-card"
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
>
<Form form={form} layout="vertical" onFinish={handleSave}>
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => {
if (key === 'birth_date') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Укажите дату рождения' }]}
>
<DatePicker
format={DATE_FORMAT}
placeholder="Выберите дату"
style={{ width: '100%' }}
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
);
}
if (key === 'inn') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[
{ required: true, message: 'Введите ИНН' },
{ pattern: /^\d{12}$/, message: 'ИНН должен содержать ровно 12 цифр' },
]}
help={
<span style={{ fontSize: 12, color: 'var(--ant-color-text-secondary, rgba(0,0,0,0.45))' }}>
Узнать свой ИНН вы можете{' '}
<a href="https://service.nalog.ru/static/personal-data.html?svc=inn&from=%2Finn.do" target="_blank" rel="noopener noreferrer">
здесь
</a>
{' '}(сервис ФНС России).
</span>
}
>
<Input
maxLength={12}
placeholder="12 цифр"
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 12);
form.setFieldValue('inn', v);
}}
/>
</Form.Item>
);
}
if (key === 'email') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[
{ required: true, message: 'Введите электронную почту' },
{ type: 'email', message: 'Введите корректный email' },
]}
>
<Input type="email" placeholder="example@mail.ru" />
</Form.Item>
);
}
if (key === 'registration_address') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Обязательное поле' }]}
>
<AutoComplete
options={addressOptionsReg}
placeholder="Начните вводить адрес"
onSearch={(q) => searchAddress(q, setAddressOptionsReg, setDadataLoadingReg)}
onChange={handleRegistrationAddressChange}
notFoundContent={dadataLoadingReg ? 'Загрузка...' : null}
/>
</Form.Item>
);
}
if (key === 'mailing_address') {
return (
<>
<Form.Item style={{ marginBottom: 8 }}>
<Checkbox checked={sameAsRegistration} onChange={handleSameAsRegistrationChange}>
Совпадает с адресом регистрации
</Checkbox>
</Form.Item>
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Обязательное поле' }]}
>
<AutoComplete
options={addressOptionsMail}
placeholder="Начните вводить адрес"
onSearch={(q) => searchAddress(q, setAddressOptionsMail, setDadataLoadingMail)}
disabled={sameAsRegistration}
notFoundContent={dadataLoadingMail ? 'Загрузка...' : null}
/>
</Form.Item>
</>
);
}
if (key === 'bank_for_compensation') {
return (
<Form.Item
key={key}
name={key}
label={label}
rules={[{ required: true, message: 'Выберите банк' }]}
>
<Select
showSearch
placeholder={banksLoading ? 'Загрузка банков...' : 'Выберите банк'}
loading={banksLoading}
optionFilterProp="label"
filterOption={(input, opt) => (opt?.label ?? '').toString().toLowerCase().includes((input || '').toLowerCase())}
options={banks.map((b) => ({ value: b.value || b.bankname || b.label, label: b.label || b.bankname || b.value }))}
notFoundContent={banksLoading ? 'Загрузка...' : 'Банк не найден'}
/>
</Form.Item>
);
}
return (
<Form.Item
key={key}
name={key}
label={label}
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
>
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
</Form.Item>
);
})}
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
Сохранить изменения
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
const items = PROFILE_FIELDS.map(({ keys, label }) => ({
key: keys[0],
label,
children: getValue(contact, keys) || '—',
}));
return (
<div className="profile-page">
<Card className="profile-card" title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}>
<Descriptions column={1} size="small" bordered>
{items.map((item) => (
<Descriptions.Item key={item.key} label={item.label}>
{item.children}
</Descriptions.Item>
))}
</Descriptions>
</Card>
</div>
);
}

View File

@@ -0,0 +1,269 @@
/**
* Страница «Поддержка» — сначала список тикетов/тредов, по клику — чат или новое обращение.
*/
import { useEffect, useState } from 'react';
import { Button, List, Spin, Typography, message } from 'antd';
import { ArrowLeft, MessageCirclePlus } from 'lucide-react';
import SupportChat from '../components/SupportChat';
const { Title, Text } = Typography;
export interface SupportThreadItem {
thread_id: string;
claim_id: string | null;
source: string;
ticket_id: string | null;
created_at: string;
updated_at: string;
last_body: string | null;
last_at: string | null;
messages_count: number;
unread_count: number;
}
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 SupportProps {
onNavigate?: (path: string) => void;
}
function getViewportHeight(): number {
if (typeof window === 'undefined') return 600;
const vv = window.visualViewport;
return (vv?.height ?? window.innerHeight) || 600;
}
export default function Support({ onNavigate }: SupportProps) {
const [view, setView] = useState<'list' | 'chat'>('list');
const [threads, setThreads] = useState<SupportThreadItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedClaimId, setSelectedClaimId] = useState<string | null | undefined>(undefined);
const [viewportHeight, setViewportHeight] = useState(getViewportHeight);
useEffect(() => {
if (view !== 'list') return;
const token = getSessionToken();
if (!token) {
setLoading(false);
return;
}
const params = new URLSearchParams();
params.set('session_token', token);
fetch(`/api/v1/support/threads?${params.toString()}`)
.then((res) => {
if (res.status === 401) {
message.error('Сессия истекла. Обновите страницу или войдите снова.');
return { threads: [] };
}
if (!res.ok) {
message.error('Не удалось загрузить список обращений. Попробуйте позже.');
return { threads: [] };
}
return res.json();
})
.then((data) => {
setThreads(data.threads || []);
})
.catch(() => {
message.error('Ошибка соединения. Проверьте интернет и попробуйте снова.');
setThreads([]);
})
.finally(() => setLoading(false));
}, [view]);
const handleOpenThread = (claimId: string | null) => {
setSelectedClaimId(claimId);
setView('chat');
};
const handleBack = () => {
setView('list');
setSelectedClaimId(undefined);
};
useEffect(() => {
const onGoBack = () => {
if (view === 'chat') {
setView('list');
setSelectedClaimId(undefined);
} else {
onNavigate?.('/');
}
};
window.addEventListener('miniapp:goBack', onGoBack);
return () => window.removeEventListener('miniapp:goBack', onGoBack);
}, [view, onNavigate]);
useEffect(() => {
window.dispatchEvent(
new CustomEvent('miniapp:supportChatMode', {
detail: { active: view === 'chat' },
}),
);
return () => {
window.dispatchEvent(
new CustomEvent('miniapp:supportChatMode', {
detail: { active: false },
}),
);
};
}, [view]);
useEffect(() => {
if (view !== 'chat') return;
const vv = window.visualViewport;
if (!vv) return;
const update = () => setViewportHeight(getViewportHeight());
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, [view]);
if (view === 'chat') {
return (
<div
style={{
height: viewportHeight,
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto', flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Button
type="text"
icon={<ArrowLeft size={18} />}
onClick={handleBack}
style={{ marginBottom: 16, paddingLeft: 0, flexShrink: 0 }}
>
К списку обращений
</Button>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<SupportChat
claimId={selectedClaimId === null ? undefined : selectedClaimId ?? undefined}
source="bar"
onSuccess={() => {
handleBack();
if (onNavigate) onNavigate('/hello');
else {
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}}
/>
</div>
</div>
</div>
);
}
return (
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto' }}>
<Title level={2} style={{ marginBottom: 8 }}>
Поддержка
</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
Ваши обращения и переписка с поддержкой.
</Text>
{getSessionToken() && (
<Button
type="primary"
icon={<MessageCirclePlus size={18} style={{ marginRight: 6 }} />}
onClick={() => handleOpenThread(null)}
style={{ marginBottom: 24, width: '100%' }}
>
Новое обращение
</Button>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin />
</div>
) : !getSessionToken() ? (
<Text type="secondary">Войдите в аккаунт, чтобы видеть обращения и писать в поддержку.</Text>
) : threads.length === 0 ? (
<Text type="secondary">Пока нет обращений. Нажмите «Новое обращение», чтобы написать.</Text>
) : (
<List
itemLayout="vertical"
dataSource={threads}
renderItem={(item) => {
const title = item.claim_id
? `По заявке №${item.claim_id}`
: 'Общее обращение';
const sub = item.last_at
? new Date(item.last_at).toLocaleString('ru-RU')
: new Date(item.created_at).toLocaleString('ru-RU');
const preview = item.last_body
? item.last_body.replace(/\s+/g, ' ').trim().slice(0, 80) + (item.last_body.length > 80 ? '…' : '')
: 'Нет сообщений';
return (
<List.Item
key={item.thread_id}
style={{
padding: '14px 16px',
background: '#fafafa',
borderRadius: 12,
marginBottom: 10,
cursor: 'pointer',
border: '1px solid #f0f0f0',
}}
onClick={() => handleOpenThread(item.claim_id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text strong>{title}</Text>
{item.unread_count > 0 && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 20,
height: 20,
padding: '0 6px',
borderRadius: 10,
background: '#ff4d4f',
color: '#fff',
fontSize: 12,
fontWeight: 600,
}}
title="Непрочитанные сообщения"
>
{item.unread_count > 99 ? '99+' : item.unread_count}
</span>
)}
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{sub}</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 6 }}>{preview}</div>
</div>
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
{item.messages_count} сообщ.
</Text>
</div>
</List.Item>
);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
type MiniappLogEntry = {
ts: number;
iso: string;
event: string;
build?: string;
data?: unknown;
};
declare global {
interface Window {
__MINIAPP_LOGS__?: MiniappLogEntry[];
__MINIAPP_BUILD__?: string;
}
}
const LS_KEY = 'miniapp_debug_logs_v1';
const LS_BUILD_KEY = 'miniapp_debug_build_v1';
const MAX_ENTRIES = 250;
function safeJsonParse<T>(s: string | null): T | null {
if (!s) return null;
try {
return JSON.parse(s) as T;
} catch {
return null;
}
}
function getBuffer(): MiniappLogEntry[] {
if (!window.__MINIAPP_LOGS__) {
window.__MINIAPP_LOGS__ = safeJsonParse<MiniappLogEntry[]>(localStorage.getItem(LS_KEY)) || [];
}
return window.__MINIAPP_LOGS__;
}
function getBuildTag(): string | undefined {
const b = window.__MINIAPP_BUILD__;
return typeof b === 'string' && b.length ? b : undefined;
}
function persist(buf: MiniappLogEntry[]) {
try {
const trimmed = buf.slice(-MAX_ENTRIES);
localStorage.setItem(LS_KEY, JSON.stringify(trimmed));
} catch {
// ignore
}
}
export function miniappLog(event: string, data?: unknown) {
const entry: MiniappLogEntry = {
ts: Date.now(),
iso: new Date().toISOString(),
event,
build: getBuildTag(),
data,
};
const buf = getBuffer();
buf.push(entry);
if (buf.length > MAX_ENTRIES * 2) {
window.__MINIAPP_LOGS__ = buf.slice(-MAX_ENTRIES);
}
persist(window.__MINIAPP_LOGS__ || buf);
// В TG/MAX консоль не всегда доступна, но пусть будет
// eslint-disable-next-line no-console
console.log('[MINIAPP][LOG]', entry.event, entry.data || '');
}
export function miniappDumpLogs() {
return getBuffer().slice(-MAX_ENTRIES);
}
export async function miniappSendLogs(reason: string) {
// Если билд сменился — не мешаем старые логи с новыми.
// Это не “фикс бага”, а гарантия чистой диагностики.
try {
const cur = getBuildTag();
const prev = localStorage.getItem(LS_BUILD_KEY) || '';
if (cur && prev && cur !== prev) {
localStorage.removeItem(LS_KEY);
window.__MINIAPP_LOGS__ = [];
}
if (cur) localStorage.setItem(LS_BUILD_KEY, cur);
} catch {
// ignore
}
const payload = {
reason,
client: {
href: window.location.href,
origin: window.location.origin,
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
ua: navigator.userAgent,
referrer: document.referrer,
ts: Date.now(),
},
logs: miniappDumpLogs(),
};
try {
await fetch('/api/v1/utils/client-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch {
// ignore
}
}

View File

@@ -1,5 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Полифилл crypto.getRandomValues для Node 16 — подключать через: node -r ./scripts/crypto-polyfill.cjs ...
export default defineConfig({
plugins: [react()],
@@ -10,6 +11,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 3000,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://host.docker.internal:8201',