-- 187_web_support_chat.sql -- Web-чат поддержки (сайт МЕРА) поверх УЖЕ существующего Telegram support-моста -- (data/sql/186_tg_support.sql, app/services/tgbot/bridge.py). Источник обращения -- меняется (сайт вместо Telegram-лички клиента), маршрутизация ответа оператора -- (реплай на зеркало в топике супергруппы) остаётся ТОЙ ЖЕ — оператор ничего -- нового не учит. -- -- ПОЧЕМУ ОТДЕЛЬНЫЕ ТАБЛИЦЫ, А НЕ "tg_support_* + channel" -- (взвешено явно, per code review requirement): -- -- Вариант А (отклонён) — добавить channel text ('telegram'|'web') в -- tg_support_users/tg_support_messages: -- - tg_support_users.chat_id bigint PRIMARY KEY — это Telegram private -- chat id клиента. У веб-пользователя сайта ЕГО НЕТ (клиент никогда не -- писал боту в личку) — пришлось бы либо (а) городить синтетический -- chat_id для веб-юзера (напр. отрицательный hash от username) — это -- вводит ВТОРУЮ систему идентификации внутри одной PK-колонки, -- семантика которой документирована как "Telegram chat id" (186:54), -- либо (б) делать chat_id NULLABLE и городить ещё одну колонку -- username NULLABLE рядом — таблица с двумя взаимоисключающими -- identity-схемами и кучей CHECK-ограничений вида -- "chat_id XOR username NOT NULL". -- - Доставка обратно ТОЖЕ разная: Telegram-путь шлёт copyMessage в личку -- клиента, веб-путь просто пишет строку в БД (personal chat не -- существует) — код в bridge.py и так ветвится по каналу, общая -- таблица не убирает эту ветку, только добавляет NULL-поля. -- - Риск регрессии: tg_support_* уже покрыты test_bridge.py (14+ -- кейсов) и работают в проде (PR #2526) — трогать рабочую, протестированную -- схему ради ещё не запущенной фичи повышает blast radius без выгоды. -- -- Вариант Б (выбран) — новые web_support_threads/web_support_messages: -- - Идентификатор клиента — username (X-Authenticated-User, сайт закрыт -- Caddy basic_auth, публичного доступа нет — см. app/main.py rbac_guard) -- — чистый, не smoke-и-зеркала не переиспользующий Telegram identity. -- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА -- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id, -- заполняется только для direction='in', NULL для direction='out'. -- Коллизий между web_support_messages.topic_message_id и -- tg_support_messages.topic_message_id НЕ возникает: оба — Telegram -- message_id ОДНОЙ и той же support-супергруппы, а Telegram message_id -- в пределах одного чата монотонно возрастает и никогда не переиспользуется -- — значит конкретное значение окажется ровно в одной из двух таблиц. -- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну -- дополнительную ветку (пробуем tg-резолв, потом web-резолв, потом -- existing orphan-warning) — существующий Telegram-путь не трогается. -- -- ЧТО: -- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия -- переписки с поддержкой, без под-тредов). -- - web_support_messages — лог переписки, direction='in' (от юзера) | -- 'out' (ответ оператора, реплай из bridge.py). -- -- 152-ФЗ: -- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер -- решит написать). ON DELETE CASCADE от web_support_threads делает erasure -- одной операцией: DELETE FROM web_support_threads WHERE username = :u. -- -- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run. -- Зависимости: нет (новые standalone таблицы, никакие существующие -- tg_support_*/иные таблицы не трогаются). BEGIN; CREATE TABLE IF NOT EXISTS web_support_threads ( id bigserial PRIMARY KEY, username text NOT NULL UNIQUE, created_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz NOT NULL DEFAULT now(), last_read_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE web_support_threads IS '152-ФЗ: одна строка на username (сайт МЕРА, X-Authenticated-User) — единый тред переписки с поддержкой через веб-чат. Удаление клиента — DELETE FROM web_support_threads WHERE username=...; ON DELETE CASCADE в web_support_messages подчищает переписку одной операцией.'; COMMENT ON COLUMN web_support_threads.username IS 'X-Authenticated-User (Caddy basic_auth) — сайт закрыт, анонимов нет, см. app/main.py rbac_guard.'; COMMENT ON COLUMN web_support_threads.last_seen_at IS 'Обновляется при отправке юзером нового сообщения (send-активность, НЕ на чтение истории).'; COMMENT ON COLUMN web_support_threads.last_read_at IS 'Отметка "прочитано до" (POST /api/v1/trade-in/support/read) — используется для счётчика непрочитанного (GET /support/unread).'; CREATE TABLE IF NOT EXISTS web_support_messages ( id bigserial PRIMARY KEY, thread_id bigint NOT NULL REFERENCES web_support_threads (id) ON DELETE CASCADE, direction text NOT NULL CHECK (direction IN ('in', 'out')), text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0), topic_message_id bigint, operator_tg_id bigint, created_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений). Каскадно удаляется вместе с web_support_threads по username.'; COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).'; COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).'; COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).'; COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.'; CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq ON web_support_messages (topic_message_id) WHERE topic_message_id IS NOT NULL; CREATE INDEX IF NOT EXISTS web_support_messages_thread_id_created_at_idx ON web_support_messages (thread_id, created_at DESC); COMMIT;