backend: POST /api/v1/trade-in/lead + persist (parent #1971) #2376

Closed
opened 2026-07-04 06:32:33 +00:00 by lekss361 · 1 comment
Owner

Parent: #1971

Контекст

mailto-стаб в tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx:162-198 (leadHref/leadEnabled) — единственная точка лид-CTA в кодовой базе. Нужен реальный backend перед заменой frontend (sub-issue — заведён отдельно, блокируется этим issue).

Референс-паттерн — backend/app/api/v1/pilot.py (POST /api/v1/pilot/request): та же структура (Pydantic input, INSERT...RETURNING, logger.info, notification — TODO). Копировать конвенцию 1-в-1.

Scope

  1. SQL migration tradein-mvp/backend/data/sql/172_trade_in_leads.sql:

    • 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, user_agent text, created_at timestamptz NOT NULL DEFAULT NOW(), notified_at timestamptz)
    • CHECK (consent IS TRUE) — defense-in-depth (основная валидация на API-уровне)
    • CREATE INDEX IF NOT EXISTS trade_in_leads_created_idx ON trade_in_leads (created_at DESC)
    • notified_at — колонка-заготовка под будущий notifier, миграция под него позже не нужна (паттерн pilot_requests)
    • BEGIN/COMMIT, idempotent (см. .claude/rules/sql.md)
  2. Endpoint POST /api/v1/trade-in/lead:

    • Pydantic input: phone: str (простая regex-валидация, НЕ EmailStr-подобный пакет — см. комментарий в pilot.py про email-validator ImportError), estimate_id: UUID | None, consent: Literal[True] (Pydantic сам вернёт 422 при false/отсутствии), source: Literal["result", "landing"] = "result"
    • Файл: новый tradein-mvp/backend/app/api/v1/lead.py (предпочтительно — trade_in.py уже 1747+ строк) + include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) в main.py. Альтернатива — функция прямо в trade_in.py, на усмотрение backend-engineer.
    • INSERT + RETURNING id/created_at, db.commit(), logger.info("trade_in_lead saved id=%s source=%s", ...)
    • psycopg v3 conventions: CAST(:x AS type), никаких f-string в SQL
  3. Notification — НЕ строить. Grep подтвердил: ни SMTP, ни Telegram интеграции нет нигде в коде (ни backend/app, ни tradein-mvp/backend/app) — тот же гэп, что у pilot.py (TODO с прошлого спринта). Просто persist + structured log. notified_at резервируется под follow-up.

Acceptance

ssh gendesign
docker exec tradein-backend curl -s -X POST localhost:8000/api/v1/trade-in/lead \
  -H "Content-Type: application/json" \
  -d '{"phone":"+79001234567","consent":true,"estimate_id":"<real-uuid>","source":"result"}'
# → 200/201 + id/created_at

docker exec tradein-postgres psql -U <user> -d tradein -c "SELECT * FROM trade_in_leads ORDER BY created_at DESC LIMIT 1"
# → строка присутствует

# consent:false → ожидаем 422

