"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971). POST /api/v1/trade-in/lead — контактная заявка с результата оценки: телефон + явное согласие на обработку персональных данных. Persist в trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только persist + log; `notified_at` в таблице зарезервирован под будущую доставку. """ from __future__ import annotations import logging import re from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field, field_validator from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db logger = logging.getLogger(__name__) router = APIRouter() # Простая маска телефона (RU/международная): опциональный "+", цифры/пробелы/ # скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP, # см. #2376 DoD ("простая regex, не EmailStr-подобное"). _PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$" # Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр). # Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный = # 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны). _PHONE_MIN_DIGITS = 10 _PHONE_MAX_DIGITS = 15 # Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится # per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась # только в audit-лог (#2497 TODO, теперь закрыт). _CONSENT_POLICY_VERSION = "2026-07" # Снимок точного текста согласия, который видит пользователь при отправке лида. # Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/ # trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять # _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки # будут нести устаревший snapshot под новой version-меткой. _CONSENT_TEXT_SNAPSHOT = ( "Согласен(-на) на обработку персональных данных в соответствии с " "Федеральным законом «О персональных данных» № 152-ФЗ" ) class TradeInLeadInput(BaseModel): phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN) estimate_id: UUID | None = None consent: Literal[True] # "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard # (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а # публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не # обещал невозможную воронку (#2376). Вернуть, если появится public-роут. source: Literal["result"] = "result" @field_validator("phone") @classmethod def _phone_has_enough_digits(cls, value: str) -> str: digits = len(re.sub(r"\D", "", value)) if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS): raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits") return value @router.post("/lead") async def create_trade_in_lead( payload: TradeInLeadInput, request: Request, db: Annotated[Session, Depends(get_db)], ) -> dict[str, Any]: """Сохраняет лид (телефон + согласие) в trade_in_leads.""" if payload.estimate_id is not None: exists = db.execute( text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), {"id": str(payload.estimate_id)}, ).fetchone() if exists is None: raise HTTPException(status_code=404, detail="estimate not found") user_agent = request.headers.get("user-agent") # 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит # фронтящий Caddy), fallback — прямой peer. Персистится per-row в # trade_in_leads.client_ip (migration 182), а не только в лог. client_ip = request.headers.get("x-forwarded-for") if client_ip: client_ip = client_ip.split(",")[0].strip() elif request.client is not None: client_ip = request.client.host # 152-ФЗ proof-of-consent: client_ip / consent_policy_version / # consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182, # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. row = ( db.execute( text( """ INSERT INTO trade_in_leads ( estimate_id, phone, consent, source, user_agent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent, CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot ) RETURNING CAST(id AS text), created_at """ ), { "estimate_id": str(payload.estimate_id) if payload.estimate_id else None, "phone": payload.phone, "consent": payload.consent, "source": payload.source, "user_agent": user_agent, "client_ip": client_ip, "consent_policy_version": _CONSENT_POLICY_VERSION, "consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT, }, ) .mappings() .one() ) db.commit() logger.info( "trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s", row["id"], payload.estimate_id, payload.source, client_ip, _CONSENT_POLICY_VERSION, ) return { "id": row["id"], "created_at": row["created_at"].isoformat(), "status": "received", }