Draft detail and Back button
This commit is contained in:
@@ -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,
|
||||
|
||||
89
backend/app/api/debug_session.py
Normal file
89
backend/app/api/debug_session.py
Normal 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
132
backend/app/api/documents_draft_open.py
Normal file
132
backend/app/api/documents_draft_open.py
Normal 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)}")
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user