# Бизнес-постановка: Уведомления (Notification Center) **Статус:** v6 — каталог видов уведомлений переведён в данные (§5.2); все открытые вопросы закрыты; вход в проектирование OpenAPI и миграций **Продукт:** HAN Chat (клиентское приложение + `api-backend`) **Источники:** макет Figma (**не канон** — только визуализация элементов UI; при расхождении приоритет у этого ТЗ), архитектура `HAN_chat_specification` (`arch-00`…`arch-05`, `module-01`) **Связанный backlog:** «Моделирование уведомлений»; смежно — кнопка «Позвонить оператору», кнопка «Войти»; **отдельно** — индикатор непрочитанных сообщений чата (не в scope этого ТЗ) **Нормативная часть — §1–§16.** Открытых вопросов нет: все решения приняты и внесены в текст. §17 — ненормативный журнал решений; при расхождении с §1–§16 приоритет у §1–§16. --- ## 1. Цель Дать клиенту единый канал сервисных и маркетинговых коммуникаций вне чата: срочные напоминания, статусы услуг, документы, оплаты, акции, а также подсказки в гостевой зоне (авторизация, установка PWA). Чат остаётся каналом диалога с оператором; уведомления — канал «компания → клиент» с действиями (переход, оплата, загрузка/скачивание документов, переход в чат по акции). Непрочитанные ответы оператора в чате **не** моделируются как уведомления (§3.2). Гостевые `ads_global`/`promo_global` — не персонализированная реклама на конкретного клиента; отдельное маркетинговое согласие не требуется. Функционал описывается в Пользовательском соглашении. --- ## 2. Два контура показа | Контур | Кто видит | Источник данных | Frontend | |---|---|---|---| | **G. Гостевые уведомления** | Только гость | `guest_notifications` → **public API** | Единственный источник в гостевом режиме | | **P. Персональные уведомления** | Только авторизованный | `notifications` (`user_id` NOT NULL) → **JWT API** | Единственный источник в ЛК | Правила: 1. До авторизации: только контур **G**. 2. После авторизации: только контур **P**. Гостевые **не** показываются и **не** переносятся в персональные. 3. Смешивать G и P в одной таблице / через `user_id IS NULL` — **запрещено**. 4. Принадлежность контуру задаётся **видом** уведомления (`notification_types.contour`), а не признаком записи. Один вид принадлежит ровно одному контуру. 5. В v1 **нет** клиентских условий показа (ОС / установленность PWA / прочие фильтры): фронт отображает то, что вернул API. Локальная генерация карточек на устройстве **запрещена**. 6. **Лимит и сортировка применяются на сервере.** Клиент не досортировывает и не дообрезает список. 7. У фронта всегда ровно один источник: public API **или** JWT API. Дубли тематики (акция и в G, и в P) допустимы — это разные виды и разные записи; после входа виден только P. --- ## 3. Границы релиза ### 3.1. В scope - Контуры G и P (§2); персональный жизненный цикл: `lifecycle_status` / `visibility` / `is_read` / `close_reason` + `record_status` (arch-05). - UI: карусель на главной, Центр уведомлений, деталка; инструкция по установке всегда открывается внешней страницей в новой вкладке. - **Каталог видов уведомлений как данные** (§5.2): справочник видов и три реестра (механики CTA, кнопки, палитра) в БД; добавление вида на существующей механике CTA не требует изменений кода. Публичный id записи — **UUID v7** (генерация в приложении). - Клиентские действия (ЛК): крестик, CTA, деталка, кнопки деталки из справочника (§5.4), оплата (вид CTA), приём предложения (вид CTA), загрузить документ, скачать документ. - Отправка документов клиентом: черновики → «Отправить документы» → реестр → `sync_queue` (**механизм общий, переиспользуемый другими фичами**, §6.5, §10.6–§10.8). - Internal API: **Create**, **Cancel** (без upsert / без update content) — §7.4. - Константы `notification.*` в `app_settings`; правила вложений — reuse `chat.attachments.*`. - Кнопки **Чат** + **Оператор** (`tel:` ← `operator.call.phone`). - **Realtime по WS — обязателен** (§9.4). Polling — только резервный режим деградации. - Готовность модели к Web Push (UUID, события); реализация push — out of scope. ### 3.2. Вне scope - Тип `message` / непрочитанный чат (отдельная фича). - Push / Web Push / deep link из push. - Админ-UI для гостевых уведомлений (v1: insert/SQL миграцией или seed-скриптом). - Жёсткая привязка «вид ↔ upstream-сервис». - **Админ-UI для каталога видов.** Добавление вида в v1 — seed-миграция; сама модель к появлению админки готова, но экран редактирования справочников в scope не входит. - **Новые механики CTA и новые кнопки деталки** сверх перечисленных в §5.3 и §5.4: их добавление требует кода и планируется отдельно. - Раздел профиля «Документы» / архив оплат — не заменяются уведомлениями. Но документы компании из уведомлений **регистрируются в таблице `documents`**, чтобы будущий раздел профиля собрал их без миграции файлов. - Запись в `sync_queue` из application-кода: только **триггер БД** (§10.8). - **Фактическая доставка документов в Bitrix24.** Полный `bitrix-sync` первого релиза по module-07 обрабатывает только Contact; `document.client_uploaded` остаётся вне его scope и не claim-ится. В scope notification-релиза — только корректная постановка задачи в `sync_queue`. - SMS/email поверх ЛК. - История чатов как UI-раздел — deprecated; backend API диалогов этим ТЗ не удаляется. - **Архив `lifecycle_status = closed` в UI v1 — нет.** Записи хранятся в БД бессрочно; ретенция и архивирование закрытых уведомлений не выполняются (§13). - Пагинация Центра: сверх лимита показываются только приоритетные записи (§6.2). - Внешние лендинги для рекламных видов: единственная внешняя страница — инструкция по установке (§6.4). - Поле `opened` / impression — **не моделируем** (CTA → `is_read`). - Мультиязычность уведомлений: `header` / `text` приходят готовой строкой. Перевод на мнемоники — после запуска механизма `text_resources` (сейчас таблица пустая и приложением не используется). - Валидация `notification_datetime` относительно текущего времени и отложенный показ (`date_visible_from`) — будущие доработки. --- ## 4. Изменения UI | Место | Было | Стало | |---|---|---| | Под полем чата | (см. текущий UI) | **Чат** + **Оператор** (`operator.call.phone`) | | Верхнее меню | История чата | Центр уведомлений (колокольчик; бейдж только в ЛК) | | Главная | — | Карусель (лимит/сортировка §6.1) | | Центр | — | ЛК — список; гость — auth-gate + «Авторизоваться» | | Деталка | — | Для видов с CTA `open_detail`; блоки и кнопки — из каталога (§5.6.1, §6.3) | | Инструкция по установке | — | Переход на страницу другого сайта для `install_app` (§6.4) | Визуал видов — по Figma. Figma не канон поведения. Палитра `color_token` (§5.2.1) и набор `icon_code` (§5.2) сверяются с Figma: имена токенов и кодов фиксируются в БД, значения цветов и сами SVG — в теме и коде фронта. --- ## 5. Бизнес-модель ### 5.1. Слои контента 1. **Краткий** — баннер и строка списка: `header`, `text`, цена, CTA. 2. **Детальный** — деталка (блоки `details` по фиксированной схеме §5.6.1, документы, кнопки) у видов с CTA `open_detail`. У остальных видов второго слоя нет: CTA сразу выполняет действие (оплата, сообщение в чат, авторизация, установка приложения). ### 5.2. Модель каталога: вид уведомления — данные, поведение — код **Добавление нового вида уведомления — строка в справочнике `notification_types`, без изменений кода.** Вид описывается набором обязательных атрибутов: контур, приоритет, `countable`, оформление (label, цвет, иконка, текст CTA), код CTA и набор кнопок деталки. Ни бэкенд, ни фронт не содержат ветвлений по коду вида. Каталог состоит из справочника видов и трёх реестров, на которые он ссылается (§10.2): | Справочник | Что задаёт | Расширяется | |---|---|---| | `notification_types` | Вид уведомления: оформление, ссылка на CTA, кнопки деталки, правила | **Данными** — seed-миграция или строка в справочнике | | `notification_cta_actions` | Перечень доступных механик CTA | **Только кодом**: новая механика — это реализация на бэкенде и фронте | | `notification_buttons` | Кнопки деталки и их влияние на жизненный цикл | **Только кодом** по той же причине | | `notification_color_tokens` | Палитра, допустимая для карточек (§5.2.1) | **Кодом**: новый токен появляется вместе со значениями в теме фронта | Три реестра — это перечни того, что уже реализовано во фронте и бэкенде. Вид уведомления собирается ссылками на них, поэтому попытка сослаться на нереализованную механику, кнопку или цвет отбивается ссылочной целостностью, а не обнаруживается на проде. Кнопки вида хранятся **двумя ссылками в самой строке вида** (`button_primary_code`, `button_secondary_code`), а не связующей таблицей. Причина в том, что кардинальность здесь не «много ко многим», а жёстко фиксированные два слота, заданные макетом деталки: основное действие и второстепенное. Связующая таблица описывала бы более свободную структуру, чем существует в реальности, и за эту свободу пришлось бы платить: - **Инварианты ушли бы из БД.** «Не больше двух кнопок» и «у вида с деталкой минимум одна кнопка» — это условия на количество строк в группе, которые в `CHECK` не выражаются: их пришлось бы проверять тестом или триггером. В виде двух колонок те же правила становятся обычными табличными `CHECK` (§10.2.3), то есть их нельзя нарушить даже ручным `INSERT`. - **Появилась бы возможность нарушить порядок.** `sort_order` допускает дубли и пропуски и требует отдельного unique-индекса, тогда как два именованных слота задают порядок самим фактом существования. - **Слот несёт визуальный вес.** «Primary» — акцентная кнопка, «secondary» — второстепенная. При `sort_order` фронту пришлось бы догадываться об оформлении по номеру, а справочнику кнопок — хранить ещё и признак стиля. - **Добавление вида осталось бы одним `INSERT`,** а не вставкой в две таблицы, которые надо держать согласованными. Именно эту операцию мы и делаем дешёвой. Расширение макета до трёх кнопок потребует аддитивной миграции (`button_tertiary_code`), но это всё равно релиз фронта — миграция здесь не узкое место. Граница между данными и кодом проходит по поведению и по оформлению: реестры содержат то, что уже реализовано. Новый вид уведомления, собранный из существующих механик, кнопок и токенов, добавляется данными. Вид, требующий новой механики (например «CTA открывает опрос»), требует нового кода CTA — и это видно по попытке сослаться на несуществующий код. Оформление вида — тоже данные, но в безопасных границах: - **Цвет** — не свободный hex, а `color_token` из палитры (§5.2.1). - **Иконка** — `icon_code` из набора, поставляемого с фронтом. Маппинг «код → SVG» живёт в коде фронта; бэкенд только выбирает код. Новая картинка — релиз фронта плюс обновление маппинга, после чего код доступен для выбора. Неизвестный или пустой `icon_code` → **иконка по умолчанию**, карточка не ломается. - **Тексты** (`label`, `cta_text`, подписи кнопок) — строки в справочниках. Продюсер их не переопределяет: у экземпляра нет полей для текста CTA и подписей кнопок. При запуске механизма мнемоник тексты переезжают в `text_resources` (§3.2). Каталог отдаётся фронту публичной ручкой с ETag (§9.2): он нужен и гостю, и авторизованному, PII в нём нет. **Вид уведомления никогда не удаляется физически** — только `record_status='D'`. Исторические записи ссылаются на код вида, и его исчезновение сделало бы их нерендерящимися. ### 5.2.1. Палитра карточек **В БД хранится только имя токена, сами цвета — в теме фронта.** Это разделение принципиально: одному токену соответствует не один цвет, а пара значений (светлая и тёмная тема) плюс производные — фон карточки, цвет текста на нём, цвет акцентной кнопки. Держать это в БД значило бы сделать бэкенд владельцем дизайн-решения и завести по несколько hex на токен, при этом контраст всё равно никто бы не проверил. Поэтому: | Слой | Где | Что хранит | |---|---|---| | Имя токена | App DB, `notification_color_tokens` (§10.2.4) | Перечень допустимых токенов и их семантика | | Значения токена | Тема фронта (design tokens / CSS-переменные), сверяется с Figma | Конкретные цвета для светлой и тёмной темы, производные оттенки | | Выбор токена для вида | App DB, `notification_types.color_token` | Какой токен применяется к карточкам этого вида | Токены **семантические, а не буквальные**: `critical`, а не `red`. Буквальное имя врёт при первой же смене темы или ревизии макета — «красный» в тёмной теме перестаёт быть тем же красным, а `critical` остаётся корректным. Seed палитры: | `color_token` | Семантика — когда применять | Виды в seed | |---|---|---| | `critical` | Требует немедленного внимания, есть риск потерь или срыва срока | `urgent` | | `warning` | Ждёт действия клиента, срок не критичен | `payment_pending`, `docs_required` | | `success` | Результат готов, действие клиента завершено успешно | `docs_ready` | | `info` | Информирование без требования действия | `status_changed`, `reminder`, `news` | | `promo` | Маркетинговое предложение | `ads_global`, `promo_global`, `ads_personal`, `promo_personal` | | `neutral` | Служебная подсказка интерфейса | `authorize`, `install_app` | Конкретные значения токенов и их привязка к видам подтверждаются по Figma при вёрстке: перечень выше задаёт семантику и границы, а не финальный оттенок. Палитра — реестр (§5.2). Добавление токена: дизайн определяет значения → фронт добавляет их в тему и выпускается → токен вносится строкой в `notification_color_tokens` → его можно выбирать для видов. Порядок именно такой: токен, у которого во фронте нет значений, — это карточка без цвета. **Как выбирать цвет для нового вида.** По семантике, а не по вкусу: берётся токен, чьё описание совпадает с назначением вида. Один токен допустимо использовать нескольким видам — `news`, `reminder` и `status_changed` в seed делят `info`. Токен не различает виды между собой, это делают label, иконка и текст; попытка дать каждому виду свой оттенок ради различимости быстро исчерпала бы палитру и разрушила бы её смысл. Палитра держится небольшой сознательно: каждый токен — это обязательство дизайна поддерживать согласованный набор значений в двух темах. `color_token` — `NOT NULL` и проверяется ссылочной целостностью, в отличие от `icon_code`, который nullable и допускает фолбэк. Разница не в строгости ради строгости: карточки без иконки — легитимный вариант оформления, а карточки без цвета не существует. Фолбэк на клиенте для цвета всё же нужен, но как страховка от рассинхрона версий (кэшированный SPA старше бэкенда), а не как штатный путь (§9.2). ### 5.3. Справочник механик CTA Закрытый перечень реализованных механик. Расширяется только кодом. | `cta_action` | Что делает | Обязательные поля экземпляра | Требует авторизации | |---|---|---|---| | `open_detail` | Открывает деталку `/notification/{uuid}` | `details` | да | | `open_payment_url` | Переход по ссылке оплаты | `payment_url` | да | | `send_chat_message` | Отправляет `chat_message_text` в чат от лица клиента | `chat_message_text` | да | | `start_auth` | Запускает сценарий авторизации | — | нет | | `install_app_prompt` | Пытается установить PWA, при отказе среды открывает `instruction_url` | `instruction_url` | нет | Правила: - **Колонка «обязательные поля» — источник валидации Create** (§7.3). Правила выводятся из `cta_action`, а не из кода вида, поэтому новый вид уведомления валидируется без правки кода. - **В контуре G механика, требующая авторизации, автоматически предваряется входом.** Поэтому `ads_global` и `ads_personal` используют один и тот же `send_chat_message`: разницу («сначала логин, потом сообщение») даёт контур, а не отдельная механика. - Признак «есть деталка» отдельной колонкой **не хранится**: он тождествен `cta_action='open_detail'`. Отдельный флаг создавал бы возможность рассинхрона. ### 5.4. Справочник кнопок деталки Кнопки — единственный механизм управления `visibility` и завершением у видов с деталкой. Сам CTA у таких видов `visibility` не меняет (§7.1). Расширяется только кодом. | `code` | label | `sets_hidden` | TTL | `close_reason` | Отправляет документы | |---|---|---|---|---|---| | `done` | Готово | — | — | `user_done` | нет | | `later` | Сделаю позже | нет | нет | — | нет | | `gotit` | Понятно | да | да | — | нет | | `send_docs` | Отправить документы | — | — | `docs_submitted` | да | Правила: - Кнопка с непустым `close_reason` закрывает уведомление; `visibility` при этом не важен, так как закрытая запись не отображается нигде. - `later` не меняет ничего, кроме закрытия экрана деталки: `is_read` уже выставлен по CTA. - TTL берётся из `notification_types.hidden_ttl_days`, при `NULL` — из `notification.hidden.default_ttl_days`, и применяется только если `date_expired` отсутствует. Заданный продюсером `date_expired` не перезаписывается. - `send_docs` переносит черновики клиента в реестр (§6.5.2) и закрывает уведомление. Если когда-нибудь понадобится отправка документов без закрытия — это новая кнопка в справочнике, а не флаг у вида. Кнопки привязываются к виду **двумя слотами** в строке `notification_types` (§5.2): | Слот | Смысл | Оформление | |---|---|---| | `button_primary_code` | Основное действие: «Готово», «Отправить документы», «Понятно» | Акцентная кнопка | | `button_secondary_code` | Второстепенное действие: «Сделаю позже» | Второстепенная кнопка | - **У вида с деталкой основной слот обязателен**, иначе карточку невозможно убрать с главной и она проживёт до `date_expired`. - Второй слот без первого не заполняется, и одна кнопка не может занимать оба слота. - У вида без деталки оба слота пусты: показать кнопки негде. Все четыре правила — табличные `CHECK` (§10.2.3), а не соглашение и не тест. ### 5.5. Каталог видов уведомлений (seed) | type | label | Контур | priority | countable | `cta_action` | `button_primary_code` | `button_secondary_code` | |---|---|---|---|---|---|---|---| | `authorize` | Гостевой режим | G | 1 | нет | `start_auth` | — | — | | `install_app` | Приложение | G | 2 | нет | `install_app_prompt` | — | — | | `promo_global` | Акция | G | 3 | нет | `send_chat_message` | — | — | | `ads_global` | Предложение | G | 4 | нет | `send_chat_message` | — | — | | `urgent` | Срочно | P | 1 | да | `open_detail` | `done` | `later` | | `payment_pending` | Оплата | P | 2 | да | `open_payment_url` | — | — | | `docs_required` | Требуются документы | P | 2 | да | `open_detail` | `send_docs` | `later` | | `docs_ready` | Документы готовы | P | 3 | да | `open_detail` | `gotit` | — | | `status_changed` | Статус | P | 3 | да | `open_detail` | `gotit` | — | | `reminder` | Напоминание | P | 3 | да | `open_detail` | `done` | `later` | | `news` | Новость | P | 4 | да | `open_detail` | `gotit` | — | | `promo_personal` | Акция | P | 5 | да | `send_chat_message` | — | — | | `ads_personal` | Предложение | P | 5 | да | `send_chat_message` | — | — | Эффекты CTA у видов без деталки (колонки `cta_sets_hidden` / `cta_close_reason`): | type | `cta_sets_hidden` | `cta_close_reason` | |---|---|---| | `ads_personal`, `promo_personal` | да | `offer_accepted` | | `payment_pending` | **нет** — карточка обязана остаться на главной до подтверждения оплаты | — | | Гостевые виды | не применяется: в G нет per-user состояния | — | Оформление (задаётся в тех же строках справочника): `color_token` из палитры (§5.2.1), `icon_code` из набора фронта, `cta_text`. Тексты CTA в seed: «Войти →», «Установить →», «Подробнее →» для видов с деталкой, «Узнать подробнее →» для рекламных, «Оплатить →», «Загрузить документы →» для `docs_required`, «Скачать →» для `docs_ready`. `priority`: меньше = важнее; значения меняются без релиза кода. `countable` в контуре G всегда `false`: read-state в G не существует. `ads_*` / `promo_*` в G и P — визуально одинаковые карточки, но **разные виды**: разный контур, разный жизненный цикл, разные правила валидации. Гостевые виды через Internal Create не создаются (§7.3). Виды `install_Android` / `install_IOS-HarmonyOS` / `message` — **отсутствуют**. **Инвариант:** жёсткой привязки «сервис X → вид Y» нет. ### 5.6. Поля экземпляра (контур P) Обязательные: | Поле | Тип | Описание | |---|---|---| | `id` | uuid v7 | Генерирует `api-backend` при Create | | `user_id` | uuid | `user_identities.id`, NOT NULL | | `notification_type` | varchar | Код вида контура P | | `source` | varchar | Код продюсера из справочника (§7.4) | | `external_id` | varchar | Ключ бизнес-события у продюсера (§7.4) | | `notification_datetime` | timestamptz | UTC; поле сортировки | | `header` | varchar | Заголовок | Опциональные: | Поле | Тип | Описание | |---|---|---| | `text` | varchar | Подзаголовок | | `priority_override` | smallint | Приоритет экземпляра; при `NULL` действует `notification_types.priority` | | `date_expired` | timestamptz | Наступление → `lifecycle_status=closed`, `close_reason=expired` | | `price` | numeric(12,2) | Текущая цена, рубли. Валюта захардкожена | | `old_price` | numeric(12,2) | Старая цена (зачёркнутая). Допустим **только вместе с `price`**; `price` без `old_price` допустим | | `payment_url` | text | Обязателен при `cta_action='open_payment_url'`, запрещён иначе | | `chat_message_text` | varchar | Текст в чат от лица клиента. Обязателен при `cta_action='send_chat_message'`, запрещён иначе | | `details` | jsonb | Блоки деталки по схеме §5.6.1. Обязателен при `cta_action='open_detail'`, запрещён иначе | Полей `send_documents`, `button_done`, `button_gotit`, `documents[]` на верхнем уровне **нет**: возможность приложить документы и список документов компании — блоки внутри `details` (§5.6.1), набор кнопок — атрибут вида (§5.4). Поля `instruction_url` у персональных уведомлений нет: единственная внешняя страница — инструкция по установке, а это вид контура G (§6.4). #### 5.6.1. Схема `details` — закрытый перечень блоков Схема **фиксированная и версионируемая**. Продюсер выбирает, какие блоки заполнить, но не может прислать блок, которого нет в схеме: иначе деталка обросла бы кодом «если вид такой — рисуем так», и каталог перестал бы быть данными. Незаполненные блоки не отображаются. | Блок | Тип | Кто заполняет | Описание | |---|---|---|---| | `deadline` | timestamptz, nullable | продюсер | Срок. При наличии выводится маркером срока на карточке и на деталке | | `details_header` | string, nullable | продюсер | Заголовок деталки | | `details_text` | string, nullable | продюсер | Основной текст | | `todo_header` | string, nullable | продюсер | Заголовок плана действий | | `todo_plan[]` | array, nullable | продюсер | План действий; при наличии — минимум один элемент: `number` (integer, обязателен), `text` (string, обязателен) | | `send_documents` | boolean, default `false` | продюсер | Показывать ли блок отправки документов клиентом | | `pending_documents[]` | array, **read-only** | **бэкенд** | Черновики, приложенные клиентом и ещё не отправленные: `draft_id`, `title`, `mime_type`, `size_bytes`, `scan_status`. Формируется из `client_upload_drafts` при выдаче деталки | | `documents[]` | array, nullable | продюсер (при Create), бэкенд (при чтении) | Документы компании с предпросмотром, как в сообщениях чата | Два блока ведут себя не как остальные, и это принципиально: **`pending_documents` продюсер прислать не может.** Это состояние клиента, живущее в `client_upload_drafts` (§10.6): продюсер о нём ничего не знает и знать не должен, а попытка его передать в Create отклоняется как `validation_error`. Блок наполняет бэкенд при каждой выдаче деталки, поэтому клиент всегда видит актуальный набор черновиков с любого устройства. **`documents` имеет разное представление на запись и на чтение.** Постоянной ссылки на файл у нас нет и быть не должно: скачивание идёт по короткому presigned GET, который выдаётся отдельным вызовом с audit-событием (§6.5.1). Поэтому: - **при Create** продюсер передаёт `object_key`, `title`, `mime_type`, `size_bytes`, `checksum_sha256` — метаданные объекта, уже размещённого в `han-chat-documents`; - **при чтении** клиент получает `document_id`, `title`, `mime_type`, `size_bytes` — этого достаточно для предпросмотра, а ссылка запрашивается по `document_id` в момент скачивания. Готовая ссылка в `details` жила бы дольше своего TTL, попадала бы в кэш и логи и обходила бы аудит — поэтому поля `link` в схеме нет ни в одном из представлений. ### 5.7. Состояние экземпляра (контур P) | Атрибут | Значения | Семантика | |---|---|---| | `record_status` | `A` / `D` | Soft-delete строки по arch-05. Используется **только** для административного удаления ошибочно созданных записей. Бизнес-завершение через него **не** выражается: нормально закрытое уведомление остаётся `record_status='A'` | | `lifecycle_status` | `active` / `closed` | `closed` — уведомление завершено, в UI не показывается никогда | | `visibility` | `visible` / `hidden` | `hidden` — нет на главной, есть в Центре (пока `lifecycle_status=active`) | | `is_read` | boolean | Для countable; бейдж = `active` ∧ `countable` ∧ ¬`is_read` в пределах окна Центра (§6.2) | | `close_reason` | см. ниже | Заполняется при переходе в `closed` | | `close_reason` | Когда | |---|---| | `user_done` | Клиент нажал кнопку с `close_reason='user_done'` («Готово») | | `docs_submitted` | Клиент нажал «Отправить документы» | | `offer_accepted` | Клиент принял предложение (`cta_close_reason` вида) | | `paid` | Оплата подтверждена внешним сервисом (Cancel) | | `expired` | Наступил `date_expired` | | `cancelled` | Отзыв продюсером (Cancel) | **`is_read` ≠ `visibility`:** прочтение влияет на бейдж, видимость — на главную. Правила — §7.1. Контур G: per-user `lifecycle_status` / `visibility` / `is_read` **отсутствуют**. У гостевой записи есть общий для всех гостей `lifecycle_status` (`active` / `closed`) и `record_status`. ### 5.8. Поля гостевого уведомления (контур G) Гостевая запись описывает кампанию, общую для всех гостей. | Поле | Обяз. | Тип | Описание | |---|---|---|---| | `id` | да | uuid v7 | Генерирует seed-скрипт / миграция | | `notification_type` | да | varchar | Код вида контура G | | `notification_datetime` | да | timestamptz | Поле сортировки | | `header` | да | varchar | Заголовок | | `text` | нет | varchar | Подзаголовок | | `priority_override` | нет | smallint | Приоритет записи; при `NULL` — из справочника | | `date_expired` | нет | timestamptz | Наступление → `lifecycle_status=closed` тем же джобом, что и в P (§11.1) | | `price` | нет | numeric(12,2) | Цена, рубли | | `old_price` | нет | numeric(12,2) | Только вместе с `price` | | `instruction_url` | условно | text | Адрес страницы с инструкцией по установке. **Обязателен** при `cta_action='install_app_prompt'`, запрещён иначе (§6.4) | | `chat_message_text` | условно | varchar | Текст в чат после успешной авторизации. **Обязателен** при `cta_action='send_chat_message'` | | `lifecycle_status` | да | varchar | `active` / `closed` | | `closed_at` | нет | timestamptz | Момент закрытия кампании | | common fields | да | — | `record_status`, `status_changed_at`, `status_change_reason`, `created_at`, `updated_at`, `updater_user_id` | У гостевых записей **нет**: `user_id`, `is_read`, `visibility`, `close_reason`, `details`, `payment_url`, `source`/`external_id`, кнопок. `close_reason` не нужен: кампания закрывается только по сроку или вручную, и причина видна из `closed_at` и `status_change_reason`. Кнопок нет, потому что в контуре G нечего сохранять: результат нажатия негде зафиксировать. Поэтому у всех гостевых видов оба слота кнопок пусты, а `cta_action` не может быть `open_detail` — деталка без кнопок была бы экраном без выхода. Крестика в UI нет: гостевой режим — демонстрационный, длительного использования не предполагает. ### 5.9. `install_app` — адаптивный CTA 1. Пользователь нажал CTA. 2. Если среда поддерживает установку PWA (`beforeinstallprompt` или эквивалент) — запускается установка. 3. Иначе — открывается экран инструкции по адресу из `instruction_url` (§6.4). Отдельный вид уведомления для этого не нужен. Запись с `cta_action='install_app_prompt'` без `instruction_url` считается невалидной: шаг 3 стал бы тупиком. ### 5.10. Константы и тексты Правила вложений переиспользуются из чата (`chat.attachments.*`, arch-04): `allowed_extensions`, `allowed_mime_types`, `disallowed_extensions`, `max_size_mb`, `presigned_upload_ttl_seconds`. Отдельных лимитов у уведомлений нет. Телефон оператора: `operator.call.phone`. Seed `notification.*` в `app_settings`: | Ключ | Смысл | Default | `is_public` | |---|---|---|---| | `notification.home.max_items` | Лимит карусели на главной | `7` | нет | | `notification.center.max_items` | Лимит Центра и окна подсчёта бейджа | `15` | нет | | `notification.carousel.autoplay_enabled` | Автопрокрутка карусели | `false` | **да** | | `notification.carousel.autoplay_interval_ms` | Интервал автопрокрутки | `5000` | **да** | | `notification.hidden.default_ttl_days` | Скрытие без `date_expired` → now + N дней, если у вида не задан `hidden_ttl_days` | `3` | нет | | `notification.documents.max_files` | Максимум файлов в одной отправке клиента | `10` | нет | | `notification.expire_job.run_at` | Время ежедневного джоба закрытия (UTC, `HH:MM`) | `00:01` | нет | | `notification.upload_draft.ttl_days` | TTL неотправленных черновиков документов | `7` | нет | Лимиты применяются на сервере, поэтому `max_items` клиенту не публикуются. Настройки allow-list/режима показа инструкции отсутствуют: `instruction_url` всегда открывается в новой вкладке (§6.4). **Ключей с кодом вида в имени в `app_settings` быть не должно.** Настройка, специфичная для вида, — это колонка справочника (`hidden_ttl_days`), иначе добавление вида требовало бы новых ключей настроек, то есть перестало бы быть операцией над данными. Тексты пустых состояний, гостевого Центра и недоступного уведомления в MVP — константы фронта. Планируемые мнемоники для будущего переноса в `text_resources`: `notification.center.empty.title` / `.text`, `notification.guest_center.title` / `.text` / `.cta`, `notification.detail.not_found.title` / `.text`. --- ## 6. Поведение UI ### 6.1. Главная (карусель) **ЛК (P):** - Выборка: `record_status='A'`, `lifecycle_status='active'`, `visibility='visible'`, `(date_expired IS NULL OR date_expired > now())`. - Лимит `notification.home.max_items` (7) — применяет сервер. - Сортировка: эффективный приоритет ↑, затем `notification_datetime` ↓, затем `id` ↓ (стабильность). - Крестик → `visibility='hidden'`, синхронно на всех устройствах. `is_read` крестик **не** меняет. - Свайп; автопрокрутка по настройкам. - CTA → механика `cta_action` вида + эффекты §7.1. - Оформление карточки (цвет, иконка, label, текст CTA) берётся из каталога видов (§9.2); ветвлений по коду вида на фронте нет. Наличие `details.deadline` добавляет маркер срока. **Гость (G):** - Выборка: `record_status='A'`, `lifecycle_status='active'`, `(date_expired IS NULL OR date_expired > now())`. - Лимит `notification.home.max_items`, та же сортировка — применяет сервер. - Крестика нет. ### 6.2. Центр уведомлений **Гость:** колокольчик без бейджа; экран auth-gate с кнопкой «Авторизоваться». Списка нет. **ЛК:** - Выборка: `record_status='A'`, `lifecycle_status='active'`, `(date_expired IS NULL OR date_expired > now())` — **включая** `visibility='hidden'`. - Лимит `notification.center.max_items` (15), архива закрытых нет. - Сортировка: эффективный приоритет ↑ → непрочитанные выше → `notification_datetime` ↓ → `id` ↓. - Точка на непрочитанных countable. - **Бейдж считается по тому же окну, что и список:** число непрочитанных countable среди первых `notification.center.max_items` записей выборки. Записи, не попавшие в окно, в бейдже не учитываются — бейдж и список всегда сходятся. - Значение бейджа отдаёт бэкенд (§9.1) и обновляет по WS; локально фронт его не пересчитывает. - **Пагинации нет — это осознанное решение.** Если активных уведомлений больше лимита, клиент видит только самые приоритетные, остальные недоступны до тех пор, пока верхние не будут закрыты или скрыты. Лимит здесь — техническая страховка: избыточное число одновременно активных уведомлений считается проблемой бизнес-логики продюсеров и решается на их стороне, а не прокруткой длинного списка. Метрика доли пользователей, упирающихся в лимит, — в §12. ### 6.3. Деталка - SPA-роут `/notification/{uuid}`; только контур P; только виды с `cta_action='open_detail'`. - Чужое / закрытое / удалённое → **404** (не 403), нейтральный текст «Уведомление недоступно». - Деталка рендерится как последовательность блоков `details` в фиксированном порядке схемы (§5.6.1). Незаполненный блок не отображается, порядок блоков продюсер не задаёт: иначе появились бы верстки, зависящие от вида. - Порядок: срок (`deadline`) → `details_header` → `details_text` → `todo_header` + `todo_plan[]` → `documents[]` (предпросмотр, как в сообщениях чата) → блок отправки документов при `send_documents=true` вместе с `pending_documents[]` → кнопки. - **Кнопки берутся из слотов вида** `button_primary_code` / `button_secondary_code`, подписи — из `notification_buttons.label`. Основной слот рисуется акцентной кнопкой, второстепенный — второстепенной. Фронт не знает, какие кнопки бывают у какого вида. - Эффект нажатия определяется атрибутами кнопки (§5.4), а не её кодом на фронте: фронт вызывает единый эндпоинт нажатия (§9.1) и применяет вернувшееся состояние. - Пока деталка открыта, запись может закрыться извне (expire, Cancel). Поведение — §6.6. ### 6.4. Экран инструкции по установке Единственный сценарий с внешней страницей. Применяется только к механике `install_app_prompt` (контур G) и только на шаге 3 адаптивного CTA (§5.9). `instruction_url` **всегда** открывается в новой вкладке браузера (`target=_blank` с защитой `noopener,noreferrer` или эквивалент платформы). Модалка, webview и iframe запрещены независимо от хоста. ### 6.4.1. CTA рекламных видов `ads_*` / `promo_*` (механика `send_chat_message`) промежуточных экранов не имеют: - **Контур P.** CTA сразу отправляет `chat_message_text` в чат от лица клиента и закрывает уведомление (§7.2). - **Контур G.** CTA запускает сценарий авторизации; после успешного входа `chat_message_text` уходит в чат от лица клиента. Кампания остаётся `active` для остальных гостей. Отправка `chat_message_text` после авторизации использует общий механизм отложенного сообщения (тот же, что у «Популярных вопросов»). Если у клиента нет активного диалога — он создаётся штатным путём. ### 6.5. Документы #### 6.5.1. Документы компании (получение) - Хранение: бакет **`han-chat-documents`**, ключ `documents/users/{user_uuid}/{document_uuid}`. - Каждый документ регистрируется строкой в таблице `documents` и связывается с уведомлением через `notification_documents` (§10.5). Это делает будущий раздел профиля «Документы» сборником по всем каналам без миграции файлов. - Продюсер передаёт документы в блоке `details.documents[]` метаданными (`object_key`, `title`, `mime_type`, `size_bytes`, `checksum_sha256`); `api-backend` при Create регистрирует их в `documents` / `notification_documents` и **заменяет блок на представление для чтения** с `document_id` (§5.6.1). - Скачивание — короткий presigned GET через `.../download-url` по `document_id`, с обязательным audit-событием (§12); URL в логи и audit не пишется. Постоянных ссылок на файлы не существует. - У вида с `hide_on_document_download=true` **первый успешный запрос download-url для любого связанного документа** считается началом скачивания и атомарно скрывает уведомление. Неважно, какой документ из списка скачан первым; последующие запросы по этому или другому документу состояние не меняют. Presigned GET не позволяет надёжно наблюдать получение последнего байта без проксирования файла, поэтому граница «скачивание» в API — успешная выдача URL после owner-check и audit. Эффекты — §7.1. #### 6.5.2. Документы клиента (отправка) — общий механизм Механизм проектируется как **переиспользуемый**: контекст задаётся парой `context_type` / `context_id`, уведомление — первый потребитель, будущие фичи подключаются без изменения таблиц и S3-схемы. 1. Клиент прикладывает файл на деталке уведомления с `details.send_documents=true`. 2. `init` → presigned PUT в **`han-chat-quarantine`**, ключ `quarantine/users/{user_uuid}/uploads/{draft_uuid}`. Создаётся строка-черновик в `client_upload_drafts` (§10.6) со статусом `pending`. 3. `complete` → HeadObject, сверка размера/MIME/checksum, запуск проверки Message Safety (правила и allow-list — как у вложений чата). 4. Вердикт `allow` → объект переносится в **`han-chat-attachments`**, ключ `attachments/users/{user_uuid}/{context_type}/{context_id}/{draft_uuid}`; черновик получает статус `clean`. Вердикт `deny` → объект удаляется из карантина, черновик получает статус `infected` и в UI помечается ошибкой. 5. **Черновики переживают выход из карточки.** Вернувшись, клиент видит блок `pending_documents[]` со всеми черновиками статуса `clean` и может удалить любой: черновик переходит в состояние `discarded`, объект удаляется из S3. Блок наполняет бэкенд при выдаче деталки, поэтому набор одинаков на всех устройствах. 6. Кнопка **«Отправить документы»** (`send_docs`, §5.4) активна при наличии хотя бы одного черновика `clean`. По нажатию все такие черновики одной транзакцией переносятся в `client_documents` (§10.7) с общим `submission_id`, черновики помечаются `submitted`, уведомление закрывается с `close_reason='docs_submitted'`. 7. Вставка в `client_documents` активирует **триггер БД** → задача в `sync_queue` (§10.8). На `client_upload_drafts` триггера синхронизации **нет**. 8. Закрытие — свойство кнопки, а не вида. Вид, у которого `details.send_documents=true`, но в наборе кнопок нет `send_docs`, отправить документы не позволит: кнопка — единственный способ инициировать отправку. Это проверяется валидацией Create (§7.3). Ограничения: не больше `notification.documents.max_files` файлов в одной отправке; форматы и размер — по `chat.attachments.*`. Черновики старше `notification.upload_draft.ttl_days` удаляются вместе с объектами S3 (§11.2). ### 6.6. Завершение и гонки Переход в `closed` возможен только четырьмя путями, и все они декларативны: | Путь | Механизм | `close_reason` | |---|---|---| | Нажата кнопка с непустым `close_reason` | `notification_buttons` (§5.4) | `user_done` / `docs_submitted` | | CTA вида без деталки с заданным `cta_close_reason` | `notification_types` (§5.5) | `offer_accepted` | | Cancel от продюсера | Internal API (§7.2) | `paid` / `cancelled` | | Наступил `date_expired` | ежедневный джоб (§11.1) | `expired` | Отсюда следуют наблюдаемые сценарии: `payment_pending` закрывается только Cancel от платёжного сервиса с `paid`; `docs_required` — кнопкой `send_docs`; `ads_*` / `promo_*` — своим CTA; `docs_ready` уходит с главной по `hide_on_document_download` и закрывается по сроку; вид с кнопкой `gotit` без `done` пользователем не закрывается вовсе — только по сроку или Cancel. **Гонки на открытой карточке.** Если запись закрылась (expire, Cancel) или изменилась с другого устройства, пока пользователь держит деталку открытой: - клиент получает WS-событие `notification.closed` / `notification.updated` и показывает нейтральную плашку «Уведомление больше не актуально»; кнопки действий блокируются; - любое действие по уже закрытой записи → `409 notification_closed`, состояние не меняется; - повтор того же действия по активной записи идемпотентен и возвращает `200` с текущим состоянием (повторный `read`, `hide`, `gotit` ошибкой не являются); - при отсутствии WS расхождение устраняется при следующем `GET` — фронт обязан перечитать запись перед показом результата действия. --- ## 7. Матрицы поведения (контур P) ### 7.1. `is_read` и `visibility` **Правило прочтения:** > Любое **CTA** → `is_read = true` (идемпотентно). CTA = первичное действие карточки: кнопка CTA на баннере, тап по строке Центра, ведущий к механике вида, переход в деталку по CTA, отправка сообщения в чат. Не CTA: системный back, крестик и кнопки деталки — крестик `is_read` не ставит. **Матрица не перечисляет виды: эффекты выводятся из атрибутов справочников.** Это и есть условие расширяемости данными — иначе каждый новый вид требовал бы новой строки в спецификации и в коде. | Событие | Источник эффекта | `is_read` | `visibility` | `lifecycle_status` | |---|---|---|---|---| | Крестик на главной | фиксированное поведение | — | `hidden` | — | | CTA любого вида | фиксированное поведение | `true` | см. ниже | см. ниже | | CTA вида **с деталкой** | — | `true` | **не меняется** | не меняется | | CTA вида **без деталки** | `notification_types.cta_sets_hidden` / `cta_close_reason` | `true` | `hidden`, если `cta_sets_hidden` | `closed` + `cta_close_reason`, если задан | | Нажата кнопка деталки | `notification_buttons` (§5.4) | — (уже `true`) | `hidden` + TTL, если `sets_hidden` | `closed` + `close_reason` кнопки, если задан | | Впервые успешно выдан download-url по любому связанному документу | `notification_types.hide_on_document_download` | `true` | `hidden` + TTL | — | Пояснения: - «—» = поле этим событием не меняется. - **У вида с деталкой CTA не меняет `visibility`.** Открытие деталки — это чтение, а не решение по задаче: решение принимает пользователь кнопкой. Поэтому `cta_sets_hidden=true` и непустой `cta_close_reason` у вида с `cta_action='open_detail'` запрещены CHECK-ограничением (§10.2). Практическое следствие: `news` и `status_changed` уходят с главной только по кнопке «Понятно», а не по факту открытия. - **`payment_pending` по CTA `visibility` не меняет** (`cta_sets_hidden=false`). Клиент может уйти на платёжную страницу и не заплатить; карточка обязана остаться на главной. Она уходит только по подтверждению оплаты (Cancel с `close_reason='paid'`) или по `date_expired`. - Открытие деталки из Центра по тапу строки = CTA → всегда `is_read=true`. - TTL при скрытии = `notification_types.hidden_ttl_days`, иначе `notification.hidden.default_ttl_days`, и применяется **только при `date_expired IS NULL`**. Если `date_expired` уже задан продюсером, скрытие меняет `visibility`, но дату не пересчитывает и не перезаписывает. - Синхронизация между устройствами обеспечивается тем, что все переходы выполняет бэкенд и рассылает WS-события (§9.4). ### 7.2. Механика `send_chat_message` (виды `ads_*` / `promo_*`) 1. CTA сразу отправляет `chat_message_text` в чат от лица клиента; промежуточных экранов нет. 2. Одновременно: `is_read=true`, `visibility='hidden'`, `lifecycle_status='closed'`, `close_reason='offer_accepted'` — по `cta_sets_hidden=true` и `cta_close_reason='offer_accepted'` в справочнике. В UI запись исчезает из-за `closed`; `hidden` фиксирует намерение убрать её с главной на случай гонок и для аудита. 3. Ни деталки, ни внешней страницы у этих видов нет: поля `details` и `instruction_url` запрещены. 4. Отказ от предложения не моделируется: клиент, которому предложение не интересно, убирает карточку крестиком, и она уходит по `date_expired`. ### 7.3. Валидация Internal Create Правила выводятся из справочников, а не из перечня видов. Хардкод кодов видов в валидаторе запрещён — это ломало бы расширение данными. Общие для всех: | Обязательно | Запрещено | |---|---| | `user_id`, `notification_type` (контура P), `source`, `external_id`, `notification_datetime`, `header` | неизвестный вид; вид контура G; неизвестный `user_id`; `old_price` без `price`; `instruction_url` (поле контура G); `pending_documents` внутри `details` (read-only, §5.6.1) | Выводимые из `cta_action` (колонка «обязательные поля» §5.3): поле, указанное для механики вида, — обязательно; поле, указанное для любой другой механики, — запрещено. Отсюда автоматически: `details` только при `open_detail`, `payment_url` только при `open_payment_url`, `chat_message_text` только при `send_chat_message`. Выводимые из атрибутов вида: | Проверка | Условие | |---|---| | Заполнены все блоки из `notification_types.required_detail_blocks` | Например `['documents']` у `docs_ready` | | `details.send_documents=true` допустим только у вида, среди кнопок которого есть `send_docs` | Иначе клиент увидел бы блок загрузки без возможности отправить (§6.5.2) | | Непустые `details.documents[]` допустимы только при `documents_allowed=true` | Отсекает документы у видов, для которых они не предусмотрены | | Блоки `details` — только из схемы §5.6.1 | Неизвестный ключ → ошибка, не молчаливое игнорирование | | `todo_plan[]`, если передан, содержит ≥1 элемент с непустыми `number` и `text` | То же для `documents[]` | Ошибка валидации → `400 validation_error` с перечнем полей в `details`. Гостевые записи через Internal Create не создаются: наполнение контура G в v1 — insert миграцией или seed-скриптом. ### 7.4. Контракт продюсера **Единый механизм дедупликации — бизнес-ключ `source` + `external_id`.** Заголовок `Idempotency-Key` для internal Create/Cancel **не используется**: два конкурирующих механизма не нужны, а бизнес-ключ, в отличие от 24-часового `Idempotency-Key`, действует бессрочно. | Операция | Семантика | |---|---| | **Create** | Создаёт уведомление. Пара `(source, external_id)` уникальна **навсегда**, независимо от `lifecycle_status` и `record_status`. Повтор Create с тем же ключом: если тело запроса совпадает с исходным (по fingerprint) → `200 OK` с уже созданным уведомлением, новая запись не создаётся; если тело отличается → `409 notification_conflict`, в `details.notification_id` возвращается id существующей записи | | **Cancel** | Адресуется парой `(source, external_id)`. Переводит запись в `lifecycle_status='closed'` с указанным `close_reason` (`cancelled` \| `paid`). Идемпотентна: повтор по уже закрытой записи → `200 OK` с текущим состоянием. Неизвестный ключ → `404 not_found` | | **Смена контента** | Upsert и update контента отсутствуют. Сценарий: **Cancel** старого → **Create** нового **с новым `external_id`**. Переиспользование ключа запрещено на уровне уникального индекса | Fingerprint запроса — канонизированный хэш значимых полей Create (без служебных заголовков); хранится в строке уведомления. `source` — код из справочника `notification_sources` (§10.9). Неизвестный `source` → `400 validation_error`. Кто закрывает: | Сценарий | Кто | `close_reason` | |---|---|---| | Оплата подтверждена | Внешний сервис → Cancel | `paid` | | Нажата кнопка «Отправить документы» | `api-backend` | `docs_submitted` | | Принято предложение (CTA `send_chat_message`) | `api-backend` по действию клиента | `offer_accepted` | | Нажата кнопка «Готово» | `api-backend` по действию клиента | `user_done` | | Отзыв продюсером | Внешний сервис → Cancel | `cancelled` | | Истёк `date_expired` | Ежедневный джоб (§11.1) | `expired` | --- ## 8. Идентификация - Идентификаторы записей обоих контуров — **UUID v7**, генерируются приложением или seed-скриптом, не клиентом. - SPA: `/notification/{uuid}` — только контур P и только виды с `cta_action='open_detail'`. - Deep link — вместе с push, позже. - Дедупликация Create — §7.4. --- ## 9. API Общие конвенции — arch-02: пути `/api/v1/*` (JWT), `/api/v1/public/*` (без JWT), `/internal/{mnemonic}/v1/*` (service token, не публикуется через nginx); JSON snake_case; даты RFC 3339 UTC; курсоры opaque. ### 9.1. Клиентские (JWT) — контур P | Метод и путь | Назначение | |---|---| | `GET /api/v1/notifications?place=home\|center` | Список; сервер применяет выборку, сортировку и лимит по §6.1/§6.2 | | `GET /api/v1/notifications/counter` | `{ "unread_count": N }` по правилу §6.2 | | `GET /api/v1/notifications/{id}` | Деталка | | `POST /api/v1/notifications/{id}/read` | Пометить прочитанным (идемпотентно) | | `POST /api/v1/notifications/{id}/hide` | Крестик → `visibility='hidden'` | | `POST /api/v1/notifications/{id}/buttons/{button_code}` | **Единый эндпоинт нажатия кнопки деталки.** Эффект определяется атрибутами кнопки (§5.4). Кнопка, не привязанная к виду уведомления → `422 button_not_allowed` | | `POST /api/v1/notifications/{id}/cta` | Выполнение CTA: применяет эффекты §7.1 и возвращает результат механики (например ссылку оплаты или факт отправки сообщения в чат) | | `GET /api/v1/notifications/{id}/documents/{document_id}/download-url` | Presigned GET + audit | Отдельных эндпоинтов `/gotit`, `/done`, `/documents/submit`, `/offer/accept` **нет**: каждая новая кнопка требовала бы нового эндпоинта, и добавление вида перестало бы быть операцией над данными. Отправку документов выполняет нажатие кнопки `send_docs` через общий эндпоинт. Все эндпоинты действий возвращают актуальное состояние записи (`lifecycle_status`, `visibility`, `is_read`, `unread_count`), чтобы фронт не восстанавливал его логикой по коду вида. Общий (переиспользуемый) контракт загрузки файлов клиентом: | Метод и путь | Назначение | |---|---| | `POST /api/v1/uploads/init` | `{context_type, context_id, file_name, mime_type, size_bytes}` → `{draft_id, upload_url, upload_headers, expires_at}` | | `POST /api/v1/uploads/{draft_id}/complete` | Подтверждение загрузки, запуск проверки | | `GET /api/v1/uploads?context_type=&context_id=` | Список черновиков контекста | | `DELETE /api/v1/uploads/{draft_id}` | Отозвать черновик: состояние `discarded` + удаление объекта S3 | Владелец определяется `user_id` из JWT. Разграничение кодов: **`GET`** по чужому, несуществующему, закрытому или удалённому уведомлению → `404` (существование записи не раскрывается); **действие** (`POST`) по собственному, но уже закрытому уведомлению → `409 notification_closed` (§6.6). ### 9.2. Public — контур G | Метод и путь | Назначение | |---|---| | `GET /api/v1/public/notifications` | Активные гостевые уведомления с серверными сортировкой и лимитом | | `GET /api/v1/public/notification-types` | **Каталог видов** для рендеринга: `code`, `label`, `color_token`, `icon_code`, `cta_text`, `cta_action`, `countable`, `contour`, кнопки деталки как `button_primary` / `button_secondary` (`code`, `label`) | Оба без JWT. Кэширование и rate limit — как у прочих public-эндпоинтов (arch-04). Каталог видов публичный, а не под JWT: он нужен и гостю, персональных данных не содержит и меняется редко — поэтому отдаётся с `ETag` и кэшируется на клиенте. Фронт запрашивает его при старте и обязан рендерить карточку по атрибутам из ответа. В каталоге отдаются **имена** токена цвета и иконки, а не значения: цвета берутся из темы фронта (§5.2.1), SVG — из его набора. Неизвестный `icon_code` или `color_token` (кэшированный SPA старше бэкенда) → иконка и цвет по умолчанию (`neutral`), а не пустая карточка. Внутренних полей (`hidden_ttl_days`, `cta_sets_hidden`, `cta_close_reason`, `required_detail_blocks`) в публичном каталоге нет: это правила сервера, клиенту они не нужны и создавали бы иллюзию, что переходы можно выполнять локально. ### 9.3. Internal - Мнемоника сервиса: **`notifications`**; путь `/internal/notifications/v1/...`. Мнемонику необходимо добавить в реестр arch-00. - Аутентификация: `Authorization: Bearer ` — тот же стиль, что у остальных эндпоинтов, которыми владеет `api-backend` (`/internal/openlines/v1/inbox`, `/internal/settings/v1/otp`). Сравнение токена — constant-time. Заголовок `X-Service-Token` здесь не используется. - **Токен выдаётся отдельно каждому продюсеру.** Хэш токена хранится в `notification_sources` (§10.9) и однозначно определяет `source` вызывающего. Правила авторизации: - `source` в теле запроса обязан совпадать с `source`, к которому привязан токен; иначе `403 forbidden`; - Cancel разрешён только по записям своего `source`; чужой ключ неотличим от несуществующего и даёт `404 not_found`; - добавление продюсера — строка в `notification_sources` и одна env-переменная вида `NOTIFICATIONS_TOKEN_`; ротация токена не затрагивает остальных. - seed содержит активный `source='producer_test'` только для smoke Internal API. Его secret передаётся как `NOTIFICATIONS_TOKEN_PRODUCER_TEST`, а в `notification_sources.token_hash` хранится только hash; использовать этот source для бизнес-событий запрещено. - Callers: сервисы приватной сети облака. Из интернета путь недоступен (nginx отдаёт `404`). - Операции: | Метод и путь | Операция | |---|---| | `POST /internal/notifications/v1/notifications` | Create (§7.4) | | `POST /internal/notifications/v1/notifications/cancel` | Cancel по `{source, external_id, close_reason}` | ### 9.4. Realtime — обязателен После connect на `WS /api/v1/realtime` клиент подписывается на уведомления явно, тем же сообщением `subscribe`: ```json {"type":"subscribe","dialog_ids":["uuid"],"notifications":true} {"type":"subscribed","dialog_ids":["uuid"],"notifications":true} ``` Поле `notifications` опционально и по умолчанию `false`; существующие клиенты чата продолжают работать без изменений. Гостю подписка не требуется: контур G не имеет per-user состояния. События (канал `han:rt:user:{user_id}`): | Событие | Когда | Payload | |---|---|---| | `notification.created` | Создано Internal Create | `event_id`, `occurred_at`, `notification` (тот же DTO, что в `GET`), `unread_count` | | `notification.updated` | Изменились `is_read` / `visibility` / `date_expired` / состав документов | `event_id`, `occurred_at`, `notification_id`, изменённые поля, `unread_count` | | `notification.closed` | Переход в `lifecycle_status='closed'` | `event_id`, `occurred_at`, `notification_id`, `close_reason`, `unread_count` | Правила: - События рассылаются **на все соединения пользователя, включая инициатора действия**: это и есть механизм синхронизации между устройствами. Клиент обязан корректно обрабатывать эхо собственного действия (идемпотентно, по `event_id`). - Семантика WS — at-most-once best effort, источник истины — БД (arch-02). После reconnect клиент повторяет `subscribe` и перечитывает список и счётчик через `GET`. - При недоступности WS более 30 секунд клиент переходит на polling `GET /api/v1/notifications` и `GET /api/v1/notifications/counter` с интервалом 60 секунд; возврат на WS — при первом успешном connect. - Массовое закрытие ежедневным джобом (§11.1) не рассылает событие на каждую запись: клиент узнаёт о нём при ближайшем `GET` или reconnect. ### 9.5. Ошибки Единый envelope arch-02: `{ "error": { "code", "message", "request_id", "details" } }`. | Код | HTTP | Когда | |---|---|---| | `validation_error` | 400 | Нарушены правила §7.3 или формат запроса | | `unauthorized` | 401 | Нет/невалиден JWT или service token | | `forbidden` | 403 | Продюсер указал `source`, не соответствующий своему токену (§9.3) | | `not_found` | 404 | Чужое, несуществующее, закрытое или удалённое уведомление; ключ Cancel, не принадлежащий продюсеру | | `notification_conflict` | 409 | Create с существующим `(source, external_id)` и другим содержимым; в `details.notification_id` — id существующей записи | | `notification_closed` | 409 | Действие клиента по уже закрытому уведомлению (§6.6) | | `button_not_allowed` | 422 | Нажата кнопка, не привязанная к виду уведомления (§9.1) | | `attachment_invalid` | 400 | Файл не проходит правила `chat.attachments.*` | | `rate_limit_exceeded` | 429 | Превышен лимит; заголовок `Retry-After` | `details` не содержит PII, presigned URL и внутренние трассировки. ### 9.6. Rate limiting Два слоя, как в module-01 §19: nginx edge и app-уровень через Redis. Новые ключи `app_settings`: | Ключ | Default | Identity | |---|---|---| | `rate_limit.notifications_read.per_user` | `120/minute` | user + IP | | `rate_limit.notifications_action.per_user` | `60/minute` | user | | `rate_limit.notification_upload.per_user` | `20/minute` | user | | `rate_limit.notifications_public.per_ip` | `60/minute` | IP hash | Скачивание документов использует существующий `rate_limit.download_url.per_user`. Internal Create/Cancel лимитируются по service identity и исходной сети. При недоступности Redis: загрузка файлов и выдача download-url — fail-closed `503`; чтение списков — допускается fail-open с метрикой. ### 9.7. Готовность к Web Push Стабильный UUID v7, события жизненного цикла, `header` / `text` как текст push, роут `/notification/{uuid}`. Реализация push — вне scope. --- ## 10. Модель данных (схема `han_app`) ### 10.1. Общие правила Применяются правила module-01 §9.1: `uuid` PK (UUID v7), `timestamptz` UTC, обязательные common fields (`record_status`, `status_changed_at`, `status_change_reason`, `created_at`, `updated_at`, `updater_user_id`), soft-delete `A`/`D`, запрет физического удаления прикладных строк, FK `ON DELETE RESTRICT`, строковые enum как `varchar` + `CHECK`. Технические таблицы черновиков — разрешённое исключение и очищаются по retention. ### 10.2. Справочники каталога Четыре таблицы: справочник видов (§10.2.3), наполняемый данными, и три реестра реализованных механик, кнопок и цветов, наполняемые вместе с кодом (§5.2). #### 10.2.1. `notification_cta_actions` | Поле | Тип | |---|---| | `id` | uuid PK | | `code` | varchar(32), unique active | | `description` | varchar(255) NOT NULL | | `requires_auth` | boolean NOT NULL | | `required_instance_fields` | varchar[] NOT NULL DEFAULT `'{}'` — поля экземпляра, обязательные для механики | | common fields | обязательны | Seed — по §5.3. Строки добавляются вместе с реализацией механики; наличие кода в таблице означает «механика реализована на бэкенде и фронте». #### 10.2.2. `notification_buttons` | Поле | Тип | |---|---| | `id` | uuid PK | | `code` | varchar(32), unique active | | `label` | varchar(64) NOT NULL | | `sets_hidden` | boolean NOT NULL DEFAULT `false` | | `applies_hidden_ttl` | boolean NOT NULL DEFAULT `false` | | `close_reason` | varchar(32) NULL — непустое значение означает «кнопка закрывает уведомление» | | `submits_documents` | boolean NOT NULL DEFAULT `false` | | common fields | обязательны | `CHECK`: `applies_hidden_ttl=true → sets_hidden=true` (TTL имеет смысл только при скрытии); `submits_documents=true → close_reason IS NOT NULL` (отправка без фиксации результата оставила бы уведомление висеть). Seed — по §5.4. #### 10.2.3. `notification_types` | Поле | Тип | |---|---| | `id` | uuid PK | | `code` | varchar(32), unique active | | `contour` | varchar(1) NOT NULL, CHECK `G` \| `P` | | `priority` | smallint NOT NULL | | `countable` | boolean NOT NULL | | `label` | varchar(64) NOT NULL | | `color_token` | varchar(32) NOT NULL FK → `notification_color_tokens.code` | | `icon_code` | varchar(32) NULL — при `NULL` или неизвестном фронту значении рисуется иконка по умолчанию | | `cta_text` | varchar(64) NOT NULL | | `cta_action` | varchar(32) NOT NULL FK → `notification_cta_actions.code` | | `cta_sets_hidden` | boolean NOT NULL DEFAULT `false` | | `cta_close_reason` | varchar(32) NULL | | `button_primary_code` | varchar(32) NULL FK → `notification_buttons.code` — основное действие деталки | | `button_secondary_code` | varchar(32) NULL FK → `notification_buttons.code` — второстепенное действие деталки | | `hidden_ttl_days` | smallint NULL — при `NULL` действует `notification.hidden.default_ttl_days` | | `documents_allowed` | boolean NOT NULL DEFAULT `false` | | `hide_on_document_download` | boolean NOT NULL DEFAULT `false` | | `required_detail_blocks` | varchar[] NOT NULL DEFAULT `'{}'` — блоки `details`, обязательные для вида | | common fields | обязательны | `CHECK`-ограничения: - `contour='G' → countable=false` — read-state в G не существует; - `contour='G' → cta_action <> 'open_detail'` — деталка в G была бы экраном без кнопок и без состояния (§5.8); - `cta_action='open_detail' → (cta_sets_hidden=false AND cta_close_reason IS NULL)` — у вида с деталкой `visibility` и завершение принадлежат кнопкам (§7.1); - `cta_action <> 'open_detail' → (documents_allowed=false AND hide_on_document_download=false AND required_detail_blocks='{}')` — без деталки документы негде показать; - `hide_on_document_download=true → documents_allowed=true`; - `cta_action='open_detail' → button_primary_code IS NOT NULL` — деталка без основной кнопки не убирается с главной (§5.4); - `cta_action <> 'open_detail' → (button_primary_code IS NULL AND button_secondary_code IS NULL)` — без деталки кнопки показать негде; - `button_secondary_code IS NOT NULL → button_primary_code IS NOT NULL` — второй слот не заполняется в обход первого; - `button_secondary_code <> button_primary_code` — одна кнопка не занимает оба слота. Четыре последних ограничения — та причина, по которой кнопки хранятся слотами, а не связующей таблицей: при `sort_order` те же правила стали бы условиями на количество строк в группе и в `CHECK` не выразились бы (§5.2). Наполняется seed-миграцией по §5.5. Строка **никогда не удаляется физически**: `record_status='D'` выводит вид из обращения для новых Create, оставляя исторические записи рендерящимися. #### 10.2.4. `notification_color_tokens` | Поле | Тип | |---|---| | `id` | uuid PK | | `code` | varchar(32), unique active — семантическое имя токена | | `description` | varchar(255) NOT NULL — когда применять; читается тем, кто заводит новый вид | | `sort_order` | smallint NOT NULL — порядок в будущем админ-пикере | | common fields | обязательны | Значений цвета (hex, RGB, названия CSS-переменных) в таблице **нет** — только имя токена (§5.2.1). Seed — по §5.2.1. FK из `notification_types.color_token` выбран вместо `CHECK ... IN (...)` по двум причинам: добавление токена остаётся `INSERT`, а не миграцией ограничения, и у токена появляется место для `description` — того самого текста, по которому выбирают цвет для нового вида. Перечень значений в DDL пришлось бы держать синхронным с темой фронта вслепую. ### 10.3. `notifications` (контур P) | Поле | Тип | |---|---| | `id` | uuid PK (v7) | | `user_id` | uuid NOT NULL FK → `user_identities` | | `notification_type` | varchar(32) NOT NULL FK → `notification_types.code` | | `source` | varchar(32) NOT NULL FK → `notification_sources.code` | | `external_id` | varchar(128) NOT NULL | | `request_fingerprint` | varchar(64) NOT NULL | | `notification_datetime` | timestamptz NOT NULL | | `header` | varchar(255) NOT NULL | | `text` | varchar(1024) NULL | | `priority_override` | smallint NULL | | `date_expired` | timestamptz NULL | | `price` / `old_price` | numeric(12,2) NULL | | `payment_url` | text NULL | | `details` | jsonb NULL — блоки по схеме §5.6.1, валидируется на уровне API | | `details_schema_version` | smallint NOT NULL DEFAULT 1 | | `chat_message_text` | varchar(1024) NULL | | `lifecycle_status` | varchar(16) NOT NULL, CHECK `active` \| `closed` | | `visibility` | varchar(16) NOT NULL, CHECK `visible` \| `hidden` | | `is_read` | boolean NOT NULL DEFAULT false | | `close_reason` | varchar(32) NULL, CHECK по §5.7 | | `closed_at` | timestamptz NULL | | common fields | обязательны | Ограничения и индексы: ```sql -- бизнес-ключ дедупликации: уникален навсегда, переиспользование запрещено CREATE UNIQUE INDEX uq_notifications_source_key ON han_app.notifications(source, external_id); -- выборка главной и Центра CREATE INDEX ix_notifications_user_active ON han_app.notifications(user_id, lifecycle_status, visibility, notification_datetime DESC, id DESC) WHERE record_status='A'; -- ежедневный джоб закрытия CREATE INDEX ix_notifications_expire ON han_app.notifications(date_expired) WHERE record_status='A' AND lifecycle_status='active' AND date_expired IS NOT NULL; ``` `CHECK`: `lifecycle_status='closed' → close_reason IS NOT NULL AND closed_at IS NOT NULL`; `old_price IS NOT NULL → price IS NOT NULL`. **Полей `send_documents`, `button_done`, `button_gotit` в таблице нет.** Первое — блок внутри `details`, два последних — слоты кнопок в `notification_types`. Хранение набора кнопок в экземпляре позволило бы продюсеру собрать деталку без единой кнопки, то есть карточку без выхода. `details_schema_version` нужен, чтобы будущее расширение схемы блоков (§5.6.1) не требовало миграции старых записей: рендер выбирается по версии, а не по догадке о наличии ключей. ### 10.4. `guest_notifications` (контур G) Поля — по §5.8, плюс common fields. Индексы: выборка `(lifecycle_status, notification_datetime DESC, id DESC) WHERE record_status='A'`; `(date_expired) WHERE record_status='A' AND lifecycle_status='active'`. Табличный `CHECK`: `old_price IS NOT NULL → price IS NOT NULL`; `instruction_url` — только схема `https`. Условия, зависящие от механики вида, проверяет **`BEFORE INSERT OR UPDATE` триггер валидации**: он разрешает `notification_type` в `notification_cta_actions.required_instance_fields` и требует, чтобы перечисленные там поля были заполнены, а не перечисленные — пусты. Практически это даёт то же, что раньше задавалось перечислением кодов: `chat_message_text` обязателен при `send_chat_message`, `instruction_url` — при `install_app_prompt` (без него шаг 3 адаптивного CTA §5.9 стал бы тупиком) и запрещён в остальных случаях. Триггер также проверяет, что вид принадлежит контуру G. Через `CHECK` это не выражается: условие требует чтения другой таблицы. Перечислять коды видов в `CHECK` нельзя — тогда добавление гостевого вида требовало бы миграции ограничения, то есть перестало бы быть операцией над данными. Гостевые записи создаются миграцией/seed-скриптом, минуя слой API, поэтому их валидация обязана жить в БД. ### 10.5. `notification_documents` Связь уведомления с документами компании. | Поле | Тип | |---|---| | `id` | uuid PK | | `notification_id` | uuid NOT NULL FK → `notifications` | | `document_id` | uuid NOT NULL FK → `documents` | | `sort_order` | smallint NOT NULL DEFAULT 0 | | `download_url_issued_at` | timestamptz NULL | | common fields | обязательны | Unique active `(notification_id, document_id)`. Сами файлы описываются существующей таблицей `documents` (module-01 §9.9): при Create `api-backend` проверяет объект в `han-chat-documents` через HeadObject и создаёт строку `documents`, либо переиспользует существующую по unique `(storage_bucket, object_key)`. Первое заполнение `download_url_issued_at` у **любого** документа связи — триггерное условие эффекта при `hide_on_document_download=true` (§7.1). Обновление выполняется атомарно с audit и скрытием уведомления; уже заполненная связь или ранее скачанный другой документ повторного эффекта не создают. Блок `details.documents[]` при чтении собирается из этой связи, а не из сохранённого продюсером JSON: иначе `title` и состав документов расходились бы с реестром после административной правки. ### 10.6. `client_upload_drafts` (техническая, переиспользуемая) | Поле | Тип | |---|---| | `id` | uuid PK = `draft_id` | | `user_id` | uuid NOT NULL | | `context_type` | varchar(32) NOT NULL, CHECK (`notification`, далее расширяется) | | `context_id` | uuid NOT NULL | | `original_file_name` / `safe_file_name` | varchar | | `mime_type` | varchar(128) | | `size_bytes` | bigint, CHECK > 0 | | `checksum_sha256` | char(64) | | `scan_status` | varchar(16), CHECK `pending` \| `clean` \| `bypassed` \| `infected` \| `failed`; `bypassed` — только Message Safety MOCK forced allow | | `storage_bucket` / `object_key` | varchar | | `quarantine_object_key` | varchar NULL | | `upload_expires_at` / `completed_at` | timestamptz | | `state` | varchar(16), CHECK `draft` \| `submitted` \| `discarded` | | `submission_id` | uuid NULL | | timestamps | обязательны | Индексы: `(user_id, context_type, context_id) WHERE state='draft'`; `(scan_status, updated_at)`; `(created_at)` для retention. **Триггера синхронизации на этой таблице нет.** Это техническая таблица: очистка по retention разрешена физически (§11.2). ### 10.7. `client_documents` (реестр отправленного клиентом) | Поле | Тип | |---|---| | `id` | uuid PK | | `user_id` | uuid NOT NULL FK | | `context_type` / `context_id` | varchar(32) / uuid NOT NULL | | `submission_id` | uuid NOT NULL | | `source_draft_id` | uuid NOT NULL | | `original_file_name` / `safe_file_name` / `mime_type` / `size_bytes` / `checksum_sha256` | как в черновике | | `storage_bucket` / `object_key` | varchar NOT NULL | | `submitted_at` | timestamptz NOT NULL | | common fields | обязательны | Unique active `(storage_bucket, object_key)`; unique `source_draft_id`; индекс `(context_type, context_id)`, `(user_id, submitted_at DESC)`. ### 10.8. Триггер `sync_queue` Новый `task_type`: **`document.client_uploaded`**. - Триггер `AFTER INSERT FOR EACH ROW` на `han_app.client_documents` при `record_status='A'` ставит задачу в `han_app.sync_queue`. - Dedup key задачи — `client_documents.id`, поэтому повтор невозможен; `submission_id` передаётся в payload и позволяет `bitrix-sync` сгруппировать файлы одной отправки. - Payload: `client_document_id`, `user_id`, `context_type`, `context_id`, `submission_id`, `storage_bucket`, `object_key`, `original_file_name`, `mime_type`, `size_bytes`, `checksum_sha256`. Presigned URL в payload не попадает. - Действует общее правило подавления: при `current_setting('han.sync_suppress', true)='true'` задача не создаётся. - Триггер и бизнес-транзакция — в одной транзакции. Application-код в `sync_queue` не пишет. - `bitrix_sync_user` получает GRANT на чтение `client_documents` дополнительно к существующим (arch-03). - Обработка `document.client_uploaded` — отдельное post-MVP расширение module-07; Contact worker не должен claim/ack такие задачи, они продолжают накапливаться в очереди (§3.2). ### 10.9. `notification_sources` Справочник продюсеров и их токенов. | Поле | Тип | |---|---| | `id` | uuid PK | | `code` | varchar(32), unique active — значение `source` | | `description` | text | | `token_hash` | varchar(128) NOT NULL — хэш service token продюсера, unique active | | `token_rotated_at` | timestamptz NULL | | common fields | обязательны | Сам токен в БД не хранится и в логи не попадает; сверка — по хэшу, constant-time. Входящий запрос сначала разрешается в `source` по токену, и только потом сверяется с `source` из тела (§9.3). Ротация — обновление `token_hash` и `token_rotated_at` у одной строки. ### 10.10. Схема ключей S3 ```text quarantine/users/{user_uuid}/uploads/{draft_uuid} attachments/users/{user_uuid}/{context_type}/{context_id}/{draft_uuid} documents/users/{user_uuid}/{document_uuid} ``` Первые две строки — новые, добавляются к существующей схеме module-01 §14 (ключи чата не меняются). Третья — существующая, переиспользуется для документов компании в уведомлениях. Ключи не содержат имён файлов и PII. Бакеты: `han-chat-quarantine` → `han-chat-attachments` (файлы клиента), `han-chat-documents` (документы компании). --- ## 11. Фоновые процессы ### 11.1. Ежедневное закрытие по сроку - Один запуск в сутки в `notification.expire_job.run_at` (по умолчанию 00:01 UTC), реализация — in-process worker `api-backend` по образцу существующих workers. - Операция set-based, по одному `UPDATE` на таблицу, отбор — `date_expired <= now()`, `lifecycle_status='active'`, `record_status='A'`: - `notifications` → `lifecycle_status='closed'`, `close_reason='expired'`, `closed_at=now()`; - `guest_notifications` → `lifecycle_status='closed'`, `closed_at=now()` (поля `close_reason` в контуре G нет, §5.8). - Не hot path; на пользовательские запросы не влияет. Защита от параллельного запуска на нескольких репликах — advisory lock. - **Выборки для UI дополнительно фильтруют по `date_expired > now()`** (§6.1, §6.2). Это не дублирование, а необходимость: без фильтра просроченное уведомление оставалось бы видимым до суток. - Индивидуальные WS-события джоб не рассылает (§9.4). ### 11.2. Очистка черновиков - Ежедневно: черновики `client_upload_drafts` в состоянии `draft` старше `notification.upload_draft.ttl_days` — удалить объект в S3 (карантин или working) и физически удалить строку. - Черновики в состоянии `submitted` очищаются после успешного переноса в `client_documents` по тому же TTL; объект S3 при этом **не** удаляется — он принадлежит реестру. - Черновики в состоянии `discarded` очищаются по тому же TTL; объект S3 удалён в момент отзыва черновика. - Черновики со `scan_status='infected'` удаляются вместе со строкой сразу после того, как UI показал ошибку; объект уже удалён на шаге вердикта. --- ## 12. Audit и observability Audit-события пишутся в `audit_events` (module-01 §9.14) по правилам arch-02: без PII, presigned URL и содержимого файлов. | `event_type` | Actor | Когда | |---|---|---| | `notification.created` | service | Успешный Internal Create | | `notification.create_conflict` | service | Create с занятым ключом | | `notification.cancelled` | service | Cancel | | `notification.read` | user | Первый переход `is_read` в `true` | | `notification.hidden` | user | Крестик или кнопка с `sets_hidden=true` | | `notification.button_pressed` | user | Нажата кнопка деталки; в metadata — `button_code` и применённые эффекты | | `notification.cta_invoked` | user | Выполнен CTA; в metadata — `cta_action` | | `notification.offer_accepted` | user | CTA `send_chat_message` — сообщение ушло в чат | | `notification.expired_batch` | system | Ежедневный джоб; в metadata — количество закрытых записей | | `notification.document.download_url_issued` | user | Выдача presigned GET | | `notification.document.uploaded` | user | Черновик прошёл проверку | | `notification.documents.submitted` | user | Нажата кнопка с `submits_documents=true`; в metadata — `submission_id` и количество файлов | Отдельного `event_type` на каждую кнопку **нет**: код кнопки попадает в metadata `notification.button_pressed`. Иначе добавление кнопки требовало бы правки словаря audit-событий и запросов аналитики. Метрики: количество активных/непрочитанных уведомлений, длительность ежедневного джоба и число закрытых записей, доля Create с конфликтом ключа, размер очереди `sync_queue` по `task_type='document.client_uploaded'`, количество отвалившихся черновиков, **доля пользователей, у которых число активных уведомлений упирается в `notification.center.max_items`** (сигнал о том, что продюсеры создают избыточный поток, §6.2). --- ## 13. Хранение данных - **Закрытые уведомления хранятся бессрочно и остаются `record_status='A'`.** Отдельной ретенции для них нет: перевод в `record_status='D'` означал бы «запись удалена ошибочно» и противоречил бы разграничению §5.7, при этом ничего не освобождал бы — строка, текст и файлы остаются на месте. Из UI закрытые записи не видны за счёт `lifecycle_status='closed'` (§3.2). - `record_status='D'` для уведомлений ставится только вручную — при административном удалении ошибочно созданной записи, с заполнением `status_changed_at` и `status_change_reason`. - `client_upload_drafts` — техническая таблица, физическая очистка по §11.2. Это единственная очистка в scope: брошенные черновики удерживают объекты в S3. - `client_documents` и `documents` — прикладные реестры, хранятся бессрочно. - Объекты S3 живут вместе с записями реестра; отдельная lifecycle-политика бакетов в этом релизе не вводится. - **На будущее.** При росте объёма рассматриваются два направления, оба вне scope: слой «кэширующих» таблиц без истории для горячих выборок; либо перенос истории в озеро данных с последующим удалением неактивных записей из App DB. Оба варианта требуют отдельного решения по arch-05, который сейчас запрещает физическое удаление прикладных строк. --- ## 14. Смежные сервисы и порядок работ | Компонент | Изменение | |---|---| | **Frontend** | Один источник (G или P); карусель; Центр и бейдж от бэкенда; **рендеринг карточек и кнопок по каталогу видов, без ветвлений по коду вида**; **значения токенов палитры в теме (светлая/тёмная) и набор SVG под `icon_code`**, оба с фолбэком при неизвестном имени; деталка как последовательность блоков `details`; адаптивный `install_app` с инструкцией только в новой вкладке; черновики документов; Чат/Оператор; подписка `notifications` в WS; CSP `frame-src 'none'` | | **api-backend** | Модель G+P; public/JWT/internal API; **валидация и эффекты, выводимые из справочников** (`cta_action`, кнопки, `required_detail_blocks`); валидатор схемы `details`; ежедневный джоб закрытия и очистка черновиков; WS-подписка и события; общий механизм загрузки файлов; регистрация документов компании в `documents` | | **App DB** | Таблицы §10; seed четырёх справочников каталога (виды, механики CTA, кнопки, палитра) и справочника источников; триггер валидации `guest_notifications`; триггер `document.client_uploaded`; новые ключи `app_settings`; GRANT для `bitrix_sync_user` на `client_documents` | | **nginx** | `/internal/notifications/*` не наружу; rate limit новых зон; SPA-роут `/notification/{uuid}`; CSP `frame-src 'none'`, так как instruction не встраивается | | **Redis/WS** | События `notification.*` в канал `han:rt:user:{user_id}` | | **message-safety** | Изменений контракта нет: черновики проверяются как вложения чата | | **bitrix-sync** | Новый `task_type` в контракте очереди; обработка — вне scope (§3.2) | | **S3** | Новые префиксы ключей (§10.10); новых бакетов не создаётся | | **settings** | `notification.*`, `rate_limit.notification*`, существующие `chat.attachments.*` и `operator.call.phone` | Порядок: (1) модель данных + четыре справочника каталога + Internal Create/Cancel → (2) клиентские API + публичный каталог видов + карусель и Центр + бейдж → (3) деталка, кнопки, экран инструкции, оплата → (4) документы: получение, черновики, отправка, триггер → (5) WS-подписка и события → (6) фоновые джобы (§11). Справочники идут первым шагом сознательно: если начать с UI, поведение неизбежно осядет в коде фронта, и вернуть его в данные будет уже дороже, чем заложить сразу. --- ## 15. Влияние на arch-документы | Документ | Что добавить или изменить | |---|---| | `arch-00-glossary.md` | Сущности `Notification`, `GuestNotification`, `NotificationType`, `NotificationCtaAction`, `NotificationButton`, `NotificationColorToken`, `ClientDocument`; internal-мнемоника **`notifications`** в реестр; термины `lifecycle_status`, `visibility`, `close_reason`, `cta_action` | | `arch-01-system-architecture.md` | Уведомления как домен `api-backend`; поток «продюсер → Internal Create → WS → клиент»; поток отправки документов клиентом; фиксация назначения `han-chat-documents` (документы компании из всех каналов) | | `arch-02-api-contracts.md` | Пути §9.1–§9.3, включая публичный каталог видов и единый эндпоинт нажатия кнопки; расширение `subscribe` полем `notifications` и три новых WS-события; новые коды ошибок `notification_conflict`, `notification_closed`, `button_not_allowed`, `attachment_invalid`; `task_type` `document.client_uploaded`; правило дедупликации по бизнес-ключу как исключение из общей политики `Idempotency-Key`; **модель «токен на продюсера»** — первый internal-эндпоинт с несколькими токенами и разрешением идентичности вызывающего по токену | | `arch-03-docker-compose-blueprint.md` | Env `NOTIFICATIONS_TOKEN_` — по одной переменной на продюсера; GRANT `bitrix_sync_user` на `client_documents` | | `arch-04-settings-and-content.md` | Seed `notification.*` (§5.10) с флагами `is_public`; новые ключи `rate_limit.notification*`; отсутствие allow-list/iframe-настройки instruction; **правило «настройка, специфичная для вида уведомления, — колонка справочника, а не ключ `app_settings`»** (§5.10) | | `arch-05-agent-development-process.md` | Уточнить разграничение `record_status` (только административное удаление) и доменного `lifecycle_status` (бизнес-завершение). Отметить, что запрет физического удаления прикладных строк придётся пересматривать при выносе истории в озеро данных (§13) | | `module-01-api-backend.md` | Таблицы §10 в §9; новые S3-префиксы в §14; триггер в §16; событие подписки в §17; новые джобы в списке workers; лимиты в §19; env в §18.3 | | `module-03-nginx.md` | Запрет `/internal/notifications/*` снаружи; зоны rate limit; CSP `frame-src 'none'` | | `module-07-bitrix-sync.md` | Контракт задачи `document.client_uploaded`: payload, dedup, ожидаемое поведение при включении сервиса | --- ## 16. Критерии приёмки 1. Колокольчик; в ЛК бейдж непрочитанных, синхронизация `is_read` / `visibility` между устройствами через WS; у гостя бейджа нет, Центр — экран «Авторизоваться». 2. Главная в ЛК: лимит и сортировка применены **на сервере**, только `active` + `visible` + не истёкшие; крестик → `hidden` без изменения `is_read`; закрытые не отображаются. 3. Гость: только public API, без клиентских фильтров по ОС/PWA; один тип `install_app` с адаптивным CTA и экраном инструкции. 4. Виды каталога различимы; `message` отсутствует; `ads`/`promo` разведены на `*_global` и `*_personal`; после логина виден только контур P. 5. Деталка и кнопки — по данным справочников: набор и подписи кнопок берутся из слотов вида, эффект нажатия — из атрибутов кнопки; у вида с деталкой пустой `details` не проходит валидацию; `payment_pending` CTA ведёт по `payment_url` и **не** скрывает карточку с главной; CTA `send_chat_message` сразу отправляет сообщение в чат и закрывает уведомление. 6. **Новый вид уведомления добавляется без правки кода:** добавление строки в `notification_types` со ссылкой на существующий `cta_action` и привязкой кнопок делает вид полностью работоспособным — создание через Internal Create, корректная карточка, деталка, кнопки и переходы жизненного цикла — при нулевых изменениях в бэкенде и фронте. Проверяется приёмочным тестом на заведомо новом виде, отсутствующем в seed. 7. **CTA у вида с деталкой не меняет `visibility`:** после открытия деталки и возврата назад карточка остаётся на главной; уходит она только по кнопке «Понятно» или «Готово». 8. Оформление: неизвестный или пустой `icon_code` рисуется иконкой по умолчанию, неизвестный `color_token` — токеном `neutral`; карточка остаётся работоспособной. Токен, отсутствующий в `notification_color_tokens`, в вид не сохраняется — FK не даёт. Значений цвета в БД нет. 9. Инструкция: любой допустимый `instruction_url` всегда открывается в новой вкладке; модалка/iframe не используются; запись с `install_app_prompt` без `instruction_url` не создаётся. 10. Internal только Create/Cancel; повтор Create с тем же ключом и тем же телом → `200` с существующей записью; с другим телом → `409 notification_conflict`; Cancel идемпотентен; повторное использование `(source, external_id)` невозможно даже после закрытия; продюсер не может создать или отменить запись с чужим `source`. 11. Ежедневный джоб закрывает истёкшие; до его прогона истёкшие уже не показываются за счёт фильтра выборки. 12. Документы клиента: черновик переживает выход из карточки и виден в блоке `pending_documents[]` при возврате с любого устройства; черновик можно удалить; кнопка «Отправить документы» переносит все чистые черновики в реестр, ставит задачи `document.client_uploaded` в `sync_queue` и закрывает уведомление с `docs_submitted`. 13. Документы компании лежат в `han-chat-documents`, зарегистрированы в `documents`, скачиваются presigned GET с audit-событием. Первое скачивание любого связанного документа скрывает уведомление один раз; TTL выставляет `date_expired` только при её отсутствии. **Постоянных ссылок на файлы в `details` нет** — блок `documents[]` при чтении содержит `document_id`, а не URL. 14. `details` принимается только по схеме §5.6.1: неизвестный блок и попытка передать `pending_documents` в Create → `400 validation_error`. 15. Оператор — только из `operator.call.phone`. 16. Все константы — из `app_settings`; идентификаторы UUID v7; чужое/закрытое → `404`, действие по закрытому → `409 notification_closed`, кнопка не от этого вида → `422 button_not_allowed`. 17. Валидация Create соответствует §7.3, ошибки — в едином envelope с кодами §9.5. 18. WS: после `subscribe` с `notifications:true` приходят три типа событий, включая эхо собственных действий пользователя; при обрыве более 30 секунд клиент уходит в polling и возвращается на WS. 19. Push не реализуется; модель push-ready. 20. Архива закрытых в UI нет; поля `opened` нет; бейдж и список Центра всегда согласованы. --- ## 17. Журнал решений (ненормативно) Ссылки на нормативные разделы; текст решений не дублируется. | # | Решение | Раздел | |---|---|---| | D1 | Два контура G и P; принадлежность задаётся видом; без локальных карточек и условий показа | §2, §5.5 | | D2 | Каталог видов в БД: вид — данные, поведение — код. Оформление (label, `color_token`, `icon_code`, `cta_text`) — колонки справочника | §5.2, §10.2 | | D3 | Тексты empty state и 404 — константы фронта до запуска мнемоник; поведение 404 | §5.10, §6.3 | | D4 | `ads`/`promo` разведены на `*_global` и `*_personal` | §5.5 | | D5 | Любое CTA → `is_read=true`; `visibility` — по атрибутам справочников | §7.1 | | D6 | Валидация Create выводится из `cta_action` и атрибутов вида; хардкод кодов видов в валидаторе запрещён | §7.3 | | D7 | `/internal/notifications/v1/...`, мнемоника `notifications`, Bearer service token — **отдельный токен на каждого продюсера**, `source` разрешается по токену | §9.3, §10.9 | | D8 | WS обязателен; подписка полем `notifications` в существующем `subscribe` | §9.4 | | D9 | Документы компании — `han-chat-documents` + регистрация в `documents`; файлы клиента — `han-chat-attachments` | §6.5, §10.10 | | D10 | Поле `opened` / impression не моделируется | §3.2 | | D11 | Один механизм дедупликации — бизнес-ключ `(source, external_id)`; повтор с тем же телом идемпотентен; ключ не переиспользуется | §7.4 | | D12 | IDOR: единый `404` на чужое и закрытое | §9.5 | | D13 | Кнопки деталки — справочник с фиксированными эффектами (`done`, `later`, `gotit`, `send_docs`); привязка к виду — **два слота в строке вида, а не связующая таблица**: слот даёт инварианты в `CHECK` и визуальный вес кнопки; **у вида с деталкой `visibility` и завершение управляются только кнопками, CTA их не меняет**; единый эндпоинт нажатия вместо эндпоинта на кнопку | §5.2, §5.4, §6.3, §7.1, §9.1, §10.2.3 | | D14 | Внешняя страница — только инструкция по установке (`install_app_prompt`) и всегда новая вкладка; modal/iframe отсутствуют. Рекламные виды промежуточных экранов не имеют: CTA сразу отправляет сообщение в чат | §6.4, §6.4.1, §7.2 | | D15 | Отправка документов через черновики и кнопку «Отправить документы»; механизм общий и переиспользуемый | §6.5, §10.6–§10.8 | | D16 | `payment_pending` не скрывается с главной по CTA | §7.1 | | D17 | Бейдж считается по окну Центра | §6.2 | | D18 | Приоритеты в справочнике + `priority_override` на экземпляре | §5.5, §5.6 | | D19 | Лимиты: главная 7, Центр 15; применяются на сервере | §5.10, §6.1 | | D20 | `lifecycle_status` — словами (`active`/`closed`); `record_status` — только административное удаление | §5.7, §10.1 | | D21 | `close_reason` расширен прозрачными значениями (`paid`, `docs_submitted`, `offer_accepted`) | §5.7 | | D22 | Закрытие по сроку — ежедневный джоб в 00:01 UTC + фильтр выборки | §11.1 | | D23 | Продюсер не переопределяет тексты CTA и подписи кнопок | §5.2 | | D24 | Мультиязычность и валидация `notification_datetime` — будущие релизы; `date_visible_from` не нужен | §3.2 | | D25 | Ретенция закрытых уведомлений не выполняется; рост объёма решается позже кэширующим слоем или выносом истории в озеро данных | §13 | | D26 | Пагинации в Центре нет: сверх лимита видны только приоритетные. Лимит — техническая страховка, объём потока контролируется бизнес-логикой продюсеров | §6.2 | | D27 | Справочник механик CTA (`notification_cta_actions`) — реестр реализованных механик; новая механика = код, новый вид на существующей механике = данные. Признак «есть деталка» отдельно не хранится: он тождествен `cta_action='open_detail'` | §5.2, §5.3, §10.2.1 | | D28 | `details` — закрытая схема блоков с версией: `deadline`, `details_header`, `details_text`, `todo_header`, `todo_plan[]`, `send_documents`, `pending_documents[]`, `documents[]`. Порядок и вёрстка блоков фиксированы, продюсер выбирает только заполнение | §5.6.1, §6.3, §10.3 | | D29 | `pending_documents` — read-only блок, формируемый бэкендом из черновиков клиента; продюсер его передать не может | §5.6.1, §6.5.2 | | D30 | В блоке `documents[]` постоянной ссылки нет: продюсер передаёт `object_key`, клиент получает `document_id`, URL выдаётся коротким presigned GET по запросу с audit-событием | §5.6.1, §6.5.1, §10.5 | | D31 | Настройка, специфичная для вида, — колонка справочника (`hidden_ttl_days`), а не ключ `app_settings` с кодом вида в имени | §5.10, §10.2.3 | | D32 | Каталог видов отдаётся публичной ручкой с ETag; внутренние правила переходов в него не попадают | §9.2 | | D33 | Валидация гостевых записей — триггер БД, выводящий требования из `cta_action`; перечисление кодов видов в `CHECK` запрещено | §10.4 | | D34 | Audit: код кнопки — в metadata единого события `notification.button_pressed`, отдельного `event_type` на кнопку нет | §12 | | D35 | Палитра — реестр `notification_color_tokens` с семантическими именами (`critical`, `warning`, `success`, `info`, `promo`, `neutral`) и FK из вида. **Значения цветов в БД не хранятся**: они живут в теме фронта парами для светлой и тёмной темы. Один токен может использоваться несколькими видами | §5.2, §5.2.1, §10.2.4 |