Files
han-app/functional_blocks (business logic)/notification-requirements.md
T

1131 lines
128 KiB
Markdown
Raw Blame History

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