"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C. WHY: trade_in has no self-service "delete my data" endpoint at all. Both B2B pilots (identified by `created_by` username) and future anonymous B2C users need SOME way to have their personal data physically removed on request, not just after their retention TTL expires (app/tasks/purge_expired_trade_in_data.py handles the TTL path, this module handles the on-demand path). WHO CAN BE IDENTIFIED, HONESTLY: - B2B pilot (has a `username`): trivially -- `created_by = username` scopes every estimate they created; leads/support threads follow from there. - Anonymous person: has NO username. This function can ONLY act on identifiers the requester can actually supply: * `estimate_ids` -- if they still have the link/PDF from their estimate (the UUID in the URL/QR-code IS their proof of "this is mine"). * `phone` -- if they left a contact-request lead with that phone. Matched by CANONICAL RU DIGITS on both sides (see `_ru_phone_norm_sql` below), not an exact string: lead.py stores `payload.phone` exactly as typed (no E.164 normalization, by design), so "+7 999 123-45-67", "8 (999) 123-45-67" and "89991234567" must all find the same row. Covers ONLY the RU 8-vs-7 trunk-prefix case (exact digit-count identity, no heuristic truncation) -- see the helper's docstring for why. * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own Telegram chat id -- not guessable/spoofable by a third party the way a name or IP would be). If an anonymous person has NONE of these (e.g. they only remember the street address, or ran an estimate but never saved anything and didn't log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional identification. There is no username, no stable session, nothing in the DB schema today that lets a support operator find "the one estimate this specific stranger made three days ago" among many. Do not paper over this: an operator facing that case must say so, not silently pick "the closest match". ⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit): Every tg_support_messages row was, at send time, ALSO mirrored by the bot into the support-group Telegram topic (see app/services/tgbot/bridge.py, 186_tg_support.sql). Deleting `tg_support_users` here only removes the copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup, outside this function's reach, and is NOT deleted by anything in this codebase. A complete erasure across the whole chain requires a SEPARATE manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in the supergroup) that is out of scope here. Do not cite this function's return value as proof of full erasure of the Telegram-side copy. WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped): `user_events` (184_user_events.sql) logs `estimate_request` events with a JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by `username` (empty string for anonymous callers today) + `ip_address`, with NO FK to trade_in_estimates (decoupled/append-only by explicit design -- see that migration's comment). This function does NOT purge user_events: it is an audit/analytics log, not an estimate/lead/support record, and deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that overrides an erasure request is a legal call, not an engineering one. Flag it to whoever handles the request; do not assume it is already covered. """ from __future__ import annotations import logging from collections.abc import Sequence from uuid import UUID from sqlalchemy import text from sqlalchemy.orm import Session logger = logging.getLogger(__name__) def _ru_phone_norm_sql(expr: str) -> str: """SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр, ведущая '7'), для сравнения "разного форматирования одного и того же номера" (deep-review 2026-08-06, MEDIUM + follow-up). Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем неудаление своих. Номера другой длины/страны просто не совпадут ни на этом шаге, ни дальше -- безопасный отказ, не false positive. `expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или `CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит структуру запроса из литералов, вызывающих её мест ровно два (см. _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон не попадает в текст SQL напрямую, только через bind-параметр `:phone`. """ stripped = f"regexp_replace({expr}, '\\D', '', 'g')" return ( f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' " f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)" ) # Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data # (колонка trade_in_leads.phone / входной CAST(:phone AS text)). _PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone") _PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)") def erase_person_data( db: Session, *, username: str | None = None, estimate_ids: Sequence[UUID] | None = None, phone: str | None = None, tg_chat_id: int | None = None, ) -> dict[str, int]: """Physically delete a person's data across trade_in tables. At least one identifier is required (raises ValueError otherwise -- callers MUST pass an explicit identifier, never "erase everything" by omission). Order of operations matters: leads are captured/deleted BEFORE estimates, because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the estimate row is gone, the join key to find "leads that came from this person's estimate" is gone too. Returns per-table deleted-row counters. Callers own committing the ambient Session lifecycle in whatever way their layer does (this function DOES commit itself, mirroring app/tasks/*.py conventions, since this is a one-shot admin operation, not a request-scoped unit of work shared with other writes). """ if not any([username, estimate_ids, phone, tg_chat_id]): raise ValueError( "erase_person_data requires at least one identifier: " "username / estimate_ids / phone / tg_chat_id" ) counters: dict[str, int] = { "trade_in_estimates_deleted": 0, "trade_in_leads_deleted": 0, "web_support_deleted": 0, "tg_support_deleted": 0, } # 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids # (анонимный путь -- человек прислал ссылку/PDF) + все id с # created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после # DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id # уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен. all_estimate_ids: set[UUID] = set(estimate_ids or []) if username: owned = ( db.execute( text("SELECT id FROM trade_in_estimates WHERE created_by = :username"), {"username": username}, ) .scalars() .all() ) all_estimate_ids.update(owned) # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по # телефону (лид мог быть оставлен без attach к оценке вовсе). # # ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом # (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py # сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная # E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN), # т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67" # ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone` # находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ # формат, каким когда-то ввёл номер -- почти никогда так. Раньше это # молча удаляло 0 строк и всё равно возвращало 200 "данные удалены": # для 152-ФЗ ложное подтверждение удаления хуже честной ошибки. # _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql # выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую # '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс, # без усечения до "последних 10 цифр" (риск ложного совпадения с # номером другой страны -- см. докстринг helper'а). Номера иных # форматов/длин сравниваются как есть (просто не совпадут). Параметр -- # CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL # injection convention, .claude/rules/backend.md); сам SQL-текст # собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет # ни одного значения параметра. ids_param = [str(i) for i in all_estimate_ids] result = db.execute( text( f""" DELETE FROM trade_in_leads WHERE estimate_id = ANY(CAST(:ids AS uuid[])) OR ( CAST(:phone AS text) IS NOT NULL AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL} ) """ ), {"ids": ids_param, "phone": phone}, ) counters["trade_in_leads_deleted"] = result.rowcount or 0 # 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations). if all_estimate_ids: result = db.execute( text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), {"ids": ids_param}, ) counters["trade_in_estimates_deleted"] = result.rowcount or 0 # 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у # анонима username нет и быть не может, см. 187_web_support_chat.sql). if username: result = db.execute( text("DELETE FROM web_support_threads WHERE username = :username"), {"username": username}, ) counters["web_support_deleted"] = result.rowcount or 0 # 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально # доступный анониму без username (см. module docstring). ⚠️ Чистит # ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ # затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше. if tg_chat_id is not None: result = db.execute( text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"), {"chat_id": tg_chat_id}, ) counters["tg_support_deleted"] = result.rowcount or 0 db.commit() logger.info( "erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s", username, len(all_estimate_ids), "" if phone else None, tg_chat_id, counters, ) return counters __all__: list[str] = ["erase_person_data"]