Consultations, CRM dashboard, Back button in support and consultations
- Consultations: list from DraftsContext, ticket-detail webhook, response card - Back button in bar on consultations and in support chat (miniapp:goBack) - BottomBar: back enabled on /support; Support: goBack subscription - n8n: CRM normalize (n8n_CODE_CRM_NORMALIZE), flatten data (n8n_CODE_FLATTEN_DATA) - Dashboard: filter by category for CRM items, draft card width - Backend: consultations.py, ticket-detail, n8n_ticket_form_podrobnee_webhook - CHANGELOG_MINIAPP.md: section 2026-02-25
This commit is contained in:
213
backend/app/api/consultations.py
Normal file
213
backend/app/api/consultations.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Консультации: тикеты из CRM (MySQL) через N8N_TICKET_FORM_CONSULTATION_WEBHOOK.
|
||||
|
||||
GET/POST /api/v1/consultations — верификация сессии, вызов webhook с тем же payload,
|
||||
что и у других хуков (session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id).
|
||||
Ответ webhook возвращается клиенту (список тикетов и т.д.).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.api.session import SessionVerifyRequest, verify_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/consultations", tags=["consultations"])
|
||||
|
||||
|
||||
class ConsultationsPostBody(BaseModel):
|
||||
"""Тело запроса: session_token обязателен для идентификации."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
|
||||
|
||||
|
||||
class TicketDetailBody(BaseModel):
|
||||
"""Тело запроса «подробнее по тикету»."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
ticket_id: Any = Field(..., description="ID тикета в CRM (ticketid)")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа")
|
||||
|
||||
|
||||
def _get_consultation_webhook_url() -> str:
|
||||
url = (getattr(settings, "n8n_ticket_form_consultation_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_TICKET_FORM_CONSULTATION_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def _get_podrobnee_webhook_url() -> str:
|
||||
url = (getattr(settings, "n8n_ticket_form_podrobnee_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_TICKET_FORM_PODROBNEE_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
async def _call_consultation_webhook(
|
||||
session_token: str,
|
||||
entry_channel: str = "web",
|
||||
) -> dict:
|
||||
"""
|
||||
Верифицировать сессию, собрать payload как у других хуков, POST в webhook, вернуть ответ.
|
||||
"""
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if not getattr(verify_res, "valid", False):
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
|
||||
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"form_id": "ticket_form",
|
||||
"session_token": session_token,
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (entry_channel or "web").strip() or "web",
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
webhook_url = _get_consultation_webhook_url()
|
||||
logger.info("Consultation webhook: POST %s, keys=%s", webhook_url[:60], list(payload.keys()))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Сервис консультаций временно недоступен")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис консультаций временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Consultation webhook вернул %s: %s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Сервис консультаций вернул ошибку",
|
||||
)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw": response.text or ""}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_consultations(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
entry_channel: Optional[str] = Query("web", description="Канал входа: telegram | max | web"),
|
||||
):
|
||||
"""
|
||||
Получить данные консультаций (тикеты из CRM) через n8n webhook.
|
||||
Передаётся тот же payload, что и на другие хуки: session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id.
|
||||
"""
|
||||
if not session_token or not str(session_token).strip():
|
||||
raise HTTPException(status_code=400, detail="Укажите session_token")
|
||||
return await _call_consultation_webhook(
|
||||
session_token=str(session_token).strip(),
|
||||
entry_channel=entry_channel or "web",
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def post_consultations(body: ConsultationsPostBody):
|
||||
"""То же по телу запроса."""
|
||||
return await _call_consultation_webhook(
|
||||
session_token=body.session_token.strip(),
|
||||
entry_channel=body.entry_channel or "web",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ticket-detail")
|
||||
async def get_ticket_detail(body: TicketDetailBody):
|
||||
"""
|
||||
Подробнее по тикету: верификация сессии, вызов N8N_TICKET_FORM_PODROBNEE_WEBHOOK
|
||||
с payload (session_token, unified_id, contact_id, phone, ticket_id, entry_channel, form_id).
|
||||
Ответ вебхука возвращается клиенту как есть (HTML в поле html/body или весь JSON).
|
||||
"""
|
||||
session_token = str(body.session_token or "").strip()
|
||||
if not session_token:
|
||||
raise HTTPException(status_code=400, detail="Укажите session_token")
|
||||
ticket_id = body.ticket_id
|
||||
if ticket_id is None or (isinstance(ticket_id, str) and not str(ticket_id).strip()):
|
||||
raise HTTPException(status_code=400, detail="Укажите ticket_id")
|
||||
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if not getattr(verify_res, "valid", False):
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
|
||||
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
entry_channel = (body.entry_channel or "web").strip() or "web"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"form_id": "ticket_form",
|
||||
"session_token": session_token,
|
||||
"unified_id": unified_id,
|
||||
"ticket_id": int(ticket_id) if isinstance(ticket_id, str) and ticket_id.isdigit() else ticket_id,
|
||||
"entry_channel": entry_channel,
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
webhook_url = _get_podrobnee_webhook_url()
|
||||
logger.info("Podrobnee webhook: POST %s, ticket_id=%s", webhook_url[:60], payload.get("ticket_id"))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Сервис временно недоступен")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("Podrobnee webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Сервис вернул ошибку")
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"html": response.text or "", "raw": True}
|
||||
@@ -207,6 +207,10 @@ class Settings(BaseSettings):
|
||||
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
|
||||
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
|
||||
n8n_description_webhook: str = "https://n8n.clientright.ru/webhook/ticket_form_description" # Webhook для описания проблемы (переопределяется через N8N_DESCRIPTION_WEBHOOK в .env)
|
||||
# Консультации: тикеты из CRM (MySQL) — тот же payload, что и у других хуков
|
||||
n8n_ticket_form_consultation_webhook: str = "" # N8N_TICKET_FORM_CONSULTATION_WEBHOOK в .env
|
||||
# Подробнее по тикету: session + ticket_id → ответ вебхука (HTML/JSON)
|
||||
n8n_ticket_form_podrobnee_webhook: str = "" # N8N_TICKET_FORM_PODROBNEE_WEBHOOK в .env
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user