Не входит (см. комментарий на #1971)

  • Email/Telegram notification (нет существующей интеграции — отдельный future scope)
  • Аналитика воронки (conversion)
  • CRM-интеграция за пределами persist в trade_in_leads

Референсы для делегирования

  • backend/app/api/v1/pilot.py — эталонный паттерн
  • backend/data/sql/118_pilot_requests.sql — эталонная миграция
  • tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql — последний файл, новый = 172_trade_in_leads.sql
  • tradein-mvp/backend/app/api/v1/trade_in.py (router), tradein-mvp/backend/app/main.py (include_router)
  • tradein-mvp/backend/data/sql/001_trade_in_estimates.sql — FK-таргет trade_in_estimates(id uuid)
Parent: #1971 ## Контекст mailto-стаб в `tradein-mvp/frontend/src/components/trade-in/HeroTransparency.tsx:162-198` (leadHref/leadEnabled) — единственная точка лид-CTA в кодовой базе. Нужен реальный backend перед заменой frontend (sub-issue — заведён отдельно, блокируется этим issue). Референс-паттерн — `backend/app/api/v1/pilot.py` (POST /api/v1/pilot/request): та же структура (Pydantic input, INSERT...RETURNING, logger.info, notification — TODO). Копировать конвенцию 1-в-1. ## Scope 1. **SQL migration** `tradein-mvp/backend/data/sql/172_trade_in_leads.sql`: - `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, user_agent text, created_at timestamptz NOT NULL DEFAULT NOW(), notified_at timestamptz)` - `CHECK (consent IS TRUE)` — defense-in-depth (основная валидация на API-уровне) - `CREATE INDEX IF NOT EXISTS trade_in_leads_created_idx ON trade_in_leads (created_at DESC)` - `notified_at` — колонка-заготовка под будущий notifier, миграция под него позже не нужна (паттерн `pilot_requests`) - BEGIN/COMMIT, idempotent (см. `.claude/rules/sql.md`) 2. **Endpoint** `POST /api/v1/trade-in/lead`: - Pydantic input: `phone: str` (простая regex-валидация, НЕ EmailStr-подобный пакет — см. комментарий в pilot.py про email-validator ImportError), `estimate_id: UUID | None`, `consent: Literal[True]` (Pydantic сам вернёт 422 при `false`/отсутствии), `source: Literal["result", "landing"] = "result"` - Файл: новый `tradein-mvp/backend/app/api/v1/lead.py` (предпочтительно — `trade_in.py` уже 1747+ строк) + `include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])` в `main.py`. Альтернатива — функция прямо в `trade_in.py`, на усмотрение backend-engineer. - INSERT + RETURNING id/created_at, `db.commit()`, `logger.info("trade_in_lead saved id=%s source=%s", ...)` - psycopg v3 conventions: `CAST(:x AS type)`, никаких f-string в SQL 3. **Notification — НЕ строить.** Grep подтвердил: ни SMTP, ни Telegram интеграции нет нигде в коде (ни `backend/app`, ни `tradein-mvp/backend/app`) — тот же гэп, что у `pilot.py` (TODO с прошлого спринта). Просто persist + structured log. `notified_at` резервируется под follow-up. ## Acceptance ``` ssh gendesign docker exec tradein-backend curl -s -X POST localhost:8000/api/v1/trade-in/lead \ -H "Content-Type: application/json" \ -d '{"phone":"+79001234567","consent":true,"estimate_id":"<real-uuid>","source":"result"}' # → 200/201 + id/created_at docker exec tradein-postgres psql -U <user> -d tradein -c "SELECT * FROM trade_in_leads ORDER BY created_at DESC LIMIT 1" # → строка присутствует # consent:false → ожидаем 422 ``` ## Не входит (см. комментарий на #1971) - Email/Telegram notification (нет существующей интеграции — отдельный future scope) - Аналитика воронки (conversion) - CRM-интеграция за пределами persist в trade_in_leads ## Референсы для делегирования - `backend/app/api/v1/pilot.py` — эталонный паттерн - `backend/data/sql/118_pilot_requests.sql` — эталонная миграция - `tradein-mvp/backend/data/sql/171_scrape_schedules_seed_geoportal_coords_backfill.sql` — последний файл, новый = `172_trade_in_leads.sql` - `tradein-mvp/backend/app/api/v1/trade_in.py` (router), `tradein-mvp/backend/app/main.py` (include_router) - `tradein-mvp/backend/data/sql/001_trade_in_estimates.sql` — FK-таргет `trade_in_estimates(id uuid)`
lekss361 added the
enhancement
scope/backend
scope/db
tradein
labels 2026-07-04 10:23:51 +00:00
Author
Owner

Закрываю по итогам разбора трекера 16.08.2026

Вердикт: сделано кодом.

Эндпоинт с миграцией и валидацией смержен в main двумя PR; фронтовая замена mailto — отдельный sub-issue.

Доказательство: коммит 2538fc02 «feat(tradein/lead): POST /api/v1/trade-in/lead — persist contact leads (#2376) (#2390)» + доработка 0583fb0e «fix(trade-in): усилить валидацию /lead и почистить контракт (#2376)» в forgejo/main

Независимая проверка. Вердикт отдельно проверялся вторым проходом, задачей которого было именно опровергнуть закрытие, а не подтвердить его:

Оба коммита реально в main (git merge-base --is-ancestor → да): 2538fc02 и 0583fb0e. По пунктам scope: (1) миграция tradein-mvp/backend/data/sql/172_trade_in_leads.sql есть (плюс более поздняя 182_trade_in_leads_consent_proof.sql); (2) эндпоинт вынесен в отдельный app/api/v1/lead.py (Pydantic phone с regex + доп.валидатор на 10-15 цифр, consent: Literal[True] → 422, estimate_id: UUID|None с IDOR-гвардом _assert_estimate_access, CAST(:id AS uuid) по правилу psycopg v3), роутер подключён в app/main.py:32,278 с prefix /api/v1/trade-in; (3) нотификация намеренно не строилась, как и требовало тело задачи. Проверил на проде, а не только в ветке: openapi.json контейнера tradein-backend содержит путь /api/v1/trade-in/lead; таблица trade_in_leads существует со всеми колонками из спеки (id, estimate_id, phone, consent, source, user_agent, created_at, notified_at + client_ip/consent_policy_version/consent_text_snapshot/expires_at из 182) и содержит 4 реальные строки (последняя 2026-07-12). Фронтовая замена mailto телом задачи явно вынесена в отдельный sub-issue, так что её отсутствие не держит эту.

Если что-то из перечисленного всё же живо — переоткройте задачу, разбор мог упустить частный случай.

## Закрываю по итогам разбора трекера 16.08.2026 **Вердикт:** сделано кодом. Эндпоинт с миграцией и валидацией смержен в main двумя PR; фронтовая замена mailto — отдельный sub-issue. **Доказательство:** коммит 2538fc02 «feat(tradein/lead): POST /api/v1/trade-in/lead — persist contact leads (#2376) (#2390)» + доработка 0583fb0e «fix(trade-in): усилить валидацию /lead и почистить контракт (#2376)» в forgejo/main **Независимая проверка.** Вердикт отдельно проверялся вторым проходом, задачей которого было именно опровергнуть закрытие, а не подтвердить его: > Оба коммита реально в main (`git merge-base --is-ancestor` → да): 2538fc02 и 0583fb0e. По пунктам scope: (1) миграция `tradein-mvp/backend/data/sql/172_trade_in_leads.sql` есть (плюс более поздняя 182_trade_in_leads_consent_proof.sql); (2) эндпоинт вынесен в отдельный `app/api/v1/lead.py` (Pydantic `phone` с regex + доп.валидатор на 10-15 цифр, `consent: Literal[True]` → 422, `estimate_id: UUID|None` с IDOR-гвардом `_assert_estimate_access`, `CAST(:id AS uuid)` по правилу psycopg v3), роутер подключён в `app/main.py:32,278` с prefix `/api/v1/trade-in`; (3) нотификация намеренно не строилась, как и требовало тело задачи. Проверил на проде, а не только в ветке: `openapi.json` контейнера tradein-backend содержит путь `/api/v1/trade-in/lead`; таблица trade_in_leads существует со всеми колонками из спеки (id, estimate_id, phone, consent, source, user_agent, created_at, notified_at + client_ip/consent_policy_version/consent_text_snapshot/expires_at из 182) и содержит 4 реальные строки (последняя 2026-07-12). Фронтовая замена mailto телом задачи явно вынесена в отдельный sub-issue, так что её отсутствие не держит эту. Если что-то из перечисленного всё же живо — переоткройте задачу, разбор мог упустить частный случай.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2376
No description provided.