praktika (ГК «Практика») role expired -> pilot: доступ восстановлен по решению
владельца продукта. Добавлен expiredtest QA-аккаунт, чтобы test_rbac.py сохранил
regression-покрытие role=expired (praktika больше не годится как fixture).
account_quota.is_unlimited() больше не хардкодит username == 'kopylov': грант
безлимитных оценок теперь читается из account_quota_overrides.unlimited (migration
191) — единый источник правды для всех non-admin unlimited-аккаунтов. kopylov
перенесён в данные той же миграцией (поведение не меняется, деплой применяет SQL
раньше кода); praktika получил такой же грант.
unlimited выбран как явная boolean-колонка, а не sentinel monthly_limit (-1/0),
т.к. 0 неоднозначен ("ноль оценок" vs "без лимита").
Support-мост (#2526) уже работает на проде, но попасть в бота с сайта было
неоткуда — клиент о нём просто не узнавал.
Два входа, оба ведут в @MERAsupport_bot:
- SupportButton — плавающая кнопка в правом нижнем углу, на всех экранах /v2
- «Помощь» в меню TopNav — была задизейблена с «скоро появится», теперь живая
Закрывает перекос пошире: LeadForm монтируется только при hasEstimate
(v2/page.tsx:1064), т.е. до расчёта оценки у клиента не было НИ ОДНОГО способа
связаться. Единственная другая ссылка на связь во всём UI — NoAccessScreen.tsx:84,
и та лишь на экране «доступ закончился».
Решения:
- Это внешняя ссылка, а не чат-виджет: бот живёт в Telegram, поэтому ни API,
ни состояния, ни сокетов не нужно.
- URL — module-константа, не NEXT_PUBLIC_*: env инлайнятся на build-time, так что
переменная не дала бы гибкости рантайма, только лишний build-arg. Источник
правды один — SUPPORT_BOT_URL в SupportButton.tsx, TopNav импортирует его.
- Монтаж в v2/layout, не в глобальный app/layout: последний накрывает ещё админку
/scrapers/** и /sale-share — другой продукт без бренда МЕРА.
- createPortal в document.body: /v2 рисует HUD внутри артборда 1536×1024 с
transform: scale(), а position:fixed внутри трансформированного предка
позиционируется относительно него, а не вьюпорта — без портала кнопку унесло бы
вместе с HUD.
- z-index 25 — осознанно НИЖЕ всех оверлеев v2 (SectionOverlay 29/30,
LocationDrawer 40/41, TopNav 50, UserMenu 200): при открытой модалке кнопка
должна уходить под неё, а не воровать клики и таб-порядок.
- Стиль только через tokens.ts (файл прямо запрещает хардкодить hex);
готового <Button> в проекте нет, следуем инлайн-паттерну LeadForm.
Профиль/Настройки в том же меню остаются задизейбленными — страниц под них нет.
A11y: aria-label, тач-таргет 44px, focus-visible ring, инлайновый SVG с aria-hidden.
Проверка: tsc --noEmit чисто, lint чисто (3 warning'а pre-existing, не в этих
файлах), npm run build успешно (/v2 49.4 kB, все 15 страниц prerender).
Визуальная проверка — на проде после деплоя: локально RouteGuard без бекенда
возвращает null (RouteGuard.tsx:46) и рендер не доходит до кнопки.
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.
Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.
Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.
Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
утекал в GlitchTip через locals стек-фреймов (include_local_variables
по умолчанию True) и через span data HttpxIntegration. Закрыто
include_local_variables=False + regex-редактор в before_send (обе формы:
/bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
docker logs каждые 30с. Приглушён до WARNING.
Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
restart: unless-stopped выход с любым кодом даёт рестарт-луп.
unless-stopped выбран сознательно — только он гарантирует автозапуск
после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
иначе сессия в failed-transaction не давала сохранить offset, апдейт
переигрывался и зеркалился в топик по кругу.
152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.
Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.
Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
Admin-only «Аудит доступа» → /admin/audit and «Активность» → /admin/analytics links in the UserMenu dropdown. Navigates via API_BASE_URL prefix (Topbar convention, basePath-safe). Pilot menu unchanged.
Admin-only /admin/audit (accounts × IP/device × searches) and /admin/analytics (KPIs, daily chart, top searches/paths/accounts) over the user_events read API. Gated like /scrapers; 403 → NoAccessScreen.
Admin-only read API: GET /admin/audit/accounts, /admin/audit/accounts/{username}, /admin/analytics over user_events. Read-only, empty-safe, RBAC via central gate.
Non-EKB Свердловская-область addresses no longer snap into Екатеринбург.
Root cause: geocode()/suggest() ran EKB-only local tiers (geoportal/cadastral) before the oblast-aware external providers, with no city gate — a non-EKB street+house colliding with an EKB building resolved to EKB coords. Fix gates the local tiers via _names_non_ekb_city (reuses SVERDLOVSK_OBLAST_CITIES, word-boundary + district-prefix exclusion + \bекатеринбург\b short-circuit). EKB path byte-identical. Deep-review APPROVE, CI green, live-verified (Nominatim returns correct Н.Тагил coords once local tiers skip).
Follow-up (main): geocode_cache purge of ~323 poisoned rows.
Deep-review fast-follows on the non-EKB city gate:
1. Word-boundary the "екатеринбург" short-circuit in _names_non_ekb_city
(new _EKATERINBURG_RE = \bекатеринбург\b, .search() instead of substring
`in`). A bare-substring check matched "Екатеринбургское шоссе" (a real
street in satellite towns like Pervouralsk) as if it named EKB, wrongly
keeping EKB-only local tiers on for a non-EKB address. Still returns False
for the existing homonym false-positive test case (word boundary is
satisfied there — "екатеринбург" appears as a standalone token).
2. Gate suggest()'s Tier 1 (same root cause as geocode()): the EKB-only
cadastral matchers _cadastral_house_match / _cadastral_forward_sync ran
unconditionally, so a non-EKB oblast autocomplete query could surface an
EKB building via street+house collision. Gated behind
`not _names_non_ekb_city(query)`, mirroring geocode()'s use_local_ekb.
External suggest tiers (DaData/Yandex/Nominatim) untouched — still the
path for non-EKB autocomplete.
3. Extended SVERDLOVSK_OBLAST_CITIES with unambiguous, deal-heavy oblast
cities: алапаевск, сухой лог, кушва, красноуральск, карпинск, нижняя
тура, верхний тагил, нижние серги. Deliberately did NOT add "лесной"
(reviewer flagged as reviewer-optional, left to judgment): DB check
(ekb_geoportal_buildings) confirms a real EKB street named exactly
"Лесной" (3 buildings) — an exact whole-word collision indistinguishable
from ЗАТО Лесной by word-boundary matching alone (unlike "Серова"/"Серов",
which are different word forms). Adding it would misclassify a bare
EKB address ("Лесной, 5", no "Екатеринбург" mention) as non-EKB.
Tests: word-boundary EKB regression test, suggest() gating test, two new
gazetteer entries (Верхняя Пышма multi-word, Сухой Лог).
Prod-cache verification (deep-review) found 3 false-positives: EKB addresses
that literally contain "екатеринбург" but also happen to contain a homonym
of another oblast city inside a neighborhood/posyolok name — ЖК "Заречный"
and пос. Сысерть, both administratively inside Ekaterinburg, not the ZATO
Zarechny / town Sysert they share a name with. These were gated off the
EKB-only local tiers; if Nominatim were momentarily down, a real EKB address
would return None — a latent regression not present before this PR.
Add an explicit-city short-circuit at the top of _names_non_ekb_city: if the
normalized address contains "екатеринбург", return False immediately (EKB
tiers stay on). Cannot affect the true-positive (non-EKB) cases, which never
contain "екатеринбург".
geocode() ran EKB-only local tiers (2a _geoportal_house_match on
ekb_geoportal_buildings, 2c _cadastral_house_match, 2d
_cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the
already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX
+ region cross-check, shipped in c0cbdc2f). _parse_street_house drops the
city, so any non-EKB oblast street+house that collides with an EKB building
(e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited
to EKB coordinates.
Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES
gazetteer (minus Ekaterinburg) and word-boundary matching from
_has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly
names a different oblast city. Gate tiers 2a/2c/2d behind
`use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep
the exact same code path (byte-identical ordering/logic, only additive
gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which
already handle oblast-wide geocoding correctly.
Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows);
ekb_geoportal_buildings is 100% EKB by construction — gating loses no
meaningful local coverage for non-EKB cities.
Follow-up (not in this PR): geocode_cache has rows poisoned by the old
behavior (non-EKB addresses cached with EKB coordinates) — needs a purge.