Draft detail and Back button

This commit is contained in:
Fedor
2026-02-21 22:08:30 +03:00
parent 1887336aba
commit 4536210284
19 changed files with 1454 additions and 504 deletions

View File

@@ -373,6 +373,16 @@ async def list_drafts(
# Категория проблемы
category = ai_analysis.get('category') or wizard_plan.get('category') or None
# Направление (для иконки плитки)
direction = payload.get('direction') or wizard_plan.get('direction') or category
# facts_short из AI Agent (краткие факты — заголовок плитки)
ai_agent1_facts = payload.get('ai_agent1_facts') or {}
ai_analysis_facts = (payload.get('ai_analysis') or {}).get('facts_short')
facts_short = ai_agent1_facts.get('facts_short') or ai_analysis_facts
if facts_short and len(facts_short) > 200:
facts_short = facts_short[:200].rstrip() + ''
# Подробное описание (для превью)
problem_text = payload.get('problem_description', '')
@@ -418,6 +428,8 @@ async def list_drafts(
# Полное описание
"problem_description": problem_text[:500] if problem_text else None,
"category": category,
"direction": direction,
"facts_short": facts_short,
"wizard_plan": payload.get('wizard_plan') is not None,
"wizard_answers": payload.get('answers') is not None,
"has_documents": documents_uploaded > 0,
@@ -445,11 +457,13 @@ async def list_drafts(
@router.get("/drafts/{claim_id}")
async def get_draft(claim_id: str):
"""
Получить полные данные черновика по claim_id
Возвращает все данные формы для продолжения заполнения
Получить полные данные черновика по claim_id.
Поддерживаются форматы: голый UUID, claim_id_<uuid> (из MAX startapp).
"""
try:
# Формат из MAX диплинка: claim_id_<uuid> — извлекаем UUID
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
@@ -658,11 +672,11 @@ async def get_draft(claim_id: str):
@router.delete("/drafts/{claim_id}")
async def delete_draft(claim_id: str):
"""
Удалить черновик по claim_id
Удаляет черновики с любым статусом (кроме submitted/completed)
Удалить черновик по claim_id. Поддерживается формат claim_id_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
query = """
DELETE FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
@@ -868,15 +882,14 @@ async def get_claim(claim_id: str):
@router.get("/wizard/load/{claim_id}")
async def load_wizard_data(claim_id: str):
"""
Загрузить данные визарда из PostgreSQL по claim_id
Используется после получения claim_id из ocr_events.
Возвращает полные данные для построения формы (wizard_plan, problem_description и т.д.)
Загрузить данные визарда по claim_id. Поддерживается формат claim_id_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
# Ищем заявку по claim_id (UUID или CLM-...)
query = """
SELECT
id,

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service
from .services.crm_mysql_service import crm_mysql_service
from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2
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 debug_session
# Настройка логирования
logging.basicConfig(
@@ -107,6 +108,30 @@ async def refresh_config_on_request(request, call_next):
get_settings()
return await call_next(request)
# Temporary middleware for capturing incoming init_data / startapp / claim_id for debugging.
@app.middleware("http")
async def capture_initdata_middleware(request, call_next):
try:
# Check query string first
qs = str(request.url.query or "")
if qs and ("claim_id" in qs or "startapp" in qs or "start_param" in qs):
logger.info("[CAPTURE Q] %s %s QUERY: %s", request.method, request.url.path, qs)
# Check JSON body for known keys
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.body()
if body:
text = body.decode(errors="ignore")
if any(k in text for k in ("init_data", "startapp", "start_param", "claim_id")):
# Log truncated body (limit 10k chars)
snippet = text if len(text) <= 10000 else (text[:10000] + "...[truncated]")
logger.info("[CAPTURE B] %s %s BODY: %s", request.method, request.url.path, snippet)
except Exception:
logger.exception("❌ Error in capture_initdata_middleware")
return await call_next(request)
# API Routes
app.include_router(sms.router)
app.include_router(claims.router)
@@ -121,6 +146,8 @@ app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
app.include_router(max_auth.router) # 📱 MAX Mini App auth
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
@app.get("/")