Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h
This commit is contained in:
23
DOCKER-COMPOSE-README.md
Normal file
23
DOCKER-COMPOSE-README.md
Normal 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. Оставлены для истории/других окружений.
|
||||||
@@ -85,14 +85,23 @@ async def login(request: Auth2LoginRequest):
|
|||||||
|
|
||||||
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||||
n8n_data = jsonable_encoder(n8n_response)
|
n8n_data = jsonable_encoder(n8n_response)
|
||||||
|
_result = n8n_data.get("result")
|
||||||
|
_result_dict = _result if isinstance(_result, dict) else {}
|
||||||
|
|
||||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
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")
|
has_drafts = n8n_data.get("has_drafts")
|
||||||
|
|
||||||
if not unified_id:
|
if not unified_id:
|
||||||
raise HTTPException(status_code=500, detail="n8n не вернул 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(
|
await session_api.create_session(session_api.SessionCreateRequest(
|
||||||
session_token=session_token,
|
session_token=session_token,
|
||||||
@@ -100,6 +109,7 @@ async def login(request: Auth2LoginRequest):
|
|||||||
phone=phone or "",
|
phone=phone or "",
|
||||||
contact_id=contact_id or "",
|
contact_id=contact_id or "",
|
||||||
ttl_hours=24,
|
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 ""
|
first_name = tg_user.get("first_name") or ""
|
||||||
@@ -143,18 +153,23 @@ async def login(request: Auth2LoginRequest):
|
|||||||
|
|
||||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||||
n8n_data = jsonable_encoder(n8n_response)
|
n8n_data = jsonable_encoder(n8n_response)
|
||||||
|
_result = n8n_data.get("result")
|
||||||
|
_result_dict = _result if isinstance(_result, dict) else {}
|
||||||
|
|
||||||
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
|
_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:
|
if need_contact:
|
||||||
|
logger.info("[AUTH2] MAX: n8n need_contact — возвращаем need_contact=true")
|
||||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||||
|
|
||||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("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 n8n_data.get("result", {}).get("contact_id")
|
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||||
has_drafts = n8n_data.get("has_drafts")
|
has_drafts = n8n_data.get("has_drafts")
|
||||||
|
|
||||||
if not unified_id:
|
if not unified_id:
|
||||||
raise HTTPException(status_code=500, detail="n8n не вернул 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(
|
await session_api.create_session(session_api.SessionCreateRequest(
|
||||||
session_token=session_token,
|
session_token=session_token,
|
||||||
@@ -162,6 +177,7 @@ async def login(request: Auth2LoginRequest):
|
|||||||
phone=phone or "",
|
phone=phone or "",
|
||||||
contact_id=contact_id or "",
|
contact_id=contact_id or "",
|
||||||
ttl_hours=24,
|
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 ""
|
first_name = max_user.get("first_name") or ""
|
||||||
|
|||||||
204
backend/app/api/auth_universal.py
Normal file
204
backend/app/api/auth_universal.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Универсальный auth: один endpoint для TG и MAX.
|
||||||
|
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
|
||||||
|
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, Any, Dict
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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 обязателен")
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
webhook_url = (getattr(settings, "n8n_auth_webhook", None) 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,
|
||||||
|
}
|
||||||
|
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):
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
|
||||||
|
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку")
|
||||||
|
return AuthUniversalResponse(
|
||||||
|
success=False,
|
||||||
|
need_contact=False,
|
||||||
|
message=(data.get("message") or "Ошибка авторизации."),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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}
|
||||||
|
session_data = {
|
||||||
|
"unified_id": unified_id,
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ from datetime import datetime
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from ..services.redis_service import redis_service
|
from ..services.redis_service import redis_service
|
||||||
from ..services.database import db
|
from ..services.database import db
|
||||||
from ..services.crm_mysql_service import crm_mysql_service
|
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"])
|
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
@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:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
webhook_url,
|
||||||
data=data,
|
data=data,
|
||||||
files=files or None,
|
files=files or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
text = response.text or ""
|
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:
|
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(
|
logger.info(
|
||||||
"✅ TicketForm wizard webhook OK",
|
"✅ TicketForm wizard webhook OK",
|
||||||
extra={"response_preview": text[:500]},
|
extra={"response_preview": text[:500]},
|
||||||
@@ -121,9 +141,10 @@ async def create_claim(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Проксируем запрос к n8n
|
# Проксируем запрос к n8n
|
||||||
|
webhook_url = _get_ticket_form_webhook()
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
webhook_url,
|
||||||
json=body,
|
json=body,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
@@ -962,12 +983,44 @@ async def load_wizard_data(claim_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
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():
|
async def _send_buffered_messages_to_webhook():
|
||||||
"""
|
"""
|
||||||
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not settings.n8n_description_webhook:
|
description_webhook_url = _get_description_webhook_url()
|
||||||
|
if not description_webhook_url:
|
||||||
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -998,7 +1051,7 @@ async def _send_buffered_messages_to_webhook():
|
|||||||
]
|
]
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
settings.n8n_description_webhook,
|
description_webhook_url,
|
||||||
json=webhook_payload, # Отправляем в формате массива
|
json=webhook_payload, # Отправляем в формате массива
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"}
|
||||||
)
|
)
|
||||||
@@ -1059,27 +1112,53 @@ async def publish_ticket_form_description(
|
|||||||
background_tasks: BackgroundTasks
|
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:
|
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(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="N8N description webhook не настроен"
|
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
|
# Формируем данные в формате, который ожидает n8n workflow
|
||||||
channel = payload.channel or f"{settings.redis_prefix}description"
|
channel = payload.channel or f"{settings.redis_prefix}description"
|
||||||
message = {
|
message = {
|
||||||
"type": "ticket_form_description",
|
"type": "ticket_form_description",
|
||||||
"session_id": payload.session_id,
|
"session_id": payload.session_id,
|
||||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||||
"phone": payload.phone,
|
"phone": phone,
|
||||||
"email": payload.email,
|
"email": payload.email,
|
||||||
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
|
"unified_id": unified_id, # из запроса или из сессии Redis
|
||||||
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
|
"contact_id": contact_id,
|
||||||
"description": payload.problem_description.strip(),
|
"description": payload.problem_description.strip(),
|
||||||
"source": payload.source,
|
"source": payload.source,
|
||||||
|
"entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1092,13 +1171,11 @@ async def publish_ticket_form_description(
|
|||||||
]
|
]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"📝 TicketForm description received",
|
"📝 TicketForm description received → webhook=%s",
|
||||||
|
description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""),
|
||||||
extra={
|
extra={
|
||||||
"session_id": payload.session_id,
|
"session_id": payload.session_id,
|
||||||
"claim_id": payload.claim_id or "not_set",
|
"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),
|
"description_length": len(payload.problem_description),
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
},
|
},
|
||||||
@@ -1114,23 +1191,44 @@ async def publish_ticket_form_description(
|
|||||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||||
extra={"session_id": payload.session_id}
|
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:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
settings.n8n_description_webhook,
|
description_webhook_url,
|
||||||
json=webhook_payload, # Отправляем в формате массива
|
json=webhook_payload, # Отправляем в формате массива
|
||||||
headers={"Content-Type": "application/json"}
|
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:
|
if response.status_code == 200:
|
||||||
|
response_body = response.text or ""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
|
"✅ Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s",
|
||||||
extra={
|
attempt,
|
||||||
"session_id": payload.session_id,
|
len(response_body),
|
||||||
"status_code": response.status_code,
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -9,12 +9,108 @@ from pydantic import BaseModel
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from app.services.redis_service import redis_service
|
from app.services.redis_service import redis_service
|
||||||
from app.services.database import db
|
from app.services.database import db
|
||||||
|
from app.config import settings
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
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):
|
class EventPublish(BaseModel):
|
||||||
"""Модель для публикации события"""
|
"""Модель для публикации события"""
|
||||||
@@ -84,7 +180,10 @@ async def stream_events(task_id: str):
|
|||||||
Returns:
|
Returns:
|
||||||
StreamingResponse с событиями
|
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():
|
async def event_generator():
|
||||||
"""Генератор событий из Redis Pub/Sub"""
|
"""Генератор событий из Redis Pub/Sub"""
|
||||||
@@ -95,7 +194,10 @@ async def stream_events(task_id: str):
|
|||||||
pubsub = redis_service.client.pubsub()
|
pubsub = redis_service.client.pubsub()
|
||||||
await pubsub.subscribe(channel)
|
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"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {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_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
event_type_sent = actual_event.get("event_type", "unknown")
|
||||||
event_status = actual_event.get('status', 'unknown')
|
event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown"
|
||||||
# Логируем размер и наличие данных
|
# Логируем размер и наличие данных
|
||||||
data_info = actual_event.get('data', {})
|
data_info = actual_event.get('data', {})
|
||||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
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 (ждём ещё события)
|
# НЕ закрываем для 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")
|
logger.info(f"✅ Task {task_id} finished, closing SSE")
|
||||||
break
|
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']:
|
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Закрываем для ocr_status ready (форма заявления готова)
|
# Закрываем для ocr_status ready (форма заявления готова)
|
||||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
if raw_event_type == "ocr_status" and raw_status == "ready":
|
||||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
logger.info("✅ OCR ready event sent, closing SSE")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
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():
|
async def claim_plan_generator():
|
||||||
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
|
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
|
||||||
@@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str):
|
|||||||
pubsub = redis_service.client.pubsub()
|
pubsub = redis_service.client.pubsub()
|
||||||
await pubsub.subscribe(channel)
|
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"
|
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"
|
||||||
|
|||||||
@@ -106,19 +106,28 @@ async def max_auth(request: MaxAuthRequest):
|
|||||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
|
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
|
||||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||||
|
|
||||||
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
|
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:
|
if need_contact:
|
||||||
logger.info("[MAX] n8n: need_contact — юзер не в базе, закрываем приложение")
|
logger.info("[MAX] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||||
return MaxAuthResponse(success=False, need_contact=True)
|
return MaxAuthResponse(success=False, need_contact=True)
|
||||||
|
|
||||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("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 n8n_data.get("result", {}).get("contact_id")
|
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 n8n_data.get("result", {}).get("phone")
|
phone_res = n8n_data.get("phone") or _result_dict.get("phone")
|
||||||
has_drafts = n8n_data.get("has_drafts")
|
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||||
|
|
||||||
if not unified_id:
|
if not unified_id:
|
||||||
logger.error("[MAX] n8n не вернул unified_id. Ответ: %s", n8n_data)
|
logger.info("[MAX] n8n не вернул unified_id (юзер не в базе) — возвращаем need_contact=true. Ответ: %s", n8n_data)
|
||||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для пользователя MAX")
|
return MaxAuthResponse(success=False, need_contact=True)
|
||||||
|
|
||||||
session_request = session_api.SessionCreateRequest(
|
session_request = session_api.SessionCreateRequest(
|
||||||
session_token=session_token,
|
session_token=session_token,
|
||||||
@@ -126,6 +135,7 @@ async def max_auth(request: MaxAuthRequest):
|
|||||||
phone=phone_res or phone or "",
|
phone=phone_res or phone or "",
|
||||||
contact_id=contact_id or "",
|
contact_id=contact_id or "",
|
||||||
ttl_hours=24,
|
ttl_hours=24,
|
||||||
|
chat_id=str(max_user_id) if max_user_id else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -75,4 +75,5 @@ class TicketFormDescriptionRequest(BaseModel):
|
|||||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||||
source: str = Field("ticket_form", description="Источник события")
|
source: str = Field("ticket_form", description="Источник события")
|
||||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||||
|
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web — для роутинга в n8n")
|
||||||
|
|
||||||
|
|||||||
208
backend/app/api/profile.py
Normal file
208
backend/app/api/profile.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Профиль пользователя: контактные данные из CRM через n8n webhook.
|
||||||
|
|
||||||
|
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
|
||||||
|
unified_id берётся из сессии по session_token или передаётся явно.
|
||||||
|
|
||||||
|
----- Что уходит на N8N_CONTACT_WEBHOOK (POST body) -----
|
||||||
|
- unified_id (str): идентификатор пользователя в CRM
|
||||||
|
- entry_channel (str): "telegram" | "max" | "web"
|
||||||
|
- chat_id (str, опционально): Telegram user id или Max user id
|
||||||
|
- session_token, contact_id, phone (опционально)
|
||||||
|
|
||||||
|
----- Как n8n должен возвращать ответ -----
|
||||||
|
|
||||||
|
1) Ничего не нашло (контакт не найден в CRM или нет данных):
|
||||||
|
- HTTP 200
|
||||||
|
- Тело: пустой массив [] ИЛИ объект {"items": []}
|
||||||
|
Пример: [] или {"items": []}
|
||||||
|
|
||||||
|
2) Нашло контакт(ы):
|
||||||
|
- HTTP 200
|
||||||
|
- Тело: массив контактов ИЛИ объект с полем items/contact/data:
|
||||||
|
• [] → нормализуется в {"items": []}
|
||||||
|
• {"items": [...]} → как есть
|
||||||
|
• {"contact": {...}} → один контакт в items
|
||||||
|
• {"contact": [...]} → массив в items
|
||||||
|
• {"data": [...]} → массив в items
|
||||||
|
• один объект {...} → один элемент в items
|
||||||
|
|
||||||
|
Поля контакта (snake_case или camelCase, фронт смотрит оба):
|
||||||
|
last_name/lastName, first_name/firstName, middle_name/middleName,
|
||||||
|
birth_date/birthDate, birth_place/birthPlace, inn, email,
|
||||||
|
registration_address/address/mailingstreet, mailing_address/postal_address,
|
||||||
|
bank_for_compensation/bank, phone/mobile/mobile_phone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
|
||||||
|
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)")
|
||||||
|
|
||||||
|
|
||||||
|
@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 не настроен",
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_id: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
# Сессия по channel + channel_user_id (универсальный auth пишет в Redis по этому ключу)
|
||||||
|
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="Сессия недействительна или истекла")
|
||||||
|
|
||||||
|
# Сессия по session_token
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
# В хук уходит всё, что нужно для обработки в n8n: канал, unified_id, chat_id, contact_id, phone, session_token
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
# Нормализация ответа n8n в единый формат { "items": [...] }
|
||||||
|
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": []}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
Session management API endpoints
|
Session management API endpoints
|
||||||
|
|
||||||
Обеспечивает управление сессиями пользователей через Redis:
|
Обеспечивает управление сессиями пользователей через Redis:
|
||||||
- Верификация существующей сессии
|
- Верификация по session_token или по (channel, channel_user_id)
|
||||||
|
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
|
||||||
- Logout (удаление сессии)
|
- Logout (удаление сессии)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -22,13 +23,83 @@ router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
|||||||
# Redis connection (используем существующее подключение)
|
# Redis connection (используем существующее подключение)
|
||||||
redis_client: Optional[redis.Redis] = None
|
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
|
global redis_client
|
||||||
redis_client = redis_conn
|
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
|
||||||
|
if ttl:
|
||||||
|
await redis_client.setex(key, ttl, json.dumps(payload))
|
||||||
|
else:
|
||||||
|
await redis_client.set(key, json.dumps(payload))
|
||||||
|
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
|
||||||
|
if ttl:
|
||||||
|
await redis_client.setex(key, ttl, json.dumps(payload))
|
||||||
|
else:
|
||||||
|
await redis_client.set(key, json.dumps(payload))
|
||||||
|
|
||||||
|
|
||||||
class SessionVerifyRequest(BaseModel):
|
class SessionVerifyRequest(BaseModel):
|
||||||
session_token: str
|
session_token: str
|
||||||
|
|
||||||
@@ -39,10 +110,16 @@ class SessionVerifyResponse(BaseModel):
|
|||||||
unified_id: Optional[str] = None
|
unified_id: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
contact_id: Optional[str] = None
|
contact_id: Optional[str] = None
|
||||||
|
chat_id: Optional[str] = None # telegram_user_id или max_user_id
|
||||||
verified_at: Optional[str] = None
|
verified_at: Optional[str] = None
|
||||||
expires_in_seconds: Optional[int] = None
|
expires_in_seconds: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionVerifyByChannelRequest(BaseModel):
|
||||||
|
channel: str # tg | max
|
||||||
|
channel_user_id: str
|
||||||
|
|
||||||
|
|
||||||
class SessionLogoutRequest(BaseModel):
|
class SessionLogoutRequest(BaseModel):
|
||||||
session_token: str
|
session_token: str
|
||||||
|
|
||||||
@@ -92,6 +169,7 @@ async def verify_session(request: SessionVerifyRequest):
|
|||||||
unified_id=session_data.get('unified_id'),
|
unified_id=session_data.get('unified_id'),
|
||||||
phone=session_data.get('phone'),
|
phone=session_data.get('phone'),
|
||||||
contact_id=session_data.get('contact_id'),
|
contact_id=session_data.get('contact_id'),
|
||||||
|
chat_id=session_data.get('chat_id'),
|
||||||
verified_at=session_data.get('verified_at'),
|
verified_at=session_data.get('verified_at'),
|
||||||
expires_in_seconds=ttl if ttl > 0 else None
|
expires_in_seconds=ttl if ttl > 0 else None
|
||||||
)
|
)
|
||||||
@@ -143,20 +221,47 @@ async def logout_session(request: SessionLogoutRequest):
|
|||||||
raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}")
|
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):
|
class SessionCreateRequest(BaseModel):
|
||||||
session_token: str
|
session_token: str
|
||||||
unified_id: str
|
unified_id: str
|
||||||
phone: str
|
phone: str
|
||||||
contact_id: str
|
contact_id: str
|
||||||
ttl_hours: int = 24
|
ttl_hours: int = 24
|
||||||
|
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create")
|
@router.post("/create")
|
||||||
async def create_session(request: SessionCreateRequest):
|
async def create_session(request: SessionCreateRequest):
|
||||||
"""
|
"""
|
||||||
Создать новую сессию (вызывается после успешной SMS верификации)
|
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
|
||||||
|
|
||||||
Обычно вызывается из Step1Phone после получения данных от n8n.
|
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not redis_client:
|
if not redis_client:
|
||||||
@@ -171,6 +276,8 @@ async def create_session(request: SessionCreateRequest):
|
|||||||
'verified_at': datetime.utcnow().isoformat(),
|
'verified_at': datetime.utcnow().isoformat(),
|
||||||
'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).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
|
# Сохраняем в Redis с TTL
|
||||||
await redis_client.setex(
|
await redis_client.setex(
|
||||||
|
|||||||
@@ -31,11 +31,12 @@ class TelegramAuthRequest(BaseModel):
|
|||||||
|
|
||||||
class TelegramAuthResponse(BaseModel):
|
class TelegramAuthResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
session_token: str
|
session_token: Optional[str] = None
|
||||||
unified_id: str
|
unified_id: Optional[str] = None
|
||||||
contact_id: Optional[str] = None
|
contact_id: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
has_drafts: Optional[bool] = None
|
has_drafts: Optional[bool] = None
|
||||||
|
need_contact: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
def _generate_session_token() -> str:
|
def _generate_session_token() -> str:
|
||||||
@@ -114,15 +115,35 @@ async def telegram_auth(request: TelegramAuthRequest):
|
|||||||
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
|
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
|
||||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||||
|
|
||||||
# Ожидаем от n8n как минимум unified_id
|
# Логируем сырой ответ n8n для отладки (ключи и need_contact/unified_id)
|
||||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
logger.info("[TG] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
_result = n8n_data.get("result")
|
||||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
_result_dict = _result if isinstance(_result, dict) else {}
|
||||||
has_drafts = n8n_data.get("has_drafts")
|
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:
|
if not unified_id:
|
||||||
logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
|
logger.info("[TG] n8n не вернул unified_id (пользователь не в базе) — возвращаем need_contact=true. Ответ n8n: %s", n8n_data)
|
||||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
|
return TelegramAuthResponse(success=False, need_contact=True)
|
||||||
|
|
||||||
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
|
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
|
||||||
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
|
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
|
||||||
@@ -132,6 +153,7 @@ async def telegram_auth(request: TelegramAuthRequest):
|
|||||||
phone=phone or "",
|
phone=phone or "",
|
||||||
contact_id=contact_id or "",
|
contact_id=contact_id or "",
|
||||||
ttl_hours=24,
|
ttl_hours=24,
|
||||||
|
chat_id=str(telegram_user_id) if telegram_user_id else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Settings(BaseSettings):
|
|||||||
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
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_host: str = "localhost"
|
||||||
redis_port: int = 6379
|
redis_port: int = 6379
|
||||||
@@ -70,13 +70,26 @@ class Settings(BaseSettings):
|
|||||||
redis_db: int = 0
|
redis_db: int = 0
|
||||||
redis_prefix: str = "ticket_form:"
|
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
|
@property
|
||||||
def redis_url(self) -> str:
|
def redis_url(self) -> str:
|
||||||
"""Формирует URL для подключения к Redis"""
|
"""Формирует URL для подключения к Redis (внешний)"""
|
||||||
if self.redis_password:
|
if self.redis_password:
|
||||||
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
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}"
|
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
|
# RABBITMQ
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -184,9 +197,14 @@ class Settings(BaseSettings):
|
|||||||
n8n_file_upload_webhook: str = ""
|
n8n_file_upload_webhook: str = ""
|
||||||
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
|
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_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)
|
||||||
|
# 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)
|
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
|
||||||
|
|
||||||
|
# Контактные данные из CRM для раздела «Профиль» (массив или пусто)
|
||||||
|
n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# TELEGRAM BOT
|
# TELEGRAM BOT
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -197,6 +215,7 @@ class Settings(BaseSettings):
|
|||||||
# ============================================
|
# ============================================
|
||||||
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
|
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
|
||||||
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
|
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
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# LOGGING
|
# LOGGING
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
from .config import settings, get_cors_origins_live, get_settings
|
from .config import settings, get_cors_origins_live, get_settings
|
||||||
from .services.database import db
|
from .services.database import db
|
||||||
from .services.redis_service import redis_service
|
from .services.redis_service import redis_service
|
||||||
@@ -17,7 +18,7 @@ from .services.rabbitmq_service import rabbitmq_service
|
|||||||
from .services.policy_service import policy_service
|
from .services.policy_service import policy_service
|
||||||
from .services.crm_mysql_service import crm_mysql_service
|
from .services.crm_mysql_service import crm_mysql_service
|
||||||
from .services.s3_service import s3_service
|
from .services.s3_service import s3_service
|
||||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, documents_draft_open
|
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
|
||||||
from .api import debug_session
|
from .api import debug_session
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
@@ -119,13 +120,24 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(f"⚠️ PostgreSQL not available: {e}")
|
logger.warning(f"⚠️ PostgreSQL not available: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Подключаем Redis
|
# Подключаем внешний Redis (события, буферы, SMS и т.д.)
|
||||||
await redis_service.connect()
|
await redis_service.connect()
|
||||||
# Инициализируем session API с Redis connection
|
|
||||||
session.init_redis(redis_service.client)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Redis not available: {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:
|
try:
|
||||||
# Подключаем RabbitMQ
|
# Подключаем RabbitMQ
|
||||||
await rabbitmq_service.connect()
|
await rabbitmq_service.connect()
|
||||||
@@ -159,6 +171,9 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
await db.disconnect()
|
await db.disconnect()
|
||||||
await redis_service.disconnect()
|
await redis_service.disconnect()
|
||||||
|
if session.redis_client:
|
||||||
|
await session.redis_client.close()
|
||||||
|
session.init_redis(None)
|
||||||
await rabbitmq_service.disconnect()
|
await rabbitmq_service.disconnect()
|
||||||
await policy_service.close()
|
await policy_service.close()
|
||||||
await crm_mysql_service.close()
|
await crm_mysql_service.close()
|
||||||
@@ -226,6 +241,8 @@ app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
|
|||||||
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
||||||
app.include_router(max_auth.router) # 📱 MAX 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(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(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
|
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
|
||||||
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
|
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
|
||||||
|
|
||||||
|
|||||||
51
docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js
Normal file
51
docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js
Normal 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 } }];
|
||||||
31
docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md
Normal file
31
docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md
Normal 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.
|
||||||
95
docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md
Normal file
95
docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md
Normal 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.
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import ClaimForm from './pages/ClaimForm';
|
import ClaimForm from './pages/ClaimForm';
|
||||||
import HelloAuth from './pages/HelloAuth';
|
import HelloAuth from './pages/HelloAuth';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
import BottomBar from './components/BottomBar';
|
import BottomBar from './components/BottomBar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
|
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
|
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 [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||||
const lastRouteTsRef = useRef<number>(Date.now());
|
const lastRouteTsRef = useRef<number>(Date.now());
|
||||||
const lastPathRef = useRef<string>(pathname);
|
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(() => {
|
useEffect(() => {
|
||||||
const onPopState = () => setPathname(window.location.pathname || '');
|
const onPopState = () => setPathname(window.location.pathname || '');
|
||||||
window.addEventListener('popstate', onPopState);
|
window.addEventListener('popstate', onPopState);
|
||||||
@@ -65,12 +77,14 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
{pathname.startsWith('/hello') ? (
|
{pathname === '/profile' ? (
|
||||||
|
<Profile onNavigate={navigateTo} />
|
||||||
|
) : pathname.startsWith('/hello') ? (
|
||||||
<HelloAuth onAvatarChange={setAvatarUrl} onNavigate={navigateTo} />
|
<HelloAuth onAvatarChange={setAvatarUrl} onNavigate={navigateTo} />
|
||||||
) : (
|
) : (
|
||||||
<ClaimForm forceNewClaim={isNewClaimPage} />
|
<ClaimForm forceNewClaim={isNewClaimPage} />
|
||||||
)}
|
)}
|
||||||
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} />
|
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} onNavigate={navigateTo} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,24 @@ import { miniappLog } from '../utils/miniappLogger';
|
|||||||
interface BottomBarProps {
|
interface BottomBarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
onNavigate?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
export default function BottomBar({ currentPath, avatarUrl, onNavigate }: BottomBarProps) {
|
||||||
const isHome = currentPath.startsWith('/hello');
|
const isHome = currentPath.startsWith('/hello');
|
||||||
|
const isProfile = currentPath === '/profile';
|
||||||
const [backEnabled, setBackEnabled] = useState(false);
|
const [backEnabled, setBackEnabled] = useState(false);
|
||||||
|
|
||||||
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться
|
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isHome) {
|
if (isHome || isProfile) {
|
||||||
setBackEnabled(false);
|
setBackEnabled(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBackEnabled(false);
|
setBackEnabled(false);
|
||||||
const t = window.setTimeout(() => setBackEnabled(true), 1200);
|
const t = window.setTimeout(() => setBackEnabled(true), 1200);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [isHome, currentPath]);
|
}, [isHome, isProfile, currentPath]);
|
||||||
|
|
||||||
const handleBack = (e: React.MouseEvent) => {
|
const handleBack = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -35,7 +37,7 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const tgWebApp = (window as any).Telegram?.WebApp;
|
const tgWebApp = (window as any).Telegram?.WebApp;
|
||||||
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
||||||
const isTg =
|
const hasTgContext =
|
||||||
tgInitData.length > 0 ||
|
tgInitData.length > 0 ||
|
||||||
window.location.href.includes('tgWebAppData') ||
|
window.location.href.includes('tgWebAppData') ||
|
||||||
navigator.userAgent.includes('Telegram');
|
navigator.userAgent.includes('Telegram');
|
||||||
@@ -43,45 +45,70 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
|||||||
const maxWebApp = (window as any).WebApp;
|
const maxWebApp = (window as any).WebApp;
|
||||||
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
||||||
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
||||||
const isMax =
|
const hasMaxContext =
|
||||||
maxInitData.length > 0 ||
|
maxInitData.length > 0 ||
|
||||||
(typeof maxStartParam === 'string' && maxStartParam.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', {
|
miniappLog('bottom_bar_exit_click', {
|
||||||
currentPath,
|
currentPath,
|
||||||
isTg,
|
hasTgContext,
|
||||||
isMax,
|
hasMaxContext,
|
||||||
tgInitDataLen: tgInitData.length,
|
tgInitDataLen: tgInitData.length,
|
||||||
maxInitDataLen: maxInitData.length,
|
maxInitDataLen: maxInitData.length,
|
||||||
hasTgClose: typeof tgWebApp?.close === 'function',
|
hasTgClose: hasTgWebApp,
|
||||||
hasMaxClose: typeof maxWebApp?.close === 'function',
|
hasMaxClose: hasMaxWebApp,
|
||||||
hasMaxPostEvent: typeof maxWebApp?.postEvent === 'function',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ВАЖНО: telegram-web-app.js может объявлять Telegram.WebApp.close() вне Telegram.
|
// ВАЖНО: выбираем платформу по контексту (URL/UA/initData). Если оба есть — приоритет у того, у кого есть initData.
|
||||||
// Поэтому выбираем платформу по реальному initData, иначе в MAX будем вызывать TG close и рано выходить.
|
if (hasTgContext && hasTgWebApp && !hasMaxContext) {
|
||||||
if (isTg) {
|
|
||||||
try {
|
try {
|
||||||
if (typeof tgWebApp?.close === 'function') {
|
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
||||||
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
tgWebApp.close();
|
||||||
tgWebApp.close();
|
return;
|
||||||
return;
|
} catch (err) {
|
||||||
}
|
miniappLog('bottom_bar_exit_error', { platform: 'tg', error: String(err) });
|
||||||
} catch (_) {}
|
}
|
||||||
}
|
}
|
||||||
|
if (hasMaxContext && hasMaxWebApp) {
|
||||||
if (isMax) {
|
|
||||||
try {
|
try {
|
||||||
if (typeof maxWebApp?.close === 'function') {
|
if (typeof maxWebApp.close === 'function') {
|
||||||
miniappLog('bottom_bar_exit_close', { platform: 'max' });
|
miniappLog('bottom_bar_exit_close', { platform: 'max' });
|
||||||
maxWebApp.close();
|
maxWebApp.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof maxWebApp?.postEvent === 'function') {
|
if (typeof maxWebApp.postEvent === 'function') {
|
||||||
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
|
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
|
||||||
maxWebApp.postEvent('web_app_close');
|
maxWebApp.postEvent('web_app_close');
|
||||||
return;
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +119,7 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="app-bottom-bar" aria-label="Навигация">
|
<nav className="app-bottom-bar" aria-label="Навигация">
|
||||||
{!isHome && (
|
{!isHome && !isProfile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="app-bar-item"
|
className="app-bar-item"
|
||||||
@@ -104,11 +131,29 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
|||||||
<span>Назад</span>
|
<span>Назад</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
|
<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} />
|
<Home size={24} strokeWidth={1.8} />
|
||||||
<span>Домой</span>
|
<span>Домой</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/hello" className="app-bar-item">
|
<a
|
||||||
|
href="/profile"
|
||||||
|
className={`app-bar-item ${isProfile ? 'app-bar-item--active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (onNavigate && !isProfile) {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate('/profile');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img src={avatarUrl} alt="" className="app-bar-avatar" />
|
<img src={avatarUrl} alt="" className="app-bar-avatar" />
|
||||||
) : (
|
) : (
|
||||||
@@ -116,7 +161,16 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
|||||||
)}
|
)}
|
||||||
<span>Профиль</span>
|
<span>Профиль</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/hello" className="app-bar-item">
|
<a
|
||||||
|
href="/hello"
|
||||||
|
className="app-bar-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (onNavigate && !currentPath.startsWith('/hello')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigate('/hello');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Headphones size={24} strokeWidth={1.8} />
|
<Headphones size={24} strokeWidth={1.8} />
|
||||||
<span>Поддержка</span>
|
<span>Поддержка</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -75,14 +75,18 @@ export default function StepDescription({
|
|||||||
return;
|
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('📝 Отправка описания проблемы на сервер:', {
|
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||||
session_id: formData.session_id,
|
session_id: formData.session_id,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
email: formData.email,
|
|
||||||
unified_id: formData.unified_id,
|
unified_id: formData.unified_id,
|
||||||
contact_id: formData.contact_id,
|
contact_id: formData.contact_id,
|
||||||
|
entry_channel: entryChannel,
|
||||||
description_length: safeDescription.length,
|
description_length: safeDescription.length,
|
||||||
description_preview: safeDescription.substring(0, 100),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('/api/v1/claims/description', {
|
const response = await fetch('/api/v1/claims/description', {
|
||||||
@@ -92,9 +96,10 @@ export default function StepDescription({
|
|||||||
session_id: formData.session_id,
|
session_id: formData.session_id,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
unified_id: formData.unified_id,
|
||||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
contact_id: formData.contact_id,
|
||||||
problem_description: safeDescription,
|
problem_description: safeDescription,
|
||||||
|
entry_channel: entryChannel, // telegram | max | web — для роутинга в n8n
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +119,10 @@ export default function StepDescription({
|
|||||||
|
|
||||||
const responseData = await response.json();
|
const responseData = await response.json();
|
||||||
console.log('✅ Описание успешно отправлено:', responseData);
|
console.log('✅ Описание успешно отправлено:', responseData);
|
||||||
|
console.log('📥 Ответ n8n (description):', responseData);
|
||||||
|
if (responseData && typeof responseData === 'object') {
|
||||||
|
console.log('📥 Ключи ответа n8n:', Object.keys(responseData));
|
||||||
|
}
|
||||||
|
|
||||||
message.success('Описание отправлено, подбираем рекомендации...');
|
message.success('Описание отправлено, подбираем рекомендации...');
|
||||||
updateFormData({
|
updateFormData({
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface Props {
|
|||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
|
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
|
||||||
|
onNewClaim?: () => void; // ✅ Переход на форму нового обращения (шаг «Описание»)
|
||||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +95,21 @@ const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
|
|||||||
|
|
||||||
const YES_VALUES = ['да', 'yes', 'true', '1'];
|
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) => {
|
const isAffirmative = (value: any) => {
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
return value;
|
return value;
|
||||||
@@ -113,6 +129,7 @@ export default function StepWizardPlan({
|
|||||||
onNext,
|
onNext,
|
||||||
onPrev,
|
onPrev,
|
||||||
backToDraftsList,
|
backToDraftsList,
|
||||||
|
onNewClaim,
|
||||||
addDebugEvent,
|
addDebugEvent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
||||||
@@ -123,6 +140,8 @@ export default function StepWizardPlan({
|
|||||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
|
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
|
||||||
|
/** Единое событие от бэка: тип + текст — одно окошко с цветом по типу */
|
||||||
|
const [responseEvent, setResponseEvent] = useState<ResponseEvent | null>(null);
|
||||||
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
||||||
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
||||||
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
||||||
@@ -465,83 +484,143 @@ export default function StepWizardPlan({
|
|||||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ❌ OUT OF SCOPE: Вопрос не связан с защитой прав потребителей
|
// Не показывать служебное сообщение подключения SSE как ответ пользователю
|
||||||
if (eventType === 'out_of_scope') {
|
if (payload.status === 'connected' && payload.message === 'Подключено к событиям') {
|
||||||
debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос вне скоупа', {
|
return;
|
||||||
session_id: sessionId,
|
}
|
||||||
message: payload.message,
|
|
||||||
|
// Единый формат от бэка: 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,
|
suggested_actions: payload.suggested_actions,
|
||||||
});
|
};
|
||||||
|
setResponseEvent(ev);
|
||||||
setIsWaiting(false);
|
setIsWaiting(false);
|
||||||
setOutOfScopeData(payload); // Сохраняем полные данные
|
setConnectionError(null);
|
||||||
setConnectionError(null); // Не используем connectionError
|
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
timeoutRef.current = null;
|
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();
|
source.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
|
||||||
if (eventType === 'documents_list_ready') {
|
if (eventType === 'documents_list_ready') {
|
||||||
const documentsRequired = payload.documents_required || [];
|
const documentsRequired = payload.documents_required || [];
|
||||||
|
setResponseEvent({
|
||||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
event_type: 'consumer_complaint',
|
||||||
session_id: sessionId,
|
message: `Подготовлен список документов: ${documentsRequired.length} шт.`,
|
||||||
documents_count: documentsRequired.length,
|
data: { documents_required: documentsRequired, claim_id: payload.claim_id },
|
||||||
documents: documentsRequired.map((d: any) => d.name),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('📋 documents_list_ready:', {
|
|
||||||
claim_id: payload.claim_id,
|
|
||||||
documents_required: documentsRequired,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Сохраняем в formData для нового флоу
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
documents_required: documentsRequired,
|
documents_required: documentsRequired,
|
||||||
claim_id: payload.claim_id,
|
claim_id: payload.claim_id,
|
||||||
wizardPlanStatus: 'documents_ready', // Новый статус
|
wizardPlanStatus: 'documents_ready',
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsWaiting(false);
|
setIsWaiting(false);
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
|
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
|
||||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||||
|
|
||||||
// TODO: onNext() для перехода к StepDocumentsNew
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wizardPayload = extractWizardPayload(payload);
|
const wizardPayload = extractWizardPayload(payload);
|
||||||
const hasWizardPlan = Boolean(wizardPayload);
|
const hasWizardPlan = Boolean(wizardPayload);
|
||||||
|
|
||||||
if (eventType?.includes('wizard') || hasWizardPlan) {
|
if (eventType?.includes('wizard') || hasWizardPlan) {
|
||||||
const wizardPlan = wizardPayload?.wizard_plan;
|
const wizardPlan = wizardPayload?.wizard_plan;
|
||||||
const answersPrefill = wizardPayload?.answers_prefill;
|
const answersPrefill = wizardPayload?.answers_prefill;
|
||||||
const coverageReport = wizardPayload?.coverage_report;
|
const coverageReport = wizardPayload?.coverage_report;
|
||||||
|
setResponseEvent({
|
||||||
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
|
event_type: 'consumer_complaint',
|
||||||
session_id: sessionId,
|
message: payload.message || 'План готов.',
|
||||||
questions: wizardPlan?.questions?.length || 0,
|
data: { wizard_plan: wizardPlan, answers_prefill: answersPrefill, coverage_report: coverageReport },
|
||||||
});
|
});
|
||||||
|
|
||||||
const prefill = buildPrefillMap(answersPrefill);
|
const prefill = buildPrefillMap(answersPrefill);
|
||||||
setPlan(wizardPlan);
|
setPlan(wizardPlan);
|
||||||
setPrefillMap(prefill);
|
setPrefillMap(prefill);
|
||||||
setIsWaiting(false);
|
setIsWaiting(false);
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
wizardPlan: wizardPlan,
|
wizardPlan: wizardPlan,
|
||||||
wizardPrefill: prefill,
|
wizardPrefill: prefill,
|
||||||
@@ -549,11 +628,7 @@ export default function StepWizardPlan({
|
|||||||
wizardCoverageReport: coverageReport,
|
wizardCoverageReport: coverageReport,
|
||||||
wizardPlanStatus: 'ready',
|
wizardPlanStatus: 'ready',
|
||||||
});
|
});
|
||||||
|
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
source.close();
|
source.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -860,6 +935,11 @@ export default function StepWizardPlan({
|
|||||||
parsed = null;
|
parsed = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('📥 Ответ n8n (wizard):', parsed);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
console.log('📥 Ключи ответа n8n:', Object.keys(parsed));
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
||||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
||||||
@@ -2613,8 +2693,103 @@ export default function StepWizardPlan({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
|
{/* Единое окошко: тип + текст, цвет по event_type */}
|
||||||
{outOfScopeData && (
|
{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 () => {
|
||||||
|
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,
|
||||||
|
reason: responseEvent.message,
|
||||||
|
message: responseEvent.message,
|
||||||
|
action: 'contact_support',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.destroy();
|
||||||
|
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
message.destroy();
|
||||||
|
message.error('Не удалось отправить запрос. Попробуйте позже.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Связаться с поддержкой →
|
||||||
|
</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={{ textAlign: 'center', padding: 24 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff7e6',
|
background: '#fff7e6',
|
||||||
@@ -2706,13 +2881,14 @@ export default function StepWizardPlan({
|
|||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
<Button type="primary" onClick={() => {
|
<Button type="primary" onClick={() => {
|
||||||
// Сбрасываем состояние и возвращаемся на первый экран
|
setOutOfScopeData(null);
|
||||||
updateFormData({
|
if (onNewClaim) {
|
||||||
wizardPlan: null,
|
onNewClaim(); // переход на форму «Описание проблемы», без дашборда
|
||||||
wizardPlanStatus: null,
|
} else {
|
||||||
problemDescription: '',
|
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||||
});
|
window.history.pushState({}, '', '/new');
|
||||||
window.location.href = '/';
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
}
|
||||||
}}>
|
}}>
|
||||||
Новое обращение
|
Новое обращение
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -128,17 +128,35 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
const [platformChecked, setPlatformChecked] = useState(false);
|
const [platformChecked, setPlatformChecked] = useState(false);
|
||||||
const forceNewClaimRef = useRef(false);
|
const forceNewClaimRef = useRef(false);
|
||||||
|
|
||||||
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении (иначе до 2.5 с оба флага false)
|
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении.
|
||||||
|
// 1) По URL (TG iframe приходит с tgWebAppData/tgWebAppVersion). 2) По initData. 3) По наличию SDK (WebApp / Telegram.WebApp).
|
||||||
useEffect(() => {
|
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 detect = () => {
|
||||||
const tg = (window as any).Telegram?.WebApp?.initData;
|
const tgInitData = (window as any).Telegram?.WebApp?.initData;
|
||||||
const max = (window as any).WebApp?.initData;
|
const maxInitData = (window as any).WebApp?.initData;
|
||||||
if (tg && typeof tg === 'string' && tg.length > 0) {
|
if (tgInitData && typeof tgInitData === 'string' && tgInitData.length > 0) {
|
||||||
setIsTelegramMiniApp(true);
|
setIsTelegramMiniApp(true);
|
||||||
setPlatformChecked(true);
|
setPlatformChecked(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (max && typeof max === 'string' && max.length > 0) {
|
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);
|
setIsMaxMiniApp(true);
|
||||||
setPlatformChecked(true);
|
setPlatformChecked(true);
|
||||||
return true;
|
return true;
|
||||||
@@ -213,164 +231,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
|
// Авторизация выполняется на /hello (универсальный auth). Здесь только помечаем, что платформа проверена.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tryTelegramAuth = async () => {
|
setTelegramAuthChecked(true);
|
||||||
try {
|
setPlatformChecked(true);
|
||||||
// Только 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');
|
|
||||||
// Если Telegram не найден — пробуем MAX Mini App (window.WebApp от MAX Bridge)
|
|
||||||
let maxWebApp = (window as any).WebApp;
|
|
||||||
const maxWait = 4000;
|
|
||||||
for (let t = 0; t < maxWait; t += 200) {
|
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
|
||||||
maxWebApp = (window as any).WebApp;
|
|
||||||
if (maxWebApp?.initData && maxWebApp.initData.length > 0) break;
|
|
||||||
}
|
|
||||||
if (maxWebApp?.initData && typeof maxWebApp.initData === 'string' && maxWebApp.initData.length > 0) {
|
|
||||||
const hasHash = maxWebApp.initData.includes('hash=');
|
|
||||||
console.log('[MAX] Обнаружен MAX WebApp, initData длина=', maxWebApp.initData.length, ', есть hash=', hasHash);
|
|
||||||
setIsMaxMiniApp(true);
|
|
||||||
try { maxWebApp.ready?.(); } catch (_) {}
|
|
||||||
const existingToken = localStorage.getItem('session_token');
|
|
||||||
if (existingToken) {
|
|
||||||
console.log('[MAX] session_token уже есть → max/auth не вызываем');
|
|
||||||
setTelegramAuthChecked(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTgDebug('MAX: POST /api/v1/max/auth...');
|
|
||||||
try {
|
|
||||||
const maxRes = await fetch('/api/v1/max/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ init_data: maxWebApp.initData }),
|
|
||||||
});
|
|
||||||
const maxData = await maxRes.json();
|
|
||||||
if (maxData.need_contact) {
|
|
||||||
setTgDebug('MAX: Нужен контакт — закрываем приложение');
|
|
||||||
try { maxWebApp.close?.(); } catch (_) {}
|
|
||||||
setTelegramAuthChecked(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (maxRes.ok && maxData.success) {
|
|
||||||
if (maxData.session_token) {
|
|
||||||
localStorage.setItem('session_token', maxData.session_token);
|
|
||||||
sessionIdRef.current = maxData.session_token;
|
|
||||||
}
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
unified_id: maxData.unified_id,
|
|
||||||
phone: maxData.phone,
|
|
||||||
contact_id: maxData.contact_id,
|
|
||||||
session_id: maxData.session_token,
|
|
||||||
}));
|
|
||||||
setIsPhoneVerified(true);
|
|
||||||
setShowDraftSelection(!!maxData.has_drafts);
|
|
||||||
setHasDrafts(!!maxData.has_drafts);
|
|
||||||
setCurrentStep(0); // дашборд «Мои обращения» при заходе из MAX
|
|
||||||
} else {
|
|
||||||
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[MAX] Ошибка max/auth:', e);
|
|
||||||
}
|
|
||||||
setTelegramAuthChecked(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
setShowDraftSelection(!!data.has_drafts);
|
|
||||||
setHasDrafts(!!data.has_drafts);
|
|
||||||
setCurrentStep(0); // дашборд «Мои обращения» при заходе из TG
|
|
||||||
} catch (error) {
|
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
|
||||||
setTgDebug(`TG: ошибка: ${msg}`);
|
|
||||||
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
|
|
||||||
} finally {
|
|
||||||
setTelegramAuthChecked(true);
|
|
||||||
setPlatformChecked(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tryTelegramAuth();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
|
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
|
||||||
@@ -383,13 +247,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
|
|
||||||
const restoreSession = async () => {
|
const restoreSession = async () => {
|
||||||
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
|
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
|
||||||
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
|
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|
||||||
console.log('🔑 Значения всех ключей:', JSON.stringify(localStorage));
|
|| localStorage.getItem('session_token');
|
||||||
|
|
||||||
const savedSessionToken = localStorage.getItem('session_token');
|
|
||||||
|
|
||||||
if (!savedSessionToken) {
|
if (!savedSessionToken) {
|
||||||
console.log('❌ Session token NOT found in localStorage');
|
|
||||||
setSessionRestored(true);
|
setSessionRestored(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -436,8 +296,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
|
|
||||||
// На странице /new («Подать жалобу») не показываем черновики
|
// На странице /new («Подать жалобу») не показываем черновики
|
||||||
if (forceNewClaimRef.current) {
|
if (forceNewClaimRef.current) {
|
||||||
// Если сессия валидна — не возвращаем на экран телефона
|
// Если сессия валидна — не возвращаем на экран телефона. В TG/MAX нет шага «Вход», первый шаг формы = индекс 0 (Обращение); в вебе первый = Вход (0), Обращение = 1.
|
||||||
setCurrentStep(1); // сразу к описанию (индекс зависит от step-структуры; ниже goBack не даст попасть на «Вход»)
|
const isMiniApp = !!(typeof window !== 'undefined' && ((window as any).Telegram?.WebApp?.initData || (window as any).WebApp?.initData));
|
||||||
|
setCurrentStep(isMiniApp ? 0 : 1);
|
||||||
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||||
message.success('Добро пожаловать!');
|
message.success('Добро пожаловать!');
|
||||||
}
|
}
|
||||||
@@ -459,7 +320,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
|
|
||||||
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
|
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
|
||||||
if (response.ok && data?.success && data?.valid === false) {
|
if (response.ok && data?.success && data?.valid === false) {
|
||||||
console.log('❌ Session invalid or expired, removing from localStorage');
|
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||||
localStorage.removeItem('session_token');
|
localStorage.removeItem('session_token');
|
||||||
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
|
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
|
||||||
return;
|
return;
|
||||||
@@ -1329,8 +1190,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
// Обработчик создания новой заявки
|
// Обработчик создания новой заявки
|
||||||
const handleNewClaim = useCallback(() => {
|
const handleNewClaim = useCallback(() => {
|
||||||
console.log('🆕 Начинаем новое обращение');
|
console.log('🆕 Начинаем новое обращение');
|
||||||
console.log('🆕 Текущий currentStep:', currentStep);
|
// ✅ Режим «новая жалоба»: без дашборда/черновиков, первый шаг = форма «Описание»
|
||||||
console.log('🆕 isPhoneVerified:', isPhoneVerified);
|
forceNewClaimRef.current = true;
|
||||||
|
window.history.pushState({}, '', '/new');
|
||||||
|
|
||||||
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
||||||
const newSessionId = 'sess_' + generateUUIDv4();
|
const newSessionId = 'sess_' + generateUUIDv4();
|
||||||
@@ -1340,8 +1202,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
// ✅ Обновляем sessionIdRef на новую сессию
|
// ✅ Обновляем sessionIdRef на новую сессию
|
||||||
sessionIdRef.current = newSessionId;
|
sessionIdRef.current = newSessionId;
|
||||||
|
|
||||||
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
|
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||||
const savedSessionToken = localStorage.getItem('session_token');
|
|
||||||
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
||||||
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
||||||
|
|
||||||
@@ -1367,13 +1228,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
||||||
|
// В TG/MAX нет шага «Вход», поэтому Обращение = индекс 0; в вебе Вход = 0, Обращение = 1.
|
||||||
// ✅ Переходим к шагу описания проблемы
|
const isMiniApp = !!(typeof window !== 'undefined' && ((window as any).Telegram?.WebApp?.initData || (window as any).WebApp?.initData));
|
||||||
// После сброса флагов черновиков, steps будут:
|
setCurrentStep(isMiniApp ? 0 : 1);
|
||||||
// Шаг 0 - Phone (уже верифицирован, но в массиве есть)
|
|
||||||
// Шаг 1 - Description (сюда переходим)
|
|
||||||
// Шаг 2 - WizardPlan
|
|
||||||
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
|
|
||||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
||||||
|
|
||||||
// ✅ Автоматический редирект на экран черновиков после успешной отправки
|
// ✅ Автоматический редирект на экран черновиков после успешной отправки
|
||||||
@@ -1615,6 +1472,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Шаг 2: свободное описание
|
// Шаг 2: свободное описание
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
@@ -1641,11 +1499,11 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
onPrev={prevStep}
|
onPrev={prevStep}
|
||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
backToDraftsList={backToDraftsList}
|
backToDraftsList={backToDraftsList}
|
||||||
|
onNewClaim={handleNewClaim}
|
||||||
addDebugEvent={addDebugEvent}
|
addDebugEvent={addDebugEvent}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
||||||
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
|
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
|
||||||
@@ -1669,6 +1527,15 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
return stepsArray;
|
return stepsArray;
|
||||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked]);
|
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked]);
|
||||||
|
|
||||||
|
// Синхронизация 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/стейл шаги).
|
// ВАЖНО: держим effect ниже prevStep и steps (иначе TDZ/стейл шаги).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1676,7 +1543,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentTitle = steps[currentStep]?.title;
|
const currentTitle = steps[currentStep]?.title;
|
||||||
const prevTitle = currentStep > 0 ? steps[currentStep - 1]?.title : null;
|
const prevTitle = currentStep > 0 ? steps[currentStep - 1]?.title : null;
|
||||||
const isAuthed = !!formData.unified_id || isPhoneVerified || !!localStorage.getItem('session_token');
|
const isAuthed = !!formData.unified_id || isPhoneVerified || !!(typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || !!localStorage.getItem('session_token');
|
||||||
|
|
||||||
miniappLog('claim_form_go_back_event', {
|
miniappLog('claim_form_go_back_event', {
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -1791,8 +1658,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
|
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
|
||||||
// Получаем session_token из localStorage
|
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token') || formData.session_id;
|
||||||
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
|
|
||||||
|
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
try {
|
try {
|
||||||
@@ -1812,7 +1678,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаляем session_token из localStorage
|
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||||
localStorage.removeItem('session_token');
|
localStorage.removeItem('session_token');
|
||||||
|
|
||||||
// Полный сброс: очищаем все данные авторизации и черновиков
|
// Полный сброс: очищаем все данные авторизации и черновиков
|
||||||
@@ -1857,7 +1723,19 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDocumentsStep = steps[currentStep]?.title === 'Документы';
|
// Пустой список шагов — не показывать форму с «Загрузка шага», показать общий 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 (
|
return (
|
||||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
|
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
|
||||||
@@ -1866,7 +1744,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
||||||
{isDocumentsStep ? (
|
{isDocumentsStep ? (
|
||||||
<div className="steps-content" style={{ marginTop: 0 }}>
|
<div className="steps-content" style={{ marginTop: 0 }}>
|
||||||
{steps[currentStep] ? steps[currentStep].content : (
|
{stepToShow ? stepToShow.content : (
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
|
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1881,7 +1759,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="steps-content">
|
<div className="steps-content">
|
||||||
{steps[currentStep] ? steps[currentStep].content : (
|
{stepToShow ? stepToShow.content : (
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||||
<p>Загрузка шага...</p>
|
<p>Загрузка шага...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ interface HelloAuthProps {
|
|||||||
onNavigate?: (path: string) => void;
|
onNavigate?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INIT_DATA_WAIT_MS = 5500;
|
||||||
|
const INIT_DATA_POLL_MS = 200;
|
||||||
|
|
||||||
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
|
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
|
||||||
const [status, setStatus] = useState<Status>('idle');
|
const [status, setStatus] = useState<Status>('idle');
|
||||||
const [greeting, setGreeting] = useState<string>('Привет!');
|
const [greeting, setGreeting] = useState<string>('Привет!');
|
||||||
@@ -30,6 +33,7 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
const [phone, setPhone] = useState<string>('');
|
const [phone, setPhone] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
const [codeSent, setCodeSent] = useState<boolean>(false);
|
const [codeSent, setCodeSent] = useState<boolean>(false);
|
||||||
|
const [noInitDataAfterTimeout, setNoInitDataAfterTimeout] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isTelegramContext = () => {
|
const isTelegramContext = () => {
|
||||||
@@ -44,104 +48,98 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 tryAuth = async () => {
|
const tryAuth = async () => {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
|
setNoInitDataAfterTimeout(false);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
// Сначала проверяем сохранённую сессию — при возврате «Домой» не показывать форму входа
|
if (isTelegramContext() && !(window as any).Telegram?.WebApp) {
|
||||||
const savedToken = localStorage.getItem('session_token');
|
await new Promise<void>((resolve) => {
|
||||||
if (savedToken) {
|
const script = document.createElement('script');
|
||||||
try {
|
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||||
const verifyRes = await fetch('/api/v1/session/verify', {
|
script.async = true;
|
||||||
method: 'POST',
|
script.onload = () => resolve();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
script.onerror = () => resolve();
|
||||||
body: JSON.stringify({ session_token: savedToken }),
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
const verifyData = await verifyRes.json();
|
|
||||||
if (verifyRes.ok && verifyData.success && verifyData.valid) {
|
|
||||||
setGreeting(verifyData.greeting || 'Привет!');
|
|
||||||
// В Telegram подставляем имя и аватар из WebApp (или из localStorage)
|
|
||||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
|
||||||
if (tgUser?.first_name) {
|
|
||||||
setGreeting(`Привет, ${tgUser.first_name}!`);
|
|
||||||
}
|
|
||||||
let avatarUrl = tgUser?.photo_url || localStorage.getItem('user_avatar_url') || '';
|
|
||||||
if (avatarUrl) {
|
|
||||||
setAvatar(avatarUrl);
|
|
||||||
onAvatarChange?.(avatarUrl);
|
|
||||||
}
|
|
||||||
setStatus('success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
// 1) Ждём появления initData (TG или MAX) с таймаутом
|
||||||
// Telegram Mini App
|
let channelInit = getChannelAndInitData();
|
||||||
if (isTelegramContext()) {
|
if (!channelInit) {
|
||||||
const script = document.createElement('script');
|
const deadline = Date.now() + INIT_DATA_WAIT_MS;
|
||||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
while (Date.now() < deadline) {
|
||||||
script.async = true;
|
await new Promise((r) => setTimeout(r, INIT_DATA_POLL_MS));
|
||||||
script.onload = async () => {
|
channelInit = getChannelAndInitData();
|
||||||
const tg = (window as any).Telegram;
|
if (channelInit) break;
|
||||||
const webApp = tg?.WebApp;
|
}
|
||||||
const initData = webApp?.initData;
|
|
||||||
if (initData) {
|
|
||||||
const res = await fetch('/api/v1/auth2/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ platform: 'tg', init_data: initData }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
setGreeting(data.greeting || 'Привет!');
|
|
||||||
let avatarUrl = data.avatar_url;
|
|
||||||
if (!avatarUrl && webApp?.initDataUnsafe?.user?.photo_url) {
|
|
||||||
avatarUrl = webApp.initDataUnsafe.user.photo_url;
|
|
||||||
}
|
|
||||||
if (avatarUrl) {
|
|
||||||
setAvatar(avatarUrl);
|
|
||||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
|
||||||
onAvatarChange?.(avatarUrl);
|
|
||||||
}
|
|
||||||
setStatus('success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(data.detail || 'Ошибка авторизации Telegram');
|
|
||||||
setStatus('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('idle');
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (channelInit) {
|
||||||
// MAX Mini App
|
const { channel, initData } = channelInit;
|
||||||
const maxWebApp = (window as any).WebApp;
|
const res = await fetch('/api/v1/auth', {
|
||||||
const initData = maxWebApp?.initData;
|
|
||||||
if (initData) {
|
|
||||||
const res = await fetch('/api/v1/auth2/login', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ platform: 'max', init_data: initData }),
|
body: JSON.stringify({ channel, init_data: initData }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data: Record<string, unknown> = await res.json().catch(() => ({}));
|
||||||
|
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;
|
||||||
|
}
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
setGreeting(data.greeting || 'Привет!');
|
const token = data.session_token as string | undefined;
|
||||||
if (data.avatar_url) {
|
if (token) {
|
||||||
setAvatar(data.avatar_url);
|
try {
|
||||||
localStorage.setItem('user_avatar_url', data.avatar_url);
|
sessionStorage.setItem('session_token', token);
|
||||||
onAvatarChange?.(data.avatar_url);
|
} 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');
|
setStatus('success');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(data.detail || 'Ошибка авторизации MAX');
|
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: SMS
|
// 2) initData не появился за таймаут
|
||||||
// If there's a claim_id in URL/hash/MAX start_param, try to load draft and redirect to form
|
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
|
||||||
|
if (likelyMiniapp) {
|
||||||
|
setNoInitDataAfterTimeout(true);
|
||||||
|
setStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Веб: claim_id в URL и SMS fallback
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
let claimFromUrl: string | null = params.get('claim_id');
|
let claimFromUrl: string | null = params.get('claim_id');
|
||||||
const parseStart = (s: string | null) => {
|
const parseStart = (s: string | null) => {
|
||||||
@@ -150,7 +148,6 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
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);
|
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;
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
};
|
};
|
||||||
// MAX может отдавать start_param строкой или объектом WebAppStartParam
|
|
||||||
const startParamToStr = (v: unknown): string | null => {
|
const startParamToStr = (v: unknown): string | null => {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (typeof v === 'string') return v;
|
if (typeof v === 'string') return v;
|
||||||
@@ -164,7 +161,7 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
return String(v);
|
return String(v);
|
||||||
};
|
};
|
||||||
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
|
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
|
||||||
if (!claimFromUrl && typeof window !== 'undefined' && window.location.hash) {
|
if (!claimFromUrl && window.location.hash) {
|
||||||
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||||
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
|
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
|
||||||
}
|
}
|
||||||
@@ -175,21 +172,12 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
|
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
|
||||||
if (draftRes.ok) {
|
if (draftRes.ok) {
|
||||||
const draftData = await draftRes.json();
|
const draftData = await draftRes.json();
|
||||||
// If backend provided session_token in draft, store it
|
|
||||||
const st = draftData?.claim?.session_token;
|
const st = draftData?.claim?.session_token;
|
||||||
if (st) {
|
if (st) localStorage.setItem('session_token', st);
|
||||||
localStorage.setItem('session_token', st);
|
|
||||||
console.log('HelloAuth: session_token from draft saved', st);
|
|
||||||
}
|
|
||||||
// Redirect to root so ClaimForm can restore session and load the draft
|
|
||||||
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
|
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
|
||||||
return;
|
return;
|
||||||
} else {
|
|
||||||
console.warn('HelloAuth: draft not found or error', draftRes.status);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
console.error('HelloAuth: error fetching draft by claim_id', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -199,7 +187,21 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
tryAuth();
|
tryAuth();
|
||||||
}, []);
|
}, [onAvatarChange, onNavigate]);
|
||||||
|
|
||||||
|
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 () => {
|
const sendCode = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
34
frontend/src/pages/Profile.css
Normal file
34
frontend/src/pages/Profile.css
Normal 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;
|
||||||
|
}
|
||||||
152
frontend/src/pages/Profile.tsx
Normal file
152
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Descriptions, Spin, Typography } from 'antd';
|
||||||
|
import { User } from 'lucide-react';
|
||||||
|
import './Profile.css';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
/** Поля профиля из CRM (поддержка snake_case и camelCase) */
|
||||||
|
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string }> = [
|
||||||
|
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия' },
|
||||||
|
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя' },
|
||||||
|
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество' },
|
||||||
|
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения' },
|
||||||
|
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения' },
|
||||||
|
{ key: 'inn', keys: ['inn'], label: 'ИНН' },
|
||||||
|
{ key: 'email', keys: ['email'], label: 'Электронная почта' },
|
||||||
|
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации' },
|
||||||
|
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес' },
|
||||||
|
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения' },
|
||||||
|
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
onNavigate?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Profile({ onNavigate }: ProfileProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [onNavigate]);
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user