1142 lines
127 KiB
Markdown
1142 lines
127 KiB
Markdown
# Бизнес-постановка: Уведомления (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), оплата, приём предложения, документы.
|
||
- Отправка документов клиентом: черновики → «Отправить документы» → реестр → `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` не перезаписывается.
|
||
- `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.instruction.allowed_hosts` | Хосты, допустимые к показу инструкции в модалке (string_list) | origin приложения | нет |
|
||
| `notification.expire_job.run_at` | Время ежедневного джоба закрытия (UTC, `HH:MM`) | `00:01` | нет |
|
||
| `notification.upload_draft.ttl_days` | TTL неотправленных черновиков документов | `7` | нет |
|
||
|
||
Лимиты применяются на сервере, поэтому `max_items` клиенту не публикуются. Allow-list инструкций клиенту не публикуется: режим показа определяет бэкенд (§6.4).
|
||
|
||
**Ключей с кодом вида в имени в `app_settings` быть не должно.** Настройка, специфичная для вида, — это колонка справочника (`hidden_ttl_days`), иначе добавление вида требовало бы новых ключей настроек, то есть перестало бы быть операцией над данными.
|
||
|
||
Тексты пустых состояний, гостевого Центра и недоступного уведомления в MVP — константы фронта. Планируемые мнемоники для будущего переноса в `text_resources`: `notification.center.empty.title` / `.text`, `notification.guest_center.title` / `.text` / `.cta`, `notification.detail.not_found.title` / `.text`.
|
||
|
||
---
|
||
|
||
## 6. Поведение UI
|
||
|
||
### 6.1. Главная (карусель)
|
||
|
||
**ЛК (P):**
|
||
|
||
- Выборка: `record_status='A'`, `lifecycle_status='active'`, `visibility='visible'`, `(date_expired IS NULL OR date_expired > now())`.
|
||
- Лимит `notification.home.max_items` (7) — применяет сервер.
|
||
- Сортировка: эффективный приоритет ↑, затем `notification_datetime` ↓, затем `id` ↓ (стабильность).
|
||
- Крестик → `visibility='hidden'`, синхронно на всех устройствах. `is_read` крестик **не** меняет.
|
||
- Свайп; автопрокрутка по настройкам.
|
||
- CTA → механика `cta_action` вида + эффекты §7.1.
|
||
- Оформление карточки (цвет, иконка, label, текст CTA) берётся из каталога видов (§9.2); ветвлений по коду вида на фронте нет. Наличие `details.deadline` добавляет маркер срока.
|
||
|
||
**Гость (G):**
|
||
|
||
- Выборка: `record_status='A'`, `lifecycle_status='active'`, `(date_expired IS NULL OR date_expired > now())`.
|
||
- Лимит `notification.home.max_items`, та же сортировка — применяет сервер.
|
||
- Крестика нет.
|
||
|
||
### 6.2. Центр уведомлений
|
||
|
||
**Гость:** колокольчик без бейджа; экран auth-gate с кнопкой «Авторизоваться». Списка нет.
|
||
|
||
**ЛК:**
|
||
|
||
- Выборка: `record_status='A'`, `lifecycle_status='active'`, `(date_expired IS NULL OR date_expired > now())` — **включая** `visibility='hidden'`.
|
||
- Лимит `notification.center.max_items` (15), архива закрытых нет.
|
||
- Сортировка: эффективный приоритет ↑ → непрочитанные выше → `notification_datetime` ↓ → `id` ↓.
|
||
- Точка на непрочитанных countable.
|
||
- **Бейдж считается по тому же окну, что и список:** число непрочитанных countable среди первых `notification.center.max_items` записей выборки. Записи, не попавшие в окно, в бейдже не учитываются — бейдж и список всегда сходятся.
|
||
- Значение бейджа отдаёт бэкенд (§9.1) и обновляет по WS; локально фронт его не пересчитывает.
|
||
- **Пагинации нет — это осознанное решение.** Если активных уведомлений больше лимита, клиент видит только самые приоритетные, остальные недоступны до тех пор, пока верхние не будут закрыты или скрыты. Лимит здесь — техническая страховка: избыточное число одновременно активных уведомлений считается проблемой бизнес-логики продюсеров и решается на их стороне, а не прокруткой длинного списка. Метрика доли пользователей, упирающихся в лимит, — в §12.
|
||
|
||
### 6.3. Деталка
|
||
|
||
- SPA-роут `/notification/{uuid}`; только контур P; только виды с `cta_action='open_detail'`.
|
||
- Чужое / закрытое / удалённое → **404** (не 403), нейтральный текст «Уведомление недоступно».
|
||
- Деталка рендерится как последовательность блоков `details` в фиксированном порядке схемы (§5.6.1). Незаполненный блок не отображается, порядок блоков продюсер не задаёт: иначе появились бы верстки, зависящие от вида.
|
||
- Порядок: срок (`deadline`) → `details_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_render_mode`:
|
||
|
||
| Значение | Когда | Поведение фронта |
|
||
|---|---|---|
|
||
| `modal` | Хост `instruction_url` входит в `notification.instruction.allowed_hosts` | Открыть модалку с `iframe` |
|
||
| `external` | Хост не в allow-list | Открыть в новой вкладке, модалку не показывать |
|
||
|
||
Причина: внешняя страница может запретить встраивание через `X-Frame-Options` / `frame-ancestors`, и это не детектируется из JS — пользователь получил бы пустое белое окно. В allow-list попадают только страницы, для которых встраивание проверено; практически это собственный origin приложения. CSP `frame-src` должен соответствовать allow-list (§14).
|
||
|
||
В модалке всегда доступны закрытие и явная ссылка «Открыть в новой вкладке». Кнопок действий на этом экране нет: инструкция ничего не меняет в состоянии, а в контуре G состояние и негде хранить.
|
||
|
||
### 6.4.1. CTA рекламных видов
|
||
|
||
`ads_*` / `promo_*` (механика `send_chat_message`) промежуточных экранов не имеют:
|
||
|
||
- **Контур P.** CTA сразу отправляет `chat_message_text` в чат от лица клиента и закрывает уведомление (§7.2).
|
||
- **Контур G.** CTA запускает сценарий авторизации; после успешного входа `chat_message_text` уходит в чат от лица клиента. Кампания остаётся `active` для остальных гостей.
|
||
|
||
Отправка `chat_message_text` после авторизации использует общий механизм отложенного сообщения (тот же, что у «Популярных вопросов»). Если у клиента нет активного диалога — он создаётся штатным путём.
|
||
|
||
### 6.5. Документы
|
||
|
||
#### 6.5.1. Документы компании (получение)
|
||
|
||
- Хранение: бакет **`han-chat-documents`**, ключ `documents/users/{user_uuid}/{document_uuid}`.
|
||
- Каждый документ регистрируется строкой в таблице `documents` и связывается с уведомлением через `notification_documents` (§10.5). Это делает будущий раздел профиля «Документы» сборником по всем каналам без миграции файлов.
|
||
- Продюсер передаёт документы в блоке `details.documents[]` метаданными (`object_key`, `title`, `mime_type`, `size_bytes`, `checksum_sha256`); `api-backend` при Create регистрирует их в `documents` / `notification_documents` и **заменяет блок на представление для чтения** с `document_id` (§5.6.1).
|
||
- Скачивание — короткий presigned GET через `.../download-url` по `document_id`, с обязательным audit-событием (§12); URL в логи и audit не пишется. Постоянных ссылок на файлы не существует.
|
||
- У вида с `hide_on_document_download=true` **факт выдачи download-url по первому документу** трактуется как получение (проверить реальное скачивание технически невозможно). Эффекты — §7.1.
|
||
|
||
#### 6.5.2. Документы клиента (отправка) — общий механизм
|
||
|
||
Механизм проектируется как **переиспользуемый**: контекст задаётся парой `context_type` / `context_id`, уведомление — первый потребитель, будущие фичи подключаются без изменения таблиц и S3-схемы.
|
||
|
||
1. Клиент прикладывает файл на деталке уведомления с `details.send_documents=true`.
|
||
2. `init` → presigned PUT в **`han-chat-quarantine`**, ключ `quarantine/users/{user_uuid}/uploads/{draft_uuid}`. Создаётся строка-черновик в `client_upload_drafts` (§10.6) со статусом `pending`.
|
||
3. `complete` → HeadObject, сверка размера/MIME/checksum, запуск проверки Message Safety (правила и allow-list — как у вложений чата).
|
||
4. Вердикт `allow` → объект переносится в **`han-chat-attachments`**, ключ `attachments/users/{user_uuid}/{context_type}/{context_id}/{draft_uuid}`; черновик получает статус `clean`. Вердикт `deny` → объект удаляется из карантина, черновик получает статус `infected` и в UI помечается ошибкой.
|
||
5. **Черновики переживают выход из карточки.** Вернувшись, клиент видит блок `pending_documents[]` со всеми черновиками статуса `clean` и может удалить любой: черновик переходит в состояние `discarded`, объект удаляется из S3. Блок наполняет бэкенд при выдаче деталки, поэтому набор одинаков на всех устройствах.
|
||
6. Кнопка **«Отправить документы»** (`send_docs`, §5.4) активна при наличии хотя бы одного черновика `clean`. По нажатию все такие черновики одной транзакцией переносятся в `client_documents` (§10.7) с общим `submission_id`, черновики помечаются `submitted`, уведомление закрывается с `close_reason='docs_submitted'`.
|
||
7. Вставка в `client_documents` активирует **триггер БД** → задача в `sync_queue` (§10.8). На `client_upload_drafts` триггера синхронизации **нет**.
|
||
8. Закрытие — свойство кнопки, а не вида. Вид, у которого `details.send_documents=true`, но в наборе кнопок нет `send_docs`, отправить документы не позволит: кнопка — единственный способ инициировать отправку. Это проверяется валидацией Create (§7.3).
|
||
|
||
Ограничения: не больше `notification.documents.max_files` файлов в одной отправке; форматы и размер — по `chat.attachments.*`. Черновики старше `notification.upload_draft.ttl_days` удаляются вместе с объектами S3 (§11.2).
|
||
|
||
### 6.6. Завершение и гонки
|
||
|
||
Переход в `closed` возможен только четырьмя путями, и все они декларативны:
|
||
|
||
| Путь | Механизм | `close_reason` |
|
||
|---|---|---|
|
||
| Нажата кнопка с непустым `close_reason` | `notification_buttons` (§5.4) | `user_done` / `docs_submitted` |
|
||
| CTA вида без деталки с заданным `cta_close_reason` | `notification_types` (§5.5) | `offer_accepted` |
|
||
| Cancel от продюсера | Internal API (§7.2) | `paid` / `cancelled` |
|
||
| Наступил `date_expired` | ежедневный джоб (§11.1) | `expired` |
|
||
|
||
Отсюда следуют наблюдаемые сценарии: `payment_pending` закрывается только Cancel от платёжного сервиса с `paid`; `docs_required` — кнопкой `send_docs`; `ads_*` / `promo_*` — своим CTA; `docs_ready` уходит с главной по `hide_on_document_download` и закрывается по сроку; вид с кнопкой `gotit` без `done` пользователем не закрывается вовсе — только по сроку или Cancel.
|
||
|
||
**Гонки на открытой карточке.** Если запись закрылась (expire, Cancel) или изменилась с другого устройства, пока пользователь держит деталку открытой:
|
||
|
||
- клиент получает WS-событие `notification.closed` / `notification.updated` и показывает нейтральную плашку «Уведомление больше не актуально»; кнопки действий блокируются;
|
||
- любое действие по уже закрытой записи → `409 notification_closed`, состояние не меняется;
|
||
- повтор того же действия по активной записи идемпотентен и возвращает `200` с текущим состоянием (повторный `read`, `hide`, `gotit` ошибкой не являются);
|
||
- при отсутствии WS расхождение устраняется при следующем `GET` — фронт обязан перечитать запись перед показом результата действия.
|
||
|
||
---
|
||
|
||
## 7. Матрицы поведения (контур P)
|
||
|
||
### 7.1. `is_read` и `visibility`
|
||
|
||
**Правило прочтения:**
|
||
|
||
> Любое **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` никогда не перезаписывается.
|
||
- Синхронизация между устройствами обеспечивается тем, что все переходы выполняет бэкенд и рассылает 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>`; ротация токена не затрагивает остальных.
|
||
- 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).
|
||
|
||
Блок `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` под allow-list инструкций |
|
||
| **api-backend** | Модель G+P; public/JWT/internal API; **валидация и эффекты, выводимые из справочников** (`cta_action`, кнопки, `required_detail_blocks`); валидатор схемы `details`; ежедневный джоб закрытия и очистка черновиков; WS-подписка и события; общий механизм загрузки файлов; регистрация документов компании в `documents` |
|
||
| **App DB** | Таблицы §10; seed четырёх справочников каталога (виды, механики CTA, кнопки, палитра) и справочника источников; триггер валидации `guest_notifications`; триггер `document.client_uploaded`; новые ключи `app_settings`; GRANT для `bitrix_sync_user` на `client_documents` |
|
||
| **nginx** | `/internal/notifications/*` не наружу; rate limit новых зон; SPA-роут `/notification/{uuid}`; заголовок CSP с `frame-src` |
|
||
| **Redis/WS** | События `notification.*` в канал `han:rt:user:{user_id}` |
|
||
| **message-safety** | Изменений контракта нет: черновики проверяются как вложения чата |
|
||
| **bitrix-sync** | Новый `task_type` в контракте очереди; обработка — вне scope (§3.2) |
|
||
| **S3** | Новые префиксы ключей (§10.10); новых бакетов не создаётся |
|
||
| **settings** | `notification.*`, `rate_limit.notification*`, существующие `chat.attachments.*` и `operator.call.phone` |
|
||
|
||
Порядок: (1) модель данных + четыре справочника каталога + Internal Create/Cancel → (2) клиентские API + публичный каталог видов + карусель и Центр + бейдж → (3) деталка, кнопки, экран инструкции, оплата → (4) документы: получение, черновики, отправка, триггер → (5) WS-подписка и события → (6) фоновые джобы (§11).
|
||
|
||
Справочники идут первым шагом сознательно: если начать с UI, поведение неизбежно осядет в коде фронта, и вернуть его в данные будет уже дороже, чем заложить сразу.
|
||
|
||
---
|
||
|
||
## 15. Влияние на arch-документы
|
||
|
||
| Документ | Что добавить или изменить |
|
||
|---|---|
|
||
| `arch-00-glossary.md` | Сущности `Notification`, `GuestNotification`, `NotificationType`, `NotificationCtaAction`, `NotificationButton`, `NotificationColorToken`, `ClientDocument`; internal-мнемоника **`notifications`** в реестр; термины `lifecycle_status`, `visibility`, `close_reason`, `cta_action` |
|
||
| `arch-01-system-architecture.md` | Уведомления как домен `api-backend`; поток «продюсер → Internal Create → WS → клиент»; поток отправки документов клиентом; фиксация назначения `han-chat-documents` (документы компании из всех каналов) |
|
||
| `arch-02-api-contracts.md` | Пути §9.1–§9.3, включая публичный каталог видов и единый эндпоинт нажатия кнопки; расширение `subscribe` полем `notifications` и три новых WS-события; новые коды ошибок `notification_conflict`, `notification_closed`, `button_not_allowed`, `attachment_invalid`; `task_type` `document.client_uploaded`; правило дедупликации по бизнес-ключу как исключение из общей политики `Idempotency-Key`; **модель «токен на продюсера»** — первый internal-эндпоинт с несколькими токенами и разрешением идентичности вызывающего по токену |
|
||
| `arch-03-docker-compose-blueprint.md` | Env `NOTIFICATIONS_TOKEN_<SOURCE>` — по одной переменной на продюсера; GRANT `bitrix_sync_user` на `client_documents` |
|
||
| `arch-04-settings-and-content.md` | Seed `notification.*` (§5.10) с флагами `is_public`; новые ключи `rate_limit.notification*`; фиксация `notification.instruction.allowed_hosts` как источника CSP `frame-src`; **правило «настройка, специфичная для вида уведомления, — колонка справочника, а не ключ `app_settings`»** (§5.10) |
|
||
| `arch-05-agent-development-process.md` | Уточнить разграничение `record_status` (только административное удаление) и доменного `lifecycle_status` (бизнес-завершение). Отметить, что запрет физического удаления прикладных строк придётся пересматривать при выносе истории в озеро данных (§13) |
|
||
| `module-01-api-backend.md` | Таблицы §10 в §9; новые S3-префиксы в §14; триггер в §16; событие подписки в §17; новые джобы в списке workers; лимиты в §19; env в §18.3 |
|
||
| `module-03-nginx.md` | Запрет `/internal/notifications/*` снаружи; зоны rate limit; CSP `frame-src` |
|
||
| `module-07-bitrix-sync.md` | Контракт задачи `document.client_uploaded`: payload, dedup, ожидаемое поведение при включении сервиса |
|
||
|
||
---
|
||
|
||
## 16. Критерии приёмки
|
||
|
||
1. Колокольчик; в ЛК бейдж непрочитанных, синхронизация `is_read` / `visibility` между устройствами через WS; у гостя бейджа нет, Центр — экран «Авторизоваться».
|
||
2. Главная в ЛК: лимит и сортировка применены **на сервере**, только `active` + `visible` + не истёкшие; крестик → `hidden` без изменения `is_read`; закрытые не отображаются.
|
||
3. Гость: только public API, без клиентских фильтров по ОС/PWA; один тип `install_app` с адаптивным CTA и экраном инструкции.
|
||
4. Виды каталога различимы; `message` отсутствует; `ads`/`promo` разведены на `*_global` и `*_personal`; после логина виден только контур P.
|
||
5. Деталка и кнопки — по данным справочников: набор и подписи кнопок берутся из слотов вида, эффект нажатия — из атрибутов кнопки; у вида с деталкой пустой `details` не проходит валидацию; `payment_pending` CTA ведёт по `payment_url` и **не** скрывает карточку с главной; CTA `send_chat_message` сразу отправляет сообщение в чат и закрывает уведомление.
|
||
6. **Новый вид уведомления добавляется без правки кода:** добавление строки в `notification_types` со ссылкой на существующий `cta_action` и привязкой кнопок делает вид полностью работоспособным — создание через Internal Create, корректная карточка, деталка, кнопки и переходы жизненного цикла — при нулевых изменениях в бэкенде и фронте. Проверяется приёмочным тестом на заведомо новом виде, отсутствующем в seed.
|
||
7. **CTA у вида с деталкой не меняет `visibility`:** после открытия деталки и возврата назад карточка остаётся на главной; уходит она только по кнопке «Понятно» или «Готово».
|
||
8. Оформление: неизвестный или пустой `icon_code` рисуется иконкой по умолчанию, неизвестный `color_token` — токеном `neutral`; карточка остаётся работоспособной. Токен, отсутствующий в `notification_color_tokens`, в вид не сохраняется — FK не даёт. Значений цвета в БД нет.
|
||
9. Экран инструкции: хост из allow-list открывается в модалке, прочие — в новой вкладке; запись с `install_app_prompt` без `instruction_url` не создаётся.
|
||
10. Internal только Create/Cancel; повтор Create с тем же ключом и тем же телом → `200` с существующей записью; с другим телом → `409 notification_conflict`; Cancel идемпотентен; повторное использование `(source, external_id)` невозможно даже после закрытия; продюсер не может создать или отменить запись с чужим `source`.
|
||
11. Ежедневный джоб закрывает истёкшие; до его прогона истёкшие уже не показываются за счёт фильтра выборки.
|
||
12. Документы клиента: черновик переживает выход из карточки и виден в блоке `pending_documents[]` при возврате с любого устройства; черновик можно удалить; кнопка «Отправить документы» переносит все чистые черновики в реестр, ставит задачи `document.client_uploaded` в `sync_queue` и закрывает уведомление с `docs_submitted`.
|
||
13. Документы компании лежат в `han-chat-documents`, зарегистрированы в `documents`, скачиваются presigned GET с audit-событием. **Постоянных ссылок на файлы в `details` нет** — блок `documents[]` при чтении содержит `document_id`, а не URL.
|
||
14. `details` принимается только по схеме §5.6.1: неизвестный блок и попытка передать `pending_documents` в Create → `400 validation_error`.
|
||
15. Оператор — только из `operator.call.phone`.
|
||
16. Все константы — из `app_settings`; идентификаторы UUID v7; чужое/закрытое → `404`, действие по закрытому → `409 notification_closed`, кнопка не от этого вида → `422 button_not_allowed`.
|
||
17. Валидация Create соответствует §7.3, ошибки — в едином envelope с кодами §9.5.
|
||
18. WS: после `subscribe` с `notifications:true` приходят три типа событий, включая эхо собственных действий пользователя; при обрыве более 30 секунд клиент уходит в polling и возвращается на WS.
|
||
19. Push не реализуется; модель push-ready.
|
||
20. Архива закрытых в UI нет; поля `opened` нет; бейдж и список Центра всегда согласованы.
|
||
|
||
---
|
||
|
||
## 17. Журнал решений (ненормативно)
|
||
|
||
Ссылки на нормативные разделы; текст решений не дублируется.
|
||
|
||
| # | Решение | Раздел |
|
||
|---|---|---|
|
||
| D1 | Два контура G и P; принадлежность задаётся видом; без локальных карточек и условий показа | §2, §5.5 |
|
||
| D2 | Каталог видов в БД: вид — данные, поведение — код. Оформление (label, `color_token`, `icon_code`, `cta_text`) — колонки справочника | §5.2, §10.2 |
|
||
| D3 | Тексты empty state и 404 — константы фронта до запуска мнемоник; поведение 404 | §5.10, §6.3 |
|
||
| D4 | `ads`/`promo` разведены на `*_global` и `*_personal` | §5.5 |
|
||
| D5 | Любое CTA → `is_read=true`; `visibility` — по атрибутам справочников | §7.1 |
|
||
| D6 | Валидация Create выводится из `cta_action` и атрибутов вида; хардкод кодов видов в валидаторе запрещён | §7.3 |
|
||
| D7 | `/internal/notifications/v1/...`, мнемоника `notifications`, Bearer service token — **отдельный токен на каждого продюсера**, `source` разрешается по токену | §9.3, §10.9 |
|
||
| D8 | WS обязателен; подписка полем `notifications` в существующем `subscribe` | §9.4 |
|
||
| D9 | Документы компании — `han-chat-documents` + регистрация в `documents`; файлы клиента — `han-chat-attachments` | §6.5, §10.10 |
|
||
| D10 | Поле `opened` / impression не моделируется | §3.2 |
|
||
| D11 | Один механизм дедупликации — бизнес-ключ `(source, external_id)`; повтор с тем же телом идемпотентен; ключ не переиспользуется | §7.4 |
|
||
| D12 | IDOR: единый `404` на чужое и закрытое | §9.5 |
|
||
| D13 | Кнопки деталки — справочник с фиксированными эффектами (`done`, `later`, `gotit`, `send_docs`); привязка к виду — **два слота в строке вида, а не связующая таблица**: слот даёт инварианты в `CHECK` и визуальный вес кнопки; **у вида с деталкой `visibility` и завершение управляются только кнопками, CTA их не меняет**; единый эндпоинт нажатия вместо эндпоинта на кнопку | §5.2, §5.4, §6.3, §7.1, §9.1, §10.2.3 |
|
||
| D14 | Внешняя страница — только инструкция по установке (`install_app_prompt`), с allow-list хостов и режимом `modal`/`external`. Рекламные виды промежуточных экранов не имеют: CTA сразу отправляет сообщение в чат | §6.4, §6.4.1, §7.2 |
|
||
| D15 | Отправка документов через черновики и кнопку «Отправить документы»; механизм общий и переиспользуемый | §6.5, §10.6–§10.8 |
|
||
| D16 | `payment_pending` не скрывается с главной по CTA | §7.1 |
|
||
| D17 | Бейдж считается по окну Центра | §6.2 |
|
||
| D18 | Приоритеты в справочнике + `priority_override` на экземпляре | §5.5, §5.6 |
|
||
| D19 | Лимиты: главная 7, Центр 15; применяются на сервере | §5.10, §6.1 |
|
||
| D20 | `lifecycle_status` — словами (`active`/`closed`); `record_status` — только административное удаление | §5.7, §10.1 |
|
||
| D21 | `close_reason` расширен прозрачными значениями (`paid`, `docs_submitted`, `offer_accepted`) | §5.7 |
|
||
| D22 | Закрытие по сроку — ежедневный джоб в 00:01 UTC + фильтр выборки | §11.1 |
|
||
| D23 | Продюсер не переопределяет тексты CTA и подписи кнопок | §5.2 |
|
||
| D24 | Мультиязычность и валидация `notification_datetime` — будущие релизы; `date_visible_from` не нужен | §3.2 |
|
||
| D25 | Ретенция закрытых уведомлений не выполняется; рост объёма решается позже кэширующим слоем или выносом истории в озеро данных | §13 |
|
||
| D26 | Пагинации в Центре нет: сверх лимита видны только приоритетные. Лимит — техническая страховка, объём потока контролируется бизнес-логикой продюсеров | §6.2 |
|
||
| D27 | Справочник механик CTA (`notification_cta_actions`) — реестр реализованных механик; новая механика = код, новый вид на существующей механике = данные. Признак «есть деталка» отдельно не хранится: он тождествен `cta_action='open_detail'` | §5.2, §5.3, §10.2.1 |
|
||
| D28 | `details` — закрытая схема блоков с версией: `deadline`, `details_header`, `details_text`, `todo_header`, `todo_plan[]`, `send_documents`, `pending_documents[]`, `documents[]`. Порядок и вёрстка блоков фиксированы, продюсер выбирает только заполнение | §5.6.1, §6.3, §10.3 |
|
||
| D29 | `pending_documents` — read-only блок, формируемый бэкендом из черновиков клиента; продюсер его передать не может | §5.6.1, §6.5.2 |
|
||
| D30 | В блоке `documents[]` постоянной ссылки нет: продюсер передаёт `object_key`, клиент получает `document_id`, URL выдаётся коротким presigned GET по запросу с audit-событием | §5.6.1, §6.5.1, §10.5 |
|
||
| D31 | Настройка, специфичная для вида, — колонка справочника (`hidden_ttl_days`), а не ключ `app_settings` с кодом вида в имени | §5.10, §10.2.3 |
|
||
| D32 | Каталог видов отдаётся публичной ручкой с ETag; внутренние правила переходов в него не попадают | §9.2 |
|
||
| D33 | Валидация гостевых записей — триггер БД, выводящий требования из `cta_action`; перечисление кодов видов в `CHECK` запрещено | §10.4 |
|
||
| D34 | Audit: код кнопки — в metadata единого события `notification.button_pressed`, отдельного `event_type` на кнопку нет | §12 |
|
||
| D35 | Палитра — реестр `notification_color_tokens` с семантическими именами (`critical`, `warning`, `success`, `info`, `promo`, `neutral`) и FK из вида. **Значения цветов в БД не хранятся**: они живут в теме фронта парами для светлой и тёмной темы. Один токен может использоваться несколькими видами | §5.2, §5.2.1, §10.2.4 |
|