Commit graph

2398 commits

Author SHA1 Message Date
a7fbd348e0 Merge pull request 'feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)' (#2584) from feat/tradein-ui-city-hint into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m28s
Deploy Trade-In / build-frontend (push) Successful in 2m40s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 6m57s
2026-07-31 15:46:01 +00:00
939c07917b Merge pull request 'fix(tradein/tasks): городской гейт в ночном бэкфилле координат (#2583)' (#2588) from fix/tradein-coords-backfill-city-gate into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 44s
Deploy Trade-In / test (push) Has been cancelled
2026-07-31 15:43:53 +00:00
bot-backend
49ee85aae2 fix(tradein/ui): не называть неподтверждённый город в плашке неопределённости (#2576)
All checks were successful
CI / changes (pull_request) Successful in 14s
CI Trade-In / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m33s
Deep-review R3: плашка city_ambiguous всегда подставляла {city} — внутреннее
состояние с дефолтом "Екатеринбург" (initCityLabel), а не то, что реально
определил бэкенд (в ответе только булев target_city_ambiguous, угаданного
города там нет). Ровно в целевом сценарии фикса — нетронутая форма, «Ленина
1», cityConfirmed=false — текст утверждал «если это не Екатеринбург»
независимо от реального результата (там мог быть Нижний Тагил) — та же
нечестность, которую предыдущий коммит убирал из запроса, только в тексте.

Текст плашки теперь ветвится по cityConfirmed:
- cityConfirmed=true (город реально был подтверждён и отправлен) — прежний
  текст с конкретным {city} уместен, не меняю.
- cityConfirmed=false (это и есть путь, где cityAmbiguous обычно и
  срабатывает после предыдущего коммита) — нейтральная формулировка без
  упоминания конкретного города: «Если это неверно, выберите город выше и
  повторите оценку.»

tsc --noEmit / next lint / next build — чисто (те же 2 pre-existing warning в
несвязанных файлах).
2026-07-31 18:42:40 +03:00
790f69ee01 Merge pull request 'fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов' (#2587) from fix/tradein-hide-saleshare-cache-from-clients into main
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 24s
Deploy / build-backend (push) Successful in 52s
Deploy / build-worker (push) Successful in 52s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / deploy (push) Successful in 2m4s
Deploy Trade-In / test (push) Successful in 3m7s
Deploy Trade-In / build-backend (push) Has been cancelled
Deploy Trade-In / build-frontend (push) Has been cancelled
2026-07-31 15:39:56 +00:00
bot-backend
cf35e632db fix(tradein/tasks): городской гейт в ночном бэкфилле координат (#2583)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 14s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m8s
backfill_coords_from_geoportal брал все listings с lat IS NULL, парсил street+house
и матчил напрямую против EKB-only ekb_geoportal_buildings, минуя geocoder.geocode()
и его городской гейт (_names_non_ekb_city). Улица+дом могут буквально совпасть между
Екатеринбургом и другим городом области ("проспект Ленина 1" есть и в ЕКБ, и в Нижнем
Тагиле) — такие адреса получали екатеринбургские координаты и портили радиусные
выборки аналогов на этой улице в ЕКБ, а сами исчезали из выборки своего города.

Фикс: _names_non_ekb_city(address) перед вызовом _geoportal_house_match — тот же гейт,
что уже используется в geocode(). Прямой вызов geoportal-матчера (а не полноценный
geocode()) сохранён намеренно — pure local-DB операция без внешнего HTTP, полноценный
geocode() добавил бы Nominatim/Yandex вызов на каждый non-EKB адрес backlog'а (лишняя
нагрузка на ограниченный Nominatim, Yandex сейчас 403 — #2585).

geo_precision оставлен NULL для house-level матчей этого тира — по конвенции
089_listings_geo_precision.sql/geocode_missing.py NULL означает "не coarse", то же
значение что geo_precision=None для precise-адресов в geocode_missing_listings;
исключать из radius-аналогов нужно только 'city'-fallback.

Порядок окон (05:00 geoportal → 06:00 geocode_missing_listings) не менялся: гонка была
безвредна для корректно заматченных EKB-адресов, вредна только из-за отсутствия гейта —
теперь non-EKB адреса здесь не матчатся вообще и просто ждут oblast-aware провайдеров
в следующем окне.

Поправлен ложный комментарий в migration 171 ("не-ЕКБ адреса не матчатся — корректно").

Ущерб на проде (SELECT-only, без изменений): 2040 листингов с координатами внутри
EKB-bbox (56.65-56.95, 60.40-60.85) при адресе, называющем другой город области
(1941 после исключения мкр/р-н/жк-омонимов вроде ЖК "Заречный" внутри ЕКБ). Только
~31 из них совпадают по координатам с ekb_geoportal_buildings/gendesign_cad_buildings —
основной массив, вероятно, из других источников координат (не только этот таск).
Чистка — отдельный шаг.
2026-07-31 18:38:39 +03:00
8d3b571925 Merge pull request 'fix(tradein/geocoder): не подставлять Екатеринбург молча — явный city_hint (#2576)' (#2580) from fix/tradein-geocoder-city-default into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 27s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m32s
Deploy Trade-In / build-backend (push) Successful in 1m17s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-07-31 15:23:32 +00:00
bot-backend
abb9398f3f fix(tradein/rbac): скрыть «Доля в продаже» и «Кэш» от клиентских аккаунтов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m19s
CI Trade-In / backend-tests (pull_request) Successful in 3m13s
CI / openapi-codegen-check (pull_request) Successful in 3m34s
CI / backend-tests (pull_request) Successful in 16m7s
Аккаунт praktika (DB-роль manager) видел оба пункта в топбаре на /trade-in/team.
Это внутренние инструменты — аналитика рынка и состояние кэшей/скраперов, —
клиентские аккаунты их видеть не должны (решение владельца продукта).

Гейт один — deny-список роли, потому что все три места сверяются с ним через
общий матчер: пункт меню (Topbar по scopePath из /me), страница (RouteGuard) и
серверные ручки (rbac_guard). Правка только фронта спрятала бы пункт, оставив
прямой URL и API открытыми.

Закрыто для employee/manager (DB_ROLE_PATHS) и для legacy pilot (roles.yaml):
  /trade-in/sale-share/**
  /trade-in/cache/**
  /trade-in/api/v1/buildings/**
  /trade-in/api/v1/trade-in/cache-stats/**

У cache-stats ГЛОБ, а не точный путь: точный паттерн — строгое равенство, его
обходит трейлинг-слэш ('…/cache-stats/' → allowed=True), и защита держалась бы
на Starlette redirect_slashes, а не на RBAC. Замерено после правки: все варианты
(слэш, %2f, ./, ../) дают 403, утечек нет.

Основной продукт не задет: buildings.py обслуживает ТОЛЬКО sale-share, секция
«Продажи в доме» на экране оценки питается estimate-хендлерами. admin и analyst
сознательно вне deny — запиннено тестом, иначе «синхронизация» списков закрыла
бы их молча.

Заодно починен КРАСНЫЙ pre-existing тест главного бэкенда:
backend/tests/test_rbac.py::test_get_role_known_users ждал pilot у всех
user1..user10, но user2 («Брусника») стал expired 2026-07-30. CI это пропустил —
auth/roles.yaml не входит в paths-filter backend/**, из-за чего сьют не бежал.

Тесты: 153 passed (tradein) + 24 passed (site-finder, было 23+1 failed).
Новые — e2e через реальный rbac_guard по session-ветке (именно ею ходит
praktika), пин deny_paths в выдаче /me, границы глоба и regression-guard'ы.
Проверены снятием deny: 7 тестов краснеют, т.е. не тавтологии.
2026-07-31 18:20:03 +03:00
eb44718677 Merge pull request 'fix(tradein/avito): обогащать деталями листинги всей области, не только ЕКБ (#2576)' (#2578) from fix/tradein-avito-detail-oblast into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m9s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / deploy (push) Successful in 1m47s
2026-07-31 15:16:49 +00:00
bot-backend
a8d29fa031 fix(tradein/ui): не слать city_hint по умолчанию — только явный выбор или город из адреса (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m15s
Deep-review R2 на #2580/#2576: предыдущий коммит слал city_hint="Екатеринбург"
даже когда дропдаун не тронут — бэкенд трактует ЛЮБОЙ city_hint как «пользователь
назвал город» (city_specified=True), так что target_city_ambiguous становился
false практически всегда, а необнаруженный житель Нижнего Тагила («Ленина, 1»
без явного упоминания города) молча резолвился бы в Екатеринбург — ровно баг,
который чинил backend, только переехавший из geocoder.py в city-registry.ts.

Вариант A (по рекомендации ревьюера): город реально известен (и поэтому
отправляется в city_hint) ТОЛЬКО когда пользователь явно выбрал его в
дропдауне ИЛИ detectCityInText нашёл совпадение в наборном тексте / выбранной
подсказке. Нетронутый дефолт → city_hint не уходит вовсе (ни в geocode/suggest,
ни в POST /estimate) — тогда backend честно возвращает target_city_ambiguous и
не форсит ЕКБ-bias без запроса.

- Новое состояние `cityConfirmed` (ParamsPanel.tsx) — гейт на отправку,
  раздельный от `city` (best-guess для отображения/текста плашки). true после
  explicit dropdown pick ИЛИ автодетекта из текста/подсказки; sticky —
  мелкая правка адреса без нового совпадения его не сбрасывает.
- До подтверждения дропдаун показывает `UNCONFIRMED_CITY_LABEL`
  ("Определить автоматически"), не статичное "Екатеринбург" — не выдаёт
  внутренний best-guess за подтверждённый пользователем выбор.
- useGeocodeSuggest получает city_hint только при cityConfirmed=true — для
  нетронутой формы автокомплит тоже больше не форсит ЕКБ-bias молча, а видит
  кандидатов из всей области (в т.ч. Нижний Тагил) — это и есть тот сценарий
  из заголовка эпика.
- ЕКБ happy path не усложнён: как только пользователь печатает город в адресе
  или (обычный путь) выбирает любую подсказку из автокомплита, detectCityInText
  почти всегда находит "Екатеринбург" в full_address (провайдер возвращает
  город как часть резолвленного адреса независимо от того, был ли отправлен
  hint) — дропдаун сам переключается на "Екатеринбург" и cityConfirmed
  становится true без отдельного клика. Требует лишнего действия только
  редкий путь "напечатал произвольный адрес без города и нажал Enter, не
  выбрав ни одной подсказки".
- city-registry.ts: явный комментарий-ссылка на бэкендовый гэзеттир
  `SVERDLOVSK_OBLAST_CITIES` (tradein-mvp/backend/app/services/geocoder.py) —
  parity-риск при добавлении нового города остаётся видимым с фронтовой
  стороны (backend/тесты не трогаю — другой PR, вне моего scope).

tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning в
несвязанных файлах, как и в предыдущем коммите).
2026-07-31 18:15:40 +03:00
996c4509ff Merge pull request 'fix(tradein/estimator): гео-ограничение якорного тира — не брать чужие города (#2581)' (#2586) from fix/tradein-anchor-geo-scope into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m4s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 1m5s
2026-07-31 15:08:23 +00:00
bot-backend
b3c76c8c62 fix(tradein/geocoder): city_hint должен доходить до локальных тиров + не терять ЕКБ-привязку в Tier 4 (#2576)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m19s
Deep-review PR #2580 нашёл два блокера в предыдущем фиксе (#2576):

C1 — city_hint не участвовал в гейте локальных ЕКБ-only тиров
(geoportal/cadastral, `use_local_ekb` в geocode() и Tier 1 в suggest()).
Явный city_hint="Нижний Тагил" для "Ленина 1" всё равно попадал на ЕКБ-only
базы, получал "точный" ЕКБ-хит и при этом city_ambiguous=False (хинт ведь
был) — то есть система теперь ложно-уверенно утверждала неверный город.
Фикс: city_hint участвует в той же проверке _names_non_ekb_city (гейт/
gazetteer #2582 не трогаю — только добавляю вход).

C2 — снятие суффикса ", Екатеринбург" в _nominatim_suggest для случая
"город неизвестен" регрессило часть реальных ЕКБ-адресов: без текстовой
подсказки о городе Nominatim иногда предпочитает street-level матч в
соседнем городе-спутнике (эмпирика ревьюера: "Победы 20" без суффикса →
Верхняя Пышма вместо ЖК "Парк Победы" в Екатеринбурге). Решение — dual-query:
bare (честный, без города) И ЕКБ-suffixed запросы объединяются (не заменяют
друг друга), оба честных кандидата остаются в подсказках, пользователь
выбирает сам. Extra round-trip только для последнего fallback-тира
(cadastral/DaData/Yandex уже не сработали) — не задевает основной трафик.

Заодно (🟠, дешёвая правка): city_hint прокинут в _nominatim_lookup — с тех
пор как Yandex-ключ недействителен (#2585), это единственный живой внешний
провайдер, и его tie-break (предпочитает tight-ЕКБ bbox) без города не
различает одноимённые улицы внутри региона.

4 новых теста (C1×2, C2×2 + dedupe) — проверено что все 4 падают на коде до
этого коммита через git stash (только geocoder.py, тесты оставлены).
2026-07-31 17:55:18 +03:00
bot-backend
23b8816863 fix(tradein/estimator): гео-ограничение якорного тира — не брать чужие города (#2581)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m13s
Tier A (_fetch_anchor_comps, "тот же дом") матчил по normalized street+house
(_normalize_building_key намеренно дропает город) БЕЗ единого гео-предиката —
единственный запрос к listings в файле без ST_DWithin/города/house_id_fk/LIMIT.
"Серов, ул. Ленина, 5" получал якорь по ЕКБ-объявлениям (~40 191 из ~40 200
активных листингов — ЕКБ) с самым доверенным тиром 'A', завышая цену в 4-5х;
хуже — anchor_tier='A' блокировал честный #oblast-D deals-headline-fallback
(гейт `anchor_tier is None`), так что область не могла получить даже
резервную ДКП-оценку.

Фикс мирроит уже одобренный geo-bound для Tier S (f9ae6f0c, #oblast-D):
ST_DWithin(geom::geography, subject_point, ANCHOR_TIER_A_RADIUS_M) от
lat/lon субъекта, radius = DEFAULT_RADIUS_M (1000м, тот же константа что и
Tier S) — строковый match уже устанавливает "та же улица+дом", радиус нужен
только чтобы отсечь РЕАЛЬНО кросс-городские коллизии (ЕКБ/Серов — сотни км),
не для внутридомовой точности. Tier A целиком гейтится на lat/lon субъекта
(как Tier C) — без них геопредикат невозможен; в проде geo всегда есть
(_empty_estimate возвращается раньше при неудачном geocode).

Объявления без geom: `AND geom IS NOT NULL` + ST_DWithin (NULL → exclude,
не blind-include) — >99.97% листингов имеют координаты (см. f9ae6f0c),
исключение погрешности не создаёт.

_normalize_building_key НЕ тронут (город по-прежнему не входит в ключ) —
добавление городского токена сломало бы матчинг источников без города в
адресе (Avito anonymous) и внесло бы новый normalization-риск; кросс-
городская коллизия закрыта на SQL-уровне надёжнее.

Поправлен ложный комментарий _band_haircut (:1900-1908): "same-building
anchor pool для oblast не формируется" был неверен даже ДО фикса (Tier A
не имел гео-фильтра вовсе — либо матчил ЕКБ, либо честно матчил местные
листинги, если они были).

Тест-страж test_estimator_deals_headline_fallback_oblast_d.py использовал
голый db=MagicMock() для _fetch_anchor_comps — db.execute(...).mappings().all()
на unconfigured MagicMock тривиально возвращает [] (MagicMock __iter__ default),
так что anchor_tier никогда не мог стать 'A' и регрессия было бы невидима.
Пропатчено явно (документирует допущение) + добавлен новый тест, который
НЕ мокает _fetch_anchor_comps — гоняет реальную SQL-логику через фейковый
db.execute, симулирующий настоящую ST_DWithin-фильтрацию по haversine-
дистанции. На pre-fix коде (git stash) даёт headline=186 400 ₽/м² вместо
честных 30 000 (deal corridor) — воспроизводит репортнутый баг buквально.
EKB control-тест подтверждает: несколько объявлений в одном доме ЕКБ
по-прежнему формируют Tier A anchor (n=4, ~145k ₽/м²) — не деградировало.

test_tier_a_primary_1774.py: позитивные Tier A тесты теперь передают
lat/lon субъекта (гейт требует их) — тесты на python-side novostroyki-
гейтинг (ожидающие tier=None) оставлены с lat=None (конечный результат
не меняется: Tier A целиком пропускается без lat/lon, как и Tier C раньше).

Full suite: 2842 passed, 9 skipped, 1 pre-existing failure (test_search_cache_hit,
не связан). Ruff чист.
2026-07-31 17:39:00 +03:00
bot-backend
2779ef5e79 feat(tradein/ui): передавать город в геокодирование + честный признак неопределённости (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
Раньше интерфейс город вообще не передавал — backend (#2580) больше не
подставляет "Екатеринбург" молча, из-за чего житель Нижнего Тагила, вводя
«Ленина, 1», получал бы результат по одноимённой екатеринбургской улице.

- Новый справочник src/lib/city-registry.ts (растущий список городов области,
  сейчас: Екатеринбург, Нижний Тагил, Каменск-Уральский, Первоуральск,
  Верхняя Пышма, Серов) — DEFAULT_CITY = Екатеринбург, чтобы ЕКБ-сценарий не
  требовал никаких лишних действий.
- ParamsPanel: компактный дропдаун «Город» рядом с лейблом адреса (переиспользует
  существующий <Dd> HUD-комбобокс) + автоопределение города из набранного
  текста/выбранной подсказки (detectCityInText, word-boundary safe — не путает
  "Серов" с "ул. Серова" в ЕКБ). city_hint уходит в geocode/suggest и в
  POST /trade-in/estimate.
- useGeocodeSuggest(query, cityHint, limit) — city_hint в query-параметрах и в
  queryKey, чтобы смена города рефетчила подсказки.
- Честная подсказка в ParamsPanel, когда estimate.target_city_ambiguous===true:
  спокойный (не danger) текст «Город определён автоматически — результат может
  относиться к другому населённому пункту области. Если это не {city},
  выберите верный город выше и повторите оценку.» — не блокирует форму.
- types/trade-in.ts: TradeInEstimateInput.city_hint,
  AggregatedEstimate.target_city_ambiguous (зеркалит backend PR #2580, ещё не
  смёржен — codegen не запускался, поля добавлены вручную по контракту схемы).

tsc --noEmit / next lint / next build — чисто (только 2 pre-existing warning
в несвязанных файлах).
2026-07-31 17:08:46 +03:00
bot-backend
5a66c2df51 fix(tradein/avito): экранировать _ в LIKE-паттернах + честная эмуляция LIKE в тесте (#2576)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m15s
2026-07-31 17:02:14 +03:00
e1935b609f Merge pull request 'feat(tradein/db): авто-refresh ценовых бэндов по городам + порог для малых городов (#2576)' (#2579) from feat/tradein-price-bands-refresh into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m6s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 5m19s
2026-07-31 14:01:34 +00:00
bot-backend
3595cb603f test(tradein): добавить deal_city_price_bands_refresh в паритет-тест планировщика (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m15s
_PRODUCT_SOURCES в test_scraper_kit_scheduler_parity.py — ручной canonical-список
(файл сам предписывает обновлять его при изменении product_handlers.py, #2397 Part C
уже чинил ровно эту протухшесть). Новый source из PR #2579 забыт — без него
test_real_build_product_handlers_covers_all_scheduled_sources не покрывает
регистрацию Handler'а: пропажа entry в product_handlers.py осталась бы незамечена,
а kit-scheduler логировал бы "unknown source, skip" каждый тик без выполнения задачи.

Проверено: тест падает при временном откате Handler-registration (AssertionError
misses source=deal_city_price_bands_refresh), проходит после восстановления.
2026-07-31 16:56:16 +03:00
bot-backend
4b1a282234 fix(tradein/geocoder): не подставлять Екатеринбург молча — явный city_hint (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m17s
Раньше _yandex_lookup/_yandex_suggest/_nominatim_suggest молча подставляли
"Екатеринбург, " в запрос, если в адресе не было маркера города/области.
Житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по
екатеринбургской улице Ленина (обе улицы называются одинаково) — фронт город
вообще не передаёт.

- geocode()/suggest() принимают опциональный city_hint: str | None; без него
  внешние тиры больше НЕ подставляют город, а bias (ll/spn) смещается на всю
  область (OBLAST66_VIEWBOX) вместо ЕКБ-центра. Явный маркер города в адресе
  или city_hint сохраняют прежнее поведение (ЕКБ-путь не деградирует).
- GeocodeResult.city_ambiguous — честный флаг «город определил провайдер, а
  не пользователь» (не эвристика на корректность), проброшен в
  AggregatedEstimate.target_city_ambiguous (ephemeral, не персистится).
- Cache-ключ geocode_cache учитывает city_hint (address|city=...) — без hint'а
  формат не меняется (backward-compat), с hint'ом разные города для одного
  текста адреса больше не делят одну запись.
- API: /api/v1/geocode/lookup, /suggest и POST /trade-in/estimate получили
  опциональный city_hint — контракт не ломается (default None).

23 новых теста в test_geocoder_city_hint.py; проверено что они падают
(ImportError на _cache_key) на коде до фикса через git stash.
2026-07-31 16:42:29 +03:00
bot-backend
f44ed4043c feat(tradein/db): авто-refresh ценовых бэндов по городам + порог для малых городов (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m21s
Миграция 178 засеяла deal_city_price_bands разово (N>=30 сделок), без scheduler'а
на refresh. Город без строки падал на глобальный DEAL_MIN_PPM2=50000 (ЕКБ-калибровка)
— для дешёвых городов области это не anti-outlier guard, а cut-off легитимного рынка
(Североуральск median ~21.7k). Замер по прод-данным: 289 из 369 не-ЕКБ городов
(1265 сделок) не имели строки и падали на ЕКБ-порог.

- 194_deal_city_price_bands_tiers.sql — трёхуровневая схема (tier колонка):
  full (N>=30, own p1/p99, unchanged) / rough (N 10-29, own p1 floor + фикс.
  ceiling 800000) / region_fallback (N 1-9, pooled областной p1=15263 вместо
  ЕКБ-порога). Екатеринбург по-прежнему не в таблице — estimator fallback
  byte-identical.
- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql — scrape_schedules
  row, окно 07:00-08:00 UTC (после rosreestr_dkp_import + asking_to_sold_ratio_refresh).
- app/tasks/deal_city_price_bands_refresh.py — периодический re-derive (kit-scheduler,
  byte-identical 194 derivation), без DELETE (множество городов монотонно растёт).
- app/services/product_handlers.py — регистрация Handler для нового source.

Валидация: scratch-БД (syntax_check) в прод-контейнере, synthetic данные на
границах тиров (N=9/10/29/30) + Екатеринбург/non-rosreestr/NULL exclusion, оба
файла применены дважды (идемпотентность подтверждена), scratch-БД удалена.
2026-07-31 16:33:20 +03:00
bot-backend
10442d0187 fix(tradein/avito): обогащать деталями листинги всей области, не только ЕКБ (#2576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m27s
2026-07-31 16:33:04 +03:00
103dc22b8c Merge pull request 'feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет»' (#2577) from feat/tradein-anon-support into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m33s
Deploy Trade-In / build-frontend (push) Successful in 3m1s
Deploy Trade-In / build-backend (push) Successful in 1m16s
Deploy Trade-In / deploy (push) Successful in 1m22s
2026-07-31 13:22:20 +00:00
910ef2503c Merge pull request 'fix(tradein/team): admin управляет менеджерами — сброс пароля и блокировка (#2556)' (#2575) from fix/tradein-team-admin-manages-managers into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 21s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m34s
Deploy Trade-In / build-frontend (push) Successful in 3m18s
Deploy Trade-In / build-backend (push) Successful in 1m26s
Deploy Trade-In / deploy (push) Successful in 1m18s
2026-07-31 13:08:13 +00:00
bot-backend
40fc94ee91 feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет»
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m42s
CI Trade-In / backend-tests (pull_request) Successful in 2m31s
После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку
остался чат ЗА логином, а самая частая причина писать в поддержку — как раз
«не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму
входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об
этом из продукта не могла ничем — на /login не было ни чата, ни контакта.

Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard):
- Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех
  же web_support_threads под ключом `anon:<token>`. Двоеточие делает коллизию с
  реальным логином структурно невозможной (CHECK миграции 193 разрешает только
  `^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред.
- Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается
  ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки.
- Форма куки валидируется — мусор из браузера не становится ключом треда.
- В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`;
  зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет.
- Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит
  обход ротацией куки, без него публичная ручка записи в общий топик беззащитна.
- Кука и запись в БД — только после успешного sendMessage (порядок операций H1),
  неудачная отправка не закрепляет за посетителем пустой тред.

Frontend:
- `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь
  и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима).
  Дефолт "auth" — существующие места монтирования не меняются.
- `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики,
  из которых пользователю больше некуда идти. На /login добавлена подсказка.

Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится
по topic_message_id → thread_id, кто автор треда — там неважно.

Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard.
62 passed (test_support + test_rbac).
2026-07-31 16:04:51 +03:00
bot-backend
5817e0b31a fix(tradein/team): admin управляет менеджерами — сброс пароля и блокировка (#2556)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 2m27s
После cutover'а на DB-auth (#2558) аккаунты `kopylov` и `praktika` живут с
`role='manager'`, а team-API жёстко фильтровал `role='employee'` — сбросить
менеджеру пароль или заблокировать его было НЕЧЕМ, кроме ручного psql на проде.
Всплыло 2026-07-31: «Практика» весь день билась в логин (5 failed, 0 успешных),
а восстановить доступ через UI админ не мог.

Что меняется:
- `_fetch_employee_row` берёт actor: admin → `role IN ('employee','manager')`,
  manager → по-прежнему только `role='employee'` + свои по `manager_id`.
- `GET /employees` без фильтра отдаёт admin'у и менеджеров (`?manager_id=` —
  без изменений, только сотрудники этого менеджера).
- `EmployeeOut.role` — новое поле, UI показывает бейдж «менеджер» и
  склоняет тексты («Заблокировать менеджера ...» вместо «сотрудника»).

Инвариант self-lockout сохранён и усилен тестом: строки `role='admin'`
недостижимы через этот роутер ни для кого, включая самого админа, поэтому
ни block, ни смена пароля с `revoke_user_sessions` не могут вырубить
действующего админа. Раздача роли admin остаётся вне API.

Тесты: 6 новых (список с менеджерами, сброс пароля менеджеру + отзыв сессий,
блокировка, manager не достаёт до чужого менеджера, admin не достаёт до
admin-строки), 3 существующих обновлены под новое ожидание списка.
2026-07-31 15:51:50 +03:00
f6b68b6eca Merge pull request 'feat(tradein/caddy): снять basic_auth с /trade-in — своя авторизация (#2558)' (#2569) from feat/tradein-caddy-cutover into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Successful in 35s
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 36s
Deploy / deploy (push) Successful in 1m8s
2026-07-30 21:52:00 +00:00
bot-backend
2b24408c3f docs(tradein/caddy): исправить обоснование scrub + scrub в ui-preview (#2558)
All checks were successful
CI / changes (pull_request) Successful in 14s
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deep-review PR #2569 (echo-стенд на caddy:2, caddy adapt): комментарий у
/trade-in/api* и @tradein утверждал, что старая Set-форма
(header_up X-Authenticated-User {http.auth.user.id}) пропустила бы клиентский
заголовок насквозь — измерено, что это неверно. Caddy подставляет
неразрешённый плейсхолдер как литеральную строку (ReplaceKnown), т.е. старая
форма была бы fail-closed-but-сломанной (get_role("{http.auth.user.id}") ->
403 для всех), а не bypass. Решение (-Field вместо Set) остаётся правильным
по другой причине: это единственная форма с документированной delete-
семантикой, не зависящая от трактовки пустого/нерезолвленного плейсхолдера.

Также: @uipreview (единственный блок в trade-in-периметре без scrub) теперь
тоже явно удаляет X-Authenticated-User - единообразие периметра, хоть
сейчас и инертно (статичная страница без секрета).

И: комментарий у @saleshare уточнён - снятие Caddy basic_auth с
/trade-in/sale-share не делает страницу публичной, она по-прежнему за
RouteGuard + session-required API (редирект на /login без валидной сессии).

Провалидировано: caddy validate --config Caddyfile --adapter caddyfile
(caddy:2, локальный docker) -> "Valid configuration".
2026-07-31 00:19:26 +03:00
69fd0ba260 Merge pull request 'fix(tradein/ui): вход в «Команду» из v2-навигации + понятный 401 (#2556)' (#2570) from fix/tradein-team-nav-and-401 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / deploy (push) Successful in 1m3s
2026-07-30 21:16:23 +00:00
bot-backend
a3628843c3 fix(tradein/ui): вход в «Команду» из v2-навигации + понятный 401 (#2556)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m11s
`/trade-in/` редиректит на `/trade-in/v2`, а v2-навигация (TopNav.tsx)
не знала про team-дашборд вообще — пункт «Команда» был только в legacy
Topbar.tsx (NAV_ITEMS), который на v2-страницах не рендерится. Дашборд
существовал, но был недостижим кликом.

- app/v2/page.tsx: showTeamNavItem — тот же двойной гейт, что и
  Topbar.NAV_ITEMS "team" (isPathAllowed(/api/v1/team) + роль
  admin/manager), передаётся в TopNav.
- v2/TopNav.tsx: пункт «Команда» в user-меню рядом с «Выйти» (не таб
  SectionOverlay — /team отдельный роут, а не секция текущей страницы).

Второй дефект: team-API требует session-cookie, легаси Caddy-роль
(/me 200) через него не проходит → GET /api/v1/team/employees отдаёт
401, UI показывал невнятную красную плашку. app/team/page.tsx теперь
ловит 401 отдельно от 403 и уводит на /login?next=/team (тот же
redirect-паттерн, что RouteGuard.tsx для 401 от /me; в dev — сообщение
с кнопкой «Войти» вместо авто-редиректа, тот же NODE_ENV-гейт что и в
RouteGuard).
2026-07-31 00:13:46 +03:00
bot-backend
01bf4a91e4 feat(tradein/caddy): снять basic_auth с /trade-in — своя авторизация (#2558)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Оба trade-in блока (`handle /trade-in/api/*`, `@tradein`) + связанный
`@saleshare` редирект перенесены ВЫШЕ `import caddy/users.caddy.snippet`
в route{} — Caddy матчит handle-блоки сверху вниз, поэтому они теперь
short-circuit'ят до basic_auth-гейта (тот же механизм, что уже применён
к /health, /preview/*, @uipreview). Site Finder (/, /api/*, /analytics)
остаётся под basic_auth без изменений.

Trade-in уже имеет собственную авторизацию (форма входа + opaque
session-cookie, #2552) поверх RBAC внутри tradein-backend — Caddy
basic_auth для него стал избыточным легаси-слоем.

Security: после переноса плейсхолдер {http.auth.user.id} для этих
блоков никогда не резолвится (basic_auth больше не выполняется на
пути к ним). Вместо `header_up X-Authenticated-User {http.auth.user.id}`
(Set-операция с пустым значением — недокументированно надёжна как
удаление заголовка) используется явное `header_up -X-Authenticated-User`
(документированная delete-семантика `-<field>`). Это исключает проход
клиентского X-Authenticated-User насквозь к tradein-backend, который
в auth_mode="dual" всё ещё принимает legacy trusted-header путь —
без явного удаления внешний запрос с подделанным заголовком мог бы
выдать себя за другого пользователя. X-Internal-Auth-Secret не тронут
(#2213, всегда перезаписывается из env).

Провалидировано: `caddy validate --config Caddyfile --adapter caddyfile`
(caddy:2 образ, локальный docker) → "Valid configuration".
2026-07-31 00:05:33 +03:00
80d54cb5cd Merge pull request 'feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556)' (#2565) from feat/tradein-team-dashboard into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / deploy (push) Successful in 1m1s
2026-07-30 19:27:30 +00:00
a916ceea52 Merge pull request 'feat(tradein/db): seed существующих юзеров + ASCII-CHECK на username (#2557)' (#2564) from feat/tradein-users-seed into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m4s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-07-30 19:17:34 +00:00
1364cd8e7a Merge pull request 'fix(tradein/team): устойчивая сортировка списка + семантика unlimited в батч-квотах (#2554)' (#2566) from fix/tradein-team-list-order into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-30 19:17:10 +00:00
bot-backend
1ed0338b95 fix(tradein/ui): пагинация на границе страницы + запрос списка только для admin/manager (#2556)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m30s
EmployeeTable: убран ранний return при пустом списке — на offset>0 (ровно
50/100/150 сотрудников) пейджер и кнопка «Назад» теперь остаются доступны;
текст различает «сотрудников вообще нет» (offset=0) и «страница за концом
списка» (offset>0).

team/page.tsx: useEmployees получает enabled=isAllowedRole, вычисленный ДО
вызова хука — прямой заход employee/analyst/pilot на /team больше не шлёт
обречённый GET до отрисовки role-gate.
2026-07-30 22:12:18 +03:00
bot-backend
df943ea1c7 fix(tradein/team): \Z вместо $ в username-regex — 422 вместо 500 на trailing newline (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m18s
Deep-review seed'а (#2564) нашёл смежный дефект в уже смерженном коде (#2563):
Python `$` матчит перед trailing newline (re.match(r'...\$', 'admin\n') -> True),
а Postgres `~` в CHECK tradein_users_username_ascii_ck (миграция 193) - False.
username="admin\n" проходил Pydantic-валидацию и падал уже в БД -> 500 вместо
честного 422. `\Z` - конец строки без поблажки на trailing newline, совпадает
с семантикой Postgres `~`.

Grep по app/schemas/ (pattern=/regex=/re.compile/re.match/re.fullmatch) -
других regex-валидаторов с `$` в схемах trade-in нет, team.py - единственный
файл с regex-based полем.
2026-07-30 22:10:52 +03:00
bot-backend
f915e1ee4d fix(tradein/db): не затирать manager_id при повторном прогоне сида (#2557)
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m0s
Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида
тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник
выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed
поля.

is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL
DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение
симметрией с реально работающими COALESCE-полями). Open/close доступа — решение
владельца продукта через UI (#2556), не повторный прогон seed-файла.
2026-07-30 22:08:14 +03:00
bot-backend
f112a4affb feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m9s
Единая страница /team для ролей admin/manager (backend сам скоупит список
по org-изоляции) — таблица сотрудников с пагинацией, создание сотрудника
с ручным паролем, изменение месячной квоты + сброс пароля одним PATCH,
drawer с историей оценок. Nav-пункт «Команда» в Topbar виден только
admin/manager (доп. roleGate поверх isPathAllowed — legacy analyst-роль
иначе тоже прошла бы path-фильтр).
2026-07-30 22:02:41 +03:00
bot-backend
2e05a16a14 fix(tradein/team): устойчивая сортировка списка + семантика unlimited в батч-квотах (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m0s
Deep-review PR #2563 follow-up (после merge+deploy):
1. ORDER BY created_at DESC, id DESC в _LIST_EMPLOYEES_BY_MANAGER_SQL /
   _LIST_EMPLOYEES_ALL_SQL. created_at DEFAULT now() — время транзакции, bulk-seed
   (#2557) вставляет много юзеров одной транзакцией -> идентичный timestamp у N+
   строк -> без тай-брейкера порядок между LIMIT/OFFSET страницами на PostgreSQL
   для строк-близнецов не гарантирован (сотрудники пропадали/дублировались бы
   при листании). id (BIGINT IDENTITY, монотонный) — детерминированный tie-break.
2. _batch_quota_status: unlimited теперь честно совпадает с
   account_quota.is_unlimited — override.unlimited=true честится ТОЛЬКО для
   username, присутствующего в roles.yaml (KeyError -> unlimited=False всегда,
   override даже не читается). Раньше батч всегда читал override независимо от
   roles.yaml -> список мог показать "unlimited" для квоты, которую реальный
   enforcement (check_and_raise/increment, тот же is_unlimited) не признаёт.
   Сегодня недостижимо (unlimited есть только у kopylov/praktika, оба в
   roles.yaml), но станет достижимым при расширении ролевки.
2026-07-30 22:02:19 +03:00
bot-backend
e7a658c7fa feat(tradein/db): seed существующих юзеров + ASCII-CHECK на username (#2557)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m56s
Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую
владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192).
password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556).
ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через
encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются
в общий downstream-identity (IDOR) — constraint запрещает это fail-closed.

ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email
через COALESCE — повторный прогон (recovery / staging без _schema_migrations
tracking) не затирает то, что менеджер поменял через UI.
2026-07-30 21:56:16 +03:00
4128564341 Merge pull request 'feat(tradein/team): team-management API — employees CRUD, quotas, stats (#2554)' (#2563) from feat/tradein-team-api into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m53s
Deploy Trade-In / build-backend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 1m16s
2026-07-30 18:38:26 +00:00
bot-backend
4ac3971061 fix(tradein/team): revoke сессий при смене пароля + Origin-check + батч-квоты (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m56s
Deep-review PR #2563:
- HIGH: PATCH new_password не ревокал старые сессии сотрудника — sliding-refresh
  продлевал скомпрометированную/чужую сессию бесконечно. Теперь revoke_user_sessions
  вызывается при is_active=False ИЛИ new_password (self-lockout невозможен —
  _fetch_employee_row фильтрует role='employee').
- Medium1: Origin/Referer-check (защита CSRF) на POST/PATCH team-роутах против
  settings.cors_origins; оба заголовка отсутствуют (curl-смоук внутри контейнера) —
  пропускаем, не ломаем прод-смоук.
- Medium2: батч-квота для GET /employees (_batch_quota_status) — было 2N+3 SQL-запросов
  на N сотрудников, стало 5 плоско (замерено: N=10 -> 5 запросов). Добавлена пагинация
  limit(default 50, max 200)/offset.
- Заодно: статические WHERE-ветки вместо f-string-сборки в list_employees;
  monthly_limit теперь сбрасывает account_quota_overrides.unlimited=false и не
  затирает существующий note.
2026-07-30 21:23:32 +03:00
acba900f21 Merge pull request 'feat(tradein/ui): login-форма + session-aware RouteGuard (#2555)' (#2562) from feat/tradein-login-ui into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m59s
Deploy Trade-In / deploy (push) Successful in 53s
2026-07-30 18:10:46 +00:00
bot-backend
e6d68c349e fix(tradein/ui): харденинг sanitizeNext + сохранение query в next (#2555)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s
PR #2562 review, 3 однострочника:
1. sanitizeNext обходился: WHATWG URL-парсер (router.push) вырезает ASCII
   tab/CR/LF из ВСЕЙ строки перед парсингом, так что "/\t//evil" проходил
   regex (позиция 1 — таб, не "/"/"\\"), а после навигации резолвился в
   protocol-relative "//evil" → чужой origin. Теперь сначала strip
   [\t\r\n], потом валидация — regex видит ту же строку, что увидит парсер.
2. next=/login (или /login?...) кидал юзера обратно на форму входа
   (RouteGuard не гейтит /login) — dead-end. Фолбэк на "/".
3. RouteGuard брал next= только из usePathname(), без query — сессия,
   истёкшая на deep-link (/v2?id=<uuid>), теряла отчёт после релогина.
   Добавлен window.location.search в next (effect всегда client-side).
2026-07-30 20:57:54 +03:00
bot-backend
712c56f456 feat(tradein/team): team-management API — employees CRUD, quotas, stats (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m47s
Session-only identity (current_team_actor, admin|manager) поверх tradein_users/
tradein_sessions (#2552 foundation). Org-изоляция manager <-> employee через
manager_id: чужой/несуществующий employee_id -> 404 (не 403 — не палим
существование), POST с чужим manager_id в теле от manager игнорируется
(принудительно свой id). Квота — upsert в account_quota_overrides (существующий
паттерн, без правки account_quota.py). История оценок — user_events LEFT JOIN
trade_in_estimates. Team-события (employee_created/blocked/unblocked/
password_reset/quota_changed) без пароля в payload.
2026-07-30 20:51:31 +03:00
bot-backend
15d506b7dd feat(tradein/ui): login-форма + session-aware RouteGuard (#2555)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m4s
POST /api/v1/auth/login/logout уже в main (DB-backed session, httponly
cookie tradein_session). Фронт: /login-форма (username+password, ошибки
401/429 по-русски, next= redirect с open-redirect guard), RouteGuard
редиректит на /login при 401 вместо NoAccessScreen variant=session
(prod-only, dev-режим без Caddy не трогаем), useLogout хук чистит
/me-кэш и уходит на /login. Role расширена admin|manager|employee (новые)
+ pilot|analyst|expired (legacy dual-mode resolver на бэке).
2026-07-30 20:46:33 +03:00
d3e0aa296c Merge pull request 'feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)' (#2561) from feat/tradein-auth-core into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m23s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-07-30 17:19:44 +00:00
bot-backend
4ecc3d689c fix(tradein/auth): предотвратить подмену X-Authenticated-User при session-auth (#2552)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m25s
CRITICAL: _propagate_authenticated_user делала skip-if-present вместо
перезаписи — клиент-контролируемый X-Authenticated-User (Caddy шлёт его
на КАЖДЫЙ прод-запрос) выигрывал у резолвленной сессии для всего
downstream-трафика, читающего заголовок напрямую (_assert_estimate_access*,
account_quota, /trade-in/history, support.py) — в обоих auth_mode
(dual и db_only). Теперь заголовок безусловно перезаписывается сессионным
username (ASGI header-имена всегда lowercase bytes).

Medium: .encode("latin-1") без errors="replace" крашил бы 500-кой каждый
запрос кириллического username. Login timing-oracle — verify_password
короткозамыкалась на unknown-username/NULL-hash (~1мс vs ~100-300мс bcrypt)
→ теперь всегда сверяется против dummy-хеша при отсутствующем юзере/хеше.
Login rate-limit key length-prefixed — username с ':' (или IPv6 IP) больше
не может схлопнуть чужой бюджет.

Новые тесты подтверждают регрессию: прогнаны на старом коде (до фикса)
через временный откат rbac.py — все три (spoof dual-mode, spoof db_only,
кириллица) падали с 'victim' == 'alice' / UnicodeEncodeError; после
фикса — зелёные. test_rbac.py/test_internal_auth_secret.py без изменений.
2026-07-30 20:14:26 +03:00
bot-backend
0835266516 feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m19s
Foundation для эпика #2549: session-cookie auth поверх legacy Caddy
trusted-header. app.services.auth_session — CRUD для tradein_sessions
(create/get/revoke) + get_user_by_username для password-логина; opaque
secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh
(не чаще раза в 5 минут).

POST /api/v1/auth/login проверяет password_hash (bcrypt) через
app.core.password, ставит httponly+secure cookie, пишет
login_success/login_failed в user_events; per-username+IP rate-limit
(SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST
/logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's
auth-required gate (иначе логин сам себя не пропустил бы).

rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль
employee/manager/admin -> paths как у pilot/+team/admin), fallback на
legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode
== "dual"; auth_mode == "db_only" отключает legacy header полностью.
Резолвленный сессией username инжектится в ASGI scope headers (до
call_next) — RequestAuditMiddleware и downstream route-хендлеры видят
его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard)
для session-запросов лимитирует по IP, не по username — документированный
trade-off, не регрессия.

GET /me — session-first: валидная cookie отдаёт scope из tradein_users
без похода в roles.yaml; без cookie — прежний legacy путь. session_secret
остаётся опциональным (opaque-токены не требуют подписи) — пустое
значение только logger.warning на старте, не startup-fail.

Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py,
test_account_quota.py) проходит без правок — regression-safe.
2026-07-30 10:48:28 +03:00
876538c57f Merge pull request 'feat(tradein/auth): password hashing + session config foundation (#2550)' (#2560) from feat/tradein-auth-password-foundation into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m6s
Deploy Trade-In / build-backend (push) Successful in 1m52s
Deploy Trade-In / deploy (push) Successful in 1m15s
2026-07-30 07:04:56 +00:00
e703d64a7f Merge pull request 'feat(tradein/db): tradein_users + tradein_sessions schema (#2551)' (#2559) from feat/tradein-auth-users-schema into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m2s
Deploy Trade-In / build-backend (push) Successful in 50s
Deploy Trade-In / deploy (push) Successful in 1m19s
2026-07-30 06:59:23 +00:00
bot-backend
3f1b86f16a feat(tradein/auth): password hashing + session config foundation (#2550)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m14s
2026-07-30 09:40:51 +03:00
bot-backend
0c5246913a feat(tradein/db): tradein_users + tradein_sessions schema (#2551)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m10s
2026-07-30 09:40:05 +03:00