Files
han-app/busines_tasks/notification-requirements.md
T
2026-07-27 13:04:02 +03:00

127 KiB
Raw Blame History

Бизнес-постановка: Уведомления (Notification Center)

Статус: v6 — каталог видов уведомлений переведён в данные (§5.2); все открытые вопросы закрыты; вход в проектирование OpenAPI и миграций
Продукт: HAN Chat (клиентское приложение + api-backend)
Источники: макет Figma (не канон — только визуализация элементов UI; при расхождении приоритет у этого ТЗ), архитектура HAN_chat_specification (arch-00arch-05, module-01)
Связанный backlog: «Моделирование уведомлений»; смежно — кнопка «Позвонить оператору», кнопка «Войти»; отдельно — индикатор непрочитанных сообщений чата (не в scope этого ТЗ)

Нормативная часть — §1–§16. Открытых вопросов нет: все решения приняты и внесены в текст. §17 — ненормативный журнал решений; при расхождении с §1–§16 приоритет у §1–§16.


1. Цель

Дать клиенту единый канал сервисных и маркетинговых коммуникаций вне чата: срочные напоминания, статусы услуг, документы, оплаты, акции, а также подсказки в гостевой зоне (авторизация, установка PWA).

Чат остаётся каналом диалога с оператором; уведомления — канал «компания → клиент» с действиями (переход, оплата, загрузка/скачивание документов, переход в чат по акции). Непрочитанные ответы оператора в чате не моделируются как уведомления (§3.2).

Гостевые ads_global/promo_global — не персонализированная реклама на конкретного клиента; отдельное маркетинговое согласие не требуется. Функционал описывается в Пользовательском соглашении.


2. Два контура показа

Контур Кто видит Источник данных Frontend
G. Гостевые уведомления Только гость guest_notificationspublic 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), оплата, приём предложения, документы.
  • Отправка документов клиентом: черновики → «Отправить документы» → реестр → 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 в текущем состоянии — no-op stub, очередь не обрабатывает. В scope этого релиза — только корректная постановка задачи в sync_queue; обработка — отдельная работа по module-07.
  • 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_tokenNOT 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 не перезаписывается.
  • 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; бейдж = activecountable ∧ ¬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_readvisibility: прочтение влияет на бейдж, видимость — на главную. Правила — §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.instruction.allowed_hosts Хосты, допустимые к показу инструкции в модалке (string_list) origin приложения нет
notification.expire_job.run_at Время ежедневного джоба закрытия (UTC, HH:MM) 00:01 нет
notification.upload_draft.ttl_days TTL неотправленных черновиков документов 7 нет

Лимиты применяются на сервере, поэтому max_items клиенту не публикуются. Allow-list инструкций клиенту не публикуется: режим показа определяет бэкенд (§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_headerdetails_texttodo_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_render_mode:

Значение Когда Поведение фронта
modal Хост instruction_url входит в notification.instruction.allowed_hosts Открыть модалку с iframe
external Хост не в allow-list Открыть в новой вкладке, модалку не показывать

Причина: внешняя страница может запретить встраивание через X-Frame-Options / frame-ancestors, и это не детектируется из JS — пользователь получил бы пустое белое окно. В allow-list попадают только страницы, для которых встраивание проверено; практически это собственный origin приложения. CSP frame-src должен соответствовать allow-list (§14).

В модалке всегда доступны закрытие и явная ссылка «Открыть в новой вкладке». Кнопок действий на этом экране нет: инструкция ничего не меняет в состоянии, а в контуре G состояние и негде хранить.

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 по первому документу трактуется как получение (проверить реальное скачивание технически невозможно). Эффекты — §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

Правило прочтения:

Любое CTAis_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 никогда не перезаписывается.
  • Синхронизация между устройствами обеспечивается тем, что все переходы выполняет бэкенд и рассылает 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). Неизвестный source400 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 <token> — тот же стиль, что у остальных эндпоинтов, которыми владеет 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_<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:

{"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 обязательны

Ограничения и индексы:

-- бизнес-ключ дедупликации: уникален навсегда, переиспользование запрещено
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).

Блок 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 | infected | failed
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).
  • Что именно происходит с задачей на стороне CRM — предмет module-07; в этом релизе bitrix-sync работает как no-op stub, задачи накапливаются в очереди (§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

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-quarantinehan-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':
    • notificationslifecycle_status='closed', close_reason='expired', closed_at=now();
    • guest_notificationslifecycle_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 под allow-list инструкций
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
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_<SOURCE> — по одной переменной на продюсера; GRANT bitrix_sync_user на client_documents
arch-04-settings-and-content.md Seed notification.* (§5.10) с флагами is_public; новые ключи rate_limit.notification*; фиксация notification.instruction.allowed_hosts как источника CSP frame-src; правило «настройка, специфичная для вида уведомления, — колонка справочника, а не ключ 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
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. Экран инструкции: хост из allow-list открывается в модалке, прочие — в новой вкладке; запись с 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-событием. Постоянных ссылок на файлы в 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), с allow-list хостов и режимом modal/external. Рекламные виды промежуточных экранов не имеют: 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