From ada9bb81252d9fc9f56c00ee4196b2a120ebee15 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 10:00:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/lead):=20POST=20/api/v1/trade-in/l?= =?UTF-8?q?ead=20=E2=80=94=20persist=20contact=20leads=20(#2376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contact capture (phone + consent) from estimate result / landing page. New trade_in_leads table (migration 172), estimate_id FK validated before insert (404 if unknown). Notification (Telegram/email) intentionally out of scope — no existing SMTP/Telegram integration anywhere in the codebase (same gap as pilot.py's TODO). --- tradein-mvp/backend/app/api/v1/lead.py | 91 ++++++++++++ tradein-mvp/backend/app/main.py | 3 +- .../backend/data/sql/172_trade_in_leads.sql | 29 ++++ .../backend/data/sql/_manifest_applied.txt | 1 + .../backend/tests/test_trade_in_lead.py | 130 ++++++++++++++++++ 5 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/app/api/v1/lead.py create mode 100644 tradein-mvp/backend/data/sql/172_trade_in_leads.sql create mode 100644 tradein-mvp/backend/tests/test_trade_in_lead.py diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py new file mode 100644 index 00000000..fa2a1c02 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -0,0 +1,91 @@ +"""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 +from typing import Annotated, Any, Literal +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field +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}$" + + +class TradeInLeadInput(BaseModel): + phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN) + estimate_id: UUID | None = None + consent: Literal[True] + source: Literal["result", "landing"] = "result" + + +@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") + + row = ( + db.execute( + text( + """ + INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent) + VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent) + 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, + }, + ) + .mappings() + .one() + ) + + db.commit() + + logger.info( + "trade_in_lead saved id=%s estimate_id=%s source=%s", + row["id"], + payload.estimate_id, + payload.source, + ) + + return { + "id": row["id"], + "created_at": row["created_at"].isoformat(), + "status": "received", + } diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index c770acc1..5f7df92d 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -24,7 +24,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration -from app.api.v1 import admin, brand, buildings, geocode, me, search, trade_in +from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in from app.core.auth import get_role from app.core.config import settings from app.core.db import SessionLocal @@ -197,6 +197,7 @@ app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"]) app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(me.router, prefix="/api/v1", tags=["me"]) diff --git a/tradein-mvp/backend/data/sql/172_trade_in_leads.sql b/tradein-mvp/backend/data/sql/172_trade_in_leads.sql new file mode 100644 index 00000000..f5e554db --- /dev/null +++ b/tradein-mvp/backend/data/sql/172_trade_in_leads.sql @@ -0,0 +1,29 @@ +-- Migration: trade_in_leads — контактные заявки с результата оценки (issue #2376, +-- sub-issue родителя #1971 "лидогенерация"). Пользователь оставляет телефон на +-- странице результата (или лендинге) с явным согласием на обработку персональных +-- данных. Уведомление (Telegram/email) сюда НЕ входит — нет существующей +-- SMTP/Telegram интеграции в коде (подтверждено), только persist + log; +-- notified_at зарезервирован под будущую доставку. +BEGIN; + +CREATE TABLE IF NOT EXISTS trade_in_leads ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE SET NULL, + phone text NOT NULL, + consent boolean NOT NULL, + source text, -- 'result', 'landing' + user_agent text, + created_at timestamptz NOT NULL DEFAULT now(), + notified_at timestamptz, -- когда/если ушло уведомление (пока не используется) + CONSTRAINT trade_in_leads_consent_required CHECK (consent IS TRUE) +); + +CREATE INDEX IF NOT EXISTS trade_in_leads_created_idx + ON trade_in_leads (created_at DESC); + +CREATE INDEX IF NOT EXISTS trade_in_leads_estimate_idx + ON trade_in_leads (estimate_id); + +COMMENT ON TABLE trade_in_leads IS '#2376 лид с контактом с результата оценки trade-in; notified_at зарезервирован под будущую Telegram/email-интеграцию (не реализована).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 397f1dec..0d8889dc 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -175,3 +175,4 @@ 168_fdw_osm_poi_ekb.sql 169_osm_poi_ekb_local.sql 170_scrape_schedules_seed_osm_poi_ekb_refresh.sql +172_trade_in_leads.sql diff --git a/tradein-mvp/backend/tests/test_trade_in_lead.py b/tradein-mvp/backend/tests/test_trade_in_lead.py new file mode 100644 index 00000000..37931512 --- /dev/null +++ b/tradein-mvp/backend/tests/test_trade_in_lead.py @@ -0,0 +1,130 @@ +"""Offline-тесты POST /api/v1/trade-in/lead (#2376, sub-issue родителя #1971). + +Покрытие (db мокается, NO live network/DB): + - happy path: consent=True + валидный телефон -> 200, INSERT + commit + - consent=False -> 422 (Literal[True] guard) + - невалидный формат телефона -> 422 + - estimate_id, которого нет в trade_in_estimates -> 404 +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def db() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def client(db: MagicMock) -> TestClient: + from app.api.v1 import lead as lead_module + from app.core.db import get_db + + app = FastAPI() + app.include_router(lead_module.router, prefix="/api/v1/trade-in") + + def fake_db() -> Any: + yield db + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +def _insert_result(lead_id: str) -> MagicMock: + result = MagicMock() + result.mappings.return_value.one.return_value = { + "id": lead_id, + "created_at": datetime(2026, 7, 4, 12, 0, tzinfo=UTC), + } + return result + + +def test_lead_happy_path(client: TestClient, db: MagicMock) -> None: + lead_id = str(uuid4()) + db.execute.return_value = _insert_result(lead_id) + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+7 (912) 345-67-89", "consent": True}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["id"] == lead_id + assert body["status"] == "received" + assert db.commit.called + + params = db.execute.call_args.args[1] + assert params["phone"] == "+7 (912) 345-67-89" + assert params["consent"] is True + assert params["source"] == "result" + assert params["estimate_id"] is None + + +def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None: + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+79123456789", "consent": False}, + ) + assert r.status_code == 422 + assert not db.execute.called + + +def test_lead_consent_missing_422(client: TestClient) -> None: + r = client.post("/api/v1/trade-in/lead", json={"phone": "+79123456789"}) + assert r.status_code == 422 + + +def test_lead_invalid_phone_422(client: TestClient) -> None: + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "not-a-phone!!", "consent": True}, + ) + assert r.status_code == 422 + + +def test_lead_with_unknown_estimate_id_404(client: TestClient, db: MagicMock) -> None: + result = MagicMock() + result.fetchone.return_value = None + db.execute.return_value = result + + r = client.post( + "/api/v1/trade-in/lead", + json={"phone": "+79123456789", "consent": True, "estimate_id": str(uuid4())}, + ) + assert r.status_code == 404 + assert not db.commit.called + + +def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> None: + lead_id = str(uuid4()) + exists_result = MagicMock() + exists_result.fetchone.return_value = (1,) + db.execute.side_effect = [exists_result, _insert_result(lead_id)] + + estimate_id = str(uuid4()) + r = client.post( + "/api/v1/trade-in/lead", + json={ + "phone": "+79123456789", + "consent": True, + "estimate_id": estimate_id, + "source": "landing", + }, + ) + assert r.status_code == 200, r.text + params = db.execute.call_args.args[1] + assert params["estimate_id"] == estimate_id + assert params["source"] == "landing"