128 KiB
Бизнес-постановка: Уведомления (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 |
Единственный источник в ЛК |
Правила:
- До авторизации: только контур G.
- После авторизации: только контур P. Гостевые не показываются и не переносятся в персональные.
- Смешивать G и P в одной таблице / через
user_id IS NULL— запрещено. - Принадлежность контуру задаётся видом уведомления (
notification_types.contour), а не признаком записи. Один вид принадлежит ровно одному контуру. - В v1 нет клиентских условий показа (ОС / установленность PWA / прочие фильтры): фронт отображает то, что вернул API. Локальная генерация карточек на устройстве запрещена.
- Лимит и сортировка применяются на сервере. Клиент не досортировывает и не дообрезает список.
- У фронта всегда ровно один источник: 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; правила вложений — reusechat.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. Слои контента
- Краткий — баннер и строка списка:
header,text, цена, CTA. - Детальный — деталка (блоки
detailsпо фиксированной схеме §5.6.1, документы, кнопки) у видов с CTAopen_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
- Пользователь нажал CTA.
- Если среда поддерживает установку PWA (
beforeinstallpromptили эквивалент) — запускается установка. - Иначе — открывается экран инструкции по адресу из
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-схемы.
- Клиент прикладывает файл на деталке уведомления с
details.send_documents=true. init→ presigned PUT вhan-chat-quarantine, ключquarantine/users/{user_uuid}/uploads/{draft_uuid}. Создаётся строка-черновик вclient_upload_drafts(§10.6) со статусомpending.complete→ HeadObject, сверка размера/MIME/checksum, запуск проверки Message Safety (правила и allow-list — как у вложений чата).- Вердикт
allow→ объект переносится вhan-chat-attachments, ключattachments/users/{user_uuid}/{context_type}/{context_id}/{draft_uuid}; черновик получает статусclean. Вердиктdeny→ объект удаляется из карантина, черновик получает статусinfectedи в UI помечается ошибкой. - Черновики переживают выход из карточки. Вернувшись, клиент видит блок
pending_documents[]со всеми черновиками статусаcleanи может удалить любой: черновик переходит в состояниеdiscarded, объект удаляется из S3. Блок наполняет бэкенд при выдаче деталки, поэтому набор одинаков на всех устройствах. - Кнопка «Отправить документы» (
send_docs, §5.4) активна при наличии хотя бы одного черновикаclean. По нажатию все такие черновики одной транзакцией переносятся вclient_documents(§10.7) с общимsubmission_id, черновики помечаютсяsubmitted, уведомление закрывается сclose_reason='docs_submitted'. - Вставка в
client_documentsактивирует триггер БД → задача вsync_queue(§10.8). Наclient_upload_draftsтриггера синхронизации нет. - Закрытие — свойство кнопки, а не вида. Вид, у которого
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по CTAvisibilityне меняет (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_*)
- CTA сразу отправляет
chat_message_textв чат от лица клиента; промежуточных экранов нет. - Одновременно:
is_read=true,visibility='hidden',lifecycle_status='closed',close_reason='offer_accepted'— поcta_sets_hidden=trueиcta_close_reason='offer_accepted'в справочнике. В UI запись исчезает из-заclosed;hiddenфиксирует намерение убрать её с главной на случай гонок и для аудита. - Ни деталки, ни внешней страницы у этих видов нет: поля
detailsиinstruction_urlзапрещены. - Отказ от предложения не моделируется: клиент, которому предложение не интересно, убирает карточку крестиком, и она уходит по
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 <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>; ротация токена не затрагивает остальных.
- 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:
{"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). Обновление выполняется атомарно с 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 | 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-quarantine → han-chat-attachments (файлы клиента), han-chat-documents (документы компании).
11. Фоновые процессы
11.1. Ежедневное закрытие по сроку
- Один запуск в сутки в
notification.expire_job.run_at(по умолчанию 00:01 UTC), реализация — in-process workerapi-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_<SOURCE> — по одной переменной на продюсера; 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. Критерии приёмки
- Колокольчик; в ЛК бейдж непрочитанных, синхронизация
is_read/visibilityмежду устройствами через WS; у гостя бейджа нет, Центр — экран «Авторизоваться». - Главная в ЛК: лимит и сортировка применены на сервере, только
active+visible+ не истёкшие; крестик →hiddenбез измененияis_read; закрытые не отображаются. - Гость: только public API, без клиентских фильтров по ОС/PWA; один тип
install_appс адаптивным CTA и экраном инструкции. - Виды каталога различимы;
messageотсутствует;ads/promoразведены на*_globalи*_personal; после логина виден только контур P. - Деталка и кнопки — по данным справочников: набор и подписи кнопок берутся из слотов вида, эффект нажатия — из атрибутов кнопки; у вида с деталкой пустой
detailsне проходит валидацию;payment_pendingCTA ведёт поpayment_urlи не скрывает карточку с главной; CTAsend_chat_messageсразу отправляет сообщение в чат и закрывает уведомление. - Новый вид уведомления добавляется без правки кода: добавление строки в
notification_typesсо ссылкой на существующийcta_actionи привязкой кнопок делает вид полностью работоспособным — создание через Internal Create, корректная карточка, деталка, кнопки и переходы жизненного цикла — при нулевых изменениях в бэкенде и фронте. Проверяется приёмочным тестом на заведомо новом виде, отсутствующем в seed. - CTA у вида с деталкой не меняет
visibility: после открытия деталки и возврата назад карточка остаётся на главной; уходит она только по кнопке «Понятно» или «Готово». - Оформление: неизвестный или пустой
icon_codeрисуется иконкой по умолчанию, неизвестныйcolor_token— токеномneutral; карточка остаётся работоспособной. Токен, отсутствующий вnotification_color_tokens, в вид не сохраняется — FK не даёт. Значений цвета в БД нет. - Инструкция: любой допустимый
instruction_urlвсегда открывается в новой вкладке; модалка/iframe не используются; запись сinstall_app_promptбезinstruction_urlне создаётся. - Internal только Create/Cancel; повтор Create с тем же ключом и тем же телом →
200с существующей записью; с другим телом →409 notification_conflict; Cancel идемпотентен; повторное использование(source, external_id)невозможно даже после закрытия; продюсер не может создать или отменить запись с чужимsource. - Ежедневный джоб закрывает истёкшие; до его прогона истёкшие уже не показываются за счёт фильтра выборки.
- Документы клиента: черновик переживает выход из карточки и виден в блоке
pending_documents[]при возврате с любого устройства; черновик можно удалить; кнопка «Отправить документы» переносит все чистые черновики в реестр, ставит задачиdocument.client_uploadedвsync_queueи закрывает уведомление сdocs_submitted. - Документы компании лежат в
han-chat-documents, зарегистрированы вdocuments, скачиваются presigned GET с audit-событием. Первое скачивание любого связанного документа скрывает уведомление один раз; TTL выставляетdate_expiredтолько при её отсутствии. Постоянных ссылок на файлы вdetailsнет — блокdocuments[]при чтении содержитdocument_id, а не URL. detailsпринимается только по схеме §5.6.1: неизвестный блок и попытка передатьpending_documentsв Create →400 validation_error.- Оператор — только из
operator.call.phone. - Все константы — из
app_settings; идентификаторы UUID v7; чужое/закрытое →404, действие по закрытому →409 notification_closed, кнопка не от этого вида →422 button_not_allowed. - Валидация Create соответствует §7.3, ошибки — в едином envelope с кодами §9.5.
- WS: после
subscribeсnotifications:trueприходят три типа событий, включая эхо собственных действий пользователя; при обрыве более 30 секунд клиент уходит в polling и возвращается на WS. - Push не реализуется; модель push-ready.
- Архива закрытых в 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 |