diff --git a/backlog.md b/backlog.md index 0b2c434..999f899 100644 --- a/backlog.md +++ b/backlog.md @@ -29,7 +29,8 @@ ~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~ 3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?) 4. Моделирование профиля клиента/ -5. Моделирование уведомлений — постановка согласована v3 (G/P; D1–D13 закрыты): `busines_tasks/notification-requirements.md` → arch-00/01/02/04. +5. Моделирование уведомлений — постановка v6 (G/P; каталог видов как данные; D1–D34 закрыты, открытых вопросов нет): `busines_tasks/notification-requirements.md` → arch-00/01/02/03/04/05, module-01/03/07. +6. Реализация мнемоник. На анализ: debounce на отправку СМС (сейчас есть Фиксированный cooldownmin_seconds_between_attempts) \ No newline at end of file diff --git a/busines_tasks/notification-requirements.md b/busines_tasks/notification-requirements.md index 4eda9de..becb021 100644 --- a/busines_tasks/notification-requirements.md +++ b/busines_tasks/notification-requirements.md @@ -1,19 +1,21 @@ # Бизнес-постановка: Уведомления (Notification Center) -**Статус:** бизнес-постановка согласована (v3: D1–D13 закрыты) → вход в проектирование arch / OpenAPI / модули +**Статус:** v6 — каталог видов уведомлений переведён в данные (§5.2); все открытые вопросы закрыты; вход в проектирование OpenAPI и миграций **Продукт:** HAN Chat (клиентское приложение + `api-backend`) -**Источники:** макет Figma (**не канон** — только визуализация элементов UI, где применимо; при расхождении приоритет у этого ТЗ), черновик требований, архитектура `HAN_chat_specification` +**Источники:** макет Figma (**не канон** — только визуализация элементов UI; при расхождении приоритет у этого ТЗ), архитектура `HAN_chat_specification` (`arch-00`…`arch-05`, `module-01`) **Связанный backlog:** «Моделирование уведомлений»; смежно — кнопка «Позвонить оператору», кнопка «Войти»; **отдельно** — индикатор непрочитанных сообщений чата (не в scope этого ТЗ) +**Нормативная часть — §1–§16.** Открытых вопросов нет: все решения приняты и внесены в текст. §17 — ненормативный журнал решений; при расхождении с §1–§16 приоритет у §1–§16. + --- ## 1. Цель Дать клиенту единый канал сервисных и маркетинговых коммуникаций вне чата: срочные напоминания, статусы услуг, документы, оплаты, акции, а также подсказки в гостевой зоне (авторизация, установка PWA). -Чат остаётся каналом диалога с оператором; уведомления — канал «компания → клиент» с действиями (переход, оплата, загрузка/скачивание документов, переход в чат по акции). Непрочитанные ответы оператора в чате **не** моделируются как уведомления (см. §3.2). +Чат остаётся каналом диалога с оператором; уведомления — канал «компания → клиент» с действиями (переход, оплата, загрузка/скачивание документов, переход в чат по акции). Непрочитанные ответы оператора в чате **не** моделируются как уведомления (§3.2). -Гостевые `ads`/`promo` — не персонализированная реклама на клиента; отдельное маркетинговое согласие не требуется. Функционал описывается в Пользовательском соглашении. +Гостевые `ads_global`/`promo_global` — не персонализированная реклама на конкретного клиента; отдельное маркетинговое согласие не требуется. Функционал описывается в Пользовательском соглашении. --- @@ -21,18 +23,20 @@ | Контур | Кто видит | Источник данных | Frontend | |---|---|---|---| -| **G. Гостевые уведомления** | Только гость | Таблица гостевых уведомлений в App DB → **public API** | Единственный источник в гостевом режиме | -| **P. Персональные уведомления** | Только авторизованный | Таблица персональных (`user_id` NOT NULL) → **JWT API** | Единственный источник в ЛК | +| **G. Гостевые уведомления** | Только гость | `guest_notifications` → **public API** | Единственный источник в гостевом режиме | +| **P. Персональные уведомления** | Только авторизованный | `notifications` (`user_id` NOT NULL) → **JWT API** | Единственный источник в ЛК | Правила: -1. До авторизации: только контур **G** (ответ public API as-is). +1. До авторизации: только контур **G**. 2. После авторизации: только контур **P**. Гостевые **не** показываются и **не** переносятся в персональные. 3. Смешивать G и P в одной таблице / через `user_id IS NULL` — **запрещено**. -4. В v1 **нет** клиентских условий показа (ОС / PWA / прочие фильтры): фронт показывает список из API в рамках лимита и сортировки. Локальная генерация карточек на устройстве **запрещена**. -5. У фронта всегда ровно один источник: public API **или** JWT API. +4. Принадлежность контуру задаётся **видом** уведомления (`notification_types.contour`), а не признаком записи. Один вид принадлежит ровно одному контуру. +5. В v1 **нет** клиентских условий показа (ОС / установленность PWA / прочие фильтры): фронт отображает то, что вернул API. Локальная генерация карточек на устройстве **запрещена**. +6. **Лимит и сортировка применяются на сервере.** Клиент не досортировывает и не дообрезает список. +7. У фронта всегда ровно один источник: public API **или** JWT API. -Дубли тематики `ads`/`promo` в G и P допустимы; после входа виден только P. +Дубли тематики (акция и в G, и в P) допустимы — это разные виды и разные записи; после входа виден только P. --- @@ -41,26 +45,35 @@ ### 3.1. В scope - Контуры G и P (§2); персональный жизненный цикл: `lifecycle_status` / `visibility` / `is_read` / `close_reason` + `record_status` (arch-05). -- UI: карусель на главной, Центр уведомлений, деталка (где применимо). -- Каталог типов; публичный id — **UUID v7** (генерация в приложении). -- Клиентские действия (ЛК): крестик, CTA, деталка/оплата, done/got it, документы (`chat.attachments.*`). +- 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.*`. +- Константы `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). -- Жёсткая привязка «тип ↔ upstream-сервис». -- Профиль «Документы» / архив оплат — не заменяются уведомлениями. -- Запись в `sync_queue` из application-кода: только **триггер БД** + новый `task_type`; куда в Bitrix — ТЗ `bitrix-sync`. +- Админ-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 = N` в UI v1 — нет.** +- **Архив `lifecycle_status = closed` в UI v1 — нет.** Записи хранятся в БД бессрочно; ретенция и архивирование закрытых уведомлений не выполняются (§13). +- Пагинация Центра: сверх лимита показываются только приоритетные записи (§6.2). +- Внешние лендинги для рекламных видов: единственная внешняя страница — инструкция по установке (§6.4). - Поле `opened` / impression — **не моделируем** (CTA → `is_read`). +- Мультиязычность уведомлений: `header` / `text` приходят готовой строкой. Перевод на мнемоники — после запуска механизма `text_resources` (сейчас таблица пустая и приложением не используется). +- Валидация `notification_datetime` относительно текущего времени и отложенный показ (`date_visible_from`) — будущие доработки. --- @@ -72,9 +85,10 @@ | Верхнее меню | История чата | Центр уведомлений (колокольчик; бейдж только в ЛК) | | Главная | — | Карусель (лимит/сортировка §6.1) | | Центр | — | ЛК — список; гость — auth-gate + «Авторизоваться» | -| Деталка | — | Для типов с деталкой; гостевые `ads`/`promo` — **без** деталки | +| Деталка | — | Для видов с CTA `open_detail`; блоки и кнопки — из каталога (§5.6.1, §6.3) | +| Инструкция по установке | — | Модальное окно со страницей инструкции для `install_app` (§6.4) | -Визуал типов — по Figma, где применимо. Figma не канон поведения. +Визуал видов — по Figma. Figma не канон поведения. Палитра `color_token` (§5.2.1) и набор `icon_code` (§5.2) сверяются с Figma: имена токенов и кодов фиксируются в БД, значения цветов и сами SVG — в теме и коде фронта. --- @@ -83,155 +97,296 @@ ### 5.1. Слои контента 1. **Краткий** — баннер и строка списка: `header`, `text`, цена, CTA. -2. **Детальный** — `details.*`, документы, кнопки. Только если у типа есть деталка. +2. **Детальный** — деталка (блоки `details` по фиксированной схеме §5.6.1, документы, кнопки) у видов с CTA `open_detail`. У остальных видов второго слоя нет: CTA сразу выполняет действие (оплата, сообщение в чат, авторизация, установка приложения). -### 5.2. Справочник типов (**принято**) +### 5.2. Модель каталога: вид уведомления — данные, поведение — код -| Слой | Где | Содержимое | +**Добавление нового вида уведомления — строка в справочнике `notification_types`, без изменений кода.** Вид описывается набором обязательных атрибутов: контур, приоритет, `countable`, оформление (label, цвет, иконка, текст CTA), код CTA и набор кнопок деталки. Ни бэкенд, ни фронт не содержат ветвлений по коду вида. + +Каталог состоит из справочника видов и трёх реестров, на которые он ссылается (§10.2): + +| Справочник | Что задаёт | Расширяется | |---|---|---| -| Структурный | App DB `notification_types` | `code`, `priority`, `countable`, признак «есть деталка», `record_status` | -| Визуал | Frontend (тема/константы), сверка с Figma | Цвет, иконка, оформление | -| Тексты по умолчанию | `text_resources` | `label`, дефолтный CTA, empty states | +| `notification_types` | Вид уведомления: оформление, ссылка на CTA, кнопки деталки, правила | **Данными** — seed-миграция или строка в справочнике | +| `notification_cta_actions` | Перечень доступных механик CTA | **Только кодом**: новая механика — это реализация на бэкенде и фронте | +| `notification_buttons` | Кнопки деталки и их влияние на жизненный цикл | **Только кодом** по той же причине | +| `notification_color_tokens` | Палитра, допустимая для карточек (§5.2.1) | **Кодом**: новый токен появляется вместе со значениями в теме фронта | -В экземпляре **не** дублируются label/цвет/icon/countable/priority/action — только `notification_type` (FK/code). +Три реестра — это перечни того, что уже реализовано во фронте и бэкенде. Вид уведомления собирается ссылками на них, поэтому попытка сослаться на нереализованную механику, кнопку или цвет отбивается ссылочной целостностью, а не обнаруживается на проде. -### 5.3. Каталог типов +Кнопки вида хранятся **двумя ссылками в самой строке вида** (`button_primary_code`, `button_secondary_code`), а не связующей таблицей. Причина в том, что кардинальность здесь не «много ко многим», а жёстко фиксированные два слота, заданные макетом деталки: основное действие и второстепенное. Связующая таблица описывала бы более свободную структуру, чем существует в реальности, и за эту свободу пришлось бы платить: -| type | label | priority | countable | Action по CTA | Контур | +- **Инварианты ушли бы из БД.** «Не больше двух кнопок» и «у вида с деталкой минимум одна кнопка» — это условия на количество строк в группе, которые в `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` | Отправляет документы | |---|---|---|---|---|---| -| `authorize` | Гостевой режим | 2 | нет | Сценарий авторизации | G | -| `install_app` | Приложение | 2 | нет | Адаптивная установка PWA (§5.7) | G | -| `urgent` | Срочно | **1** | да | Детальный просмотр | P | -| `reminder` | Напоминание | 2 | да | Детальный просмотр | P | -| `news` | Новость | 2 | да | Детальный просмотр | P | -| `ads` | Предложение | 2 | да* | G: чат после auth; P: чат + закрытие (§7.2) | G и/или P | -| `promo` | Акция | 2 | да* | G: чат после auth; P: чат + закрытие (§7.2) | G и/или P | -| `docs_required` | Требуются документы | 2 | да | Деталка (+ загрузка) | P | -| `docs_ready` | Документы готовы | 2 | да | Деталка (+ скачивание) | P | -| `status_changed` | Статус | 2 | да | Детальный просмотр | P | -| `payment_pending` | Оплата | 2 | да | Переход по `payment_url` | P | +| `done` | Готово | — | — | `user_done` | нет | +| `later` | Сделаю позже | нет | нет | — | нет | +| `gotit` | Понятно | да | да | — | нет | +| `send_docs` | Отправить документы | — | — | `docs_submitted` | да | -\* Countable/read применяются только в контуре **P**. В **G** read-state нет. +Правила: -Типы `install_Android` / `install_IOS-HarmonyOS` / `message` — **отсутствуют**. +- Кнопка с непустым `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) и закрывает уведомление. Если когда-нибудь понадобится отправка документов без закрытия — это новая кнопка в справочнике, а не флаг у вида. -`priority`: меньше = важнее. Seed: `urgent = 1`, остальные = `2`. +Кнопки привязываются к виду **двумя слотами** в строке `notification_types` (§5.2): -CTA по умолчанию: - -- `authorize` — «Войти →» -- `install_app` — «Установить →» (на UI инструкции текст может быть «Как установить →») -- `urgent` / `reminder` / `news` / `status_changed` — «Подробнее →» -- `ads` / `promo` — «Узнать подробнее →» -- `docs_required` — «Загрузить документы →» -- `docs_ready` — «Скачать →» -- `payment_pending` — «Оплатить →» - -**Инвариант:** CTA у `docs_required` / `docs_ready` открывает **карточку**; upload/download — на карточке. -**Инвариант:** жёсткой привязки «сервис X → тип Y» нет. - -### 5.4. Поля экземпляра (контур P) - -| Поле | Обяз. | Описание | +| Слот | Смысл | Оформление | |---|---|---| -| `id` | да | UUID v7 (api-backend при Create) | -| `user_id` | да | `UserIdentity.id` NOT NULL | -| `notification_type` | да | Код из справочника | -| `notification_datetime` | да | UTC | -| `header` | да | Заголовок | -| `text` | нет | Подзаголовок | +| `button_primary_code` | Основное действие: «Готово», «Отправить документы», «Понятно» | Акцентная кнопка | +| `button_secondary_code` | Второстепенное действие: «Сделаю позже» | Второстепенная кнопка | -Опционально: +- **У вида с деталкой основной слот обязателен**, иначе карточку невозможно убрать с главной и она проживёт до `date_expired`. +- Второй слот без первого не заполняется, и одна кнопка не может занимать оба слота. +- У вида без деталки оба слота пусты: показать кнопки негде. -| Поле | Описание | -|---|---| -| `date_expired` | → `lifecycle_status=N`, `close_reason=expired` | -| `price` | Текущая цена (₽), опционально. Может быть без `old_price` | -| `old_price` | Старая цена (₽), опционально. Имеет смысл только вместе с `price` (зачёркнутая «было») | -| `payment_url` | Обязателен для `payment_pending` | -| `details` / `details.*` | Деталка (если нужна) | -| `details.deadline` | Срок на карточке | -| `send_documents` | UI загрузки клиентом | -| `documents[]` | Вложения компании в бакете **`han-chat-attachments`** (id объектов working S3) | -| `chat_message_text` | Текст в чат при CTA (`ads`/`promo` P — **обязателен**) | -| `button_done` / `button_gotit` / `button_gotit_text` | Кнопки деталки | -| `external_id` + `source` | Идемпотентность Create (§8) | +Все четыре правила — табличные `CHECK` (§10.2.3), а не соглашение и не тест. -### 5.5. Состояние экземпляра (контур P) +### 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) | -| `lifecycle_status` | `A` / `N` | `N` — нет в UI; в БД для аудита | -| `visibility` | `V` / `I` | `I` — нет на главной, есть в Центре (пока `lifecycle_status=A`) | -| `is_read` | да / нет | Для countable; бейдж = `A` ∧ countable ∧ ¬is_read | -| `close_reason` | см. ниже | При `A` → `N` | +| `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` | «Выполнено»; успех upload `docs_required`; Cancel после успешной оплаты | -| `expired` | `date_expired` | -| `cancelled` | Cancel (отзыв системой) | +| `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. +**`is_read` ≠ `visibility`:** прочтение влияет на бейдж, видимость — на главную. Правила — §7.1. -Контур G: per-user lifecycle / visibility / is_read **нет**. +Контур G: per-user `lifecycle_status` / `visibility` / `is_read` **отсутствуют**. У гостевой записи есть общий для всех гостей `lifecycle_status` (`active` / `closed`) и `record_status`. -### 5.6. Константы и мнемоники +### 5.8. Поля гостевого уведомления (контур G) -`notification.*` в `app_settings`. Вложения — ключи чата: +Гостевая запись описывает кампанию, общую для всех гостей. -- `chat.attachments.allowed_extensions` -- `chat.attachments.allowed_mime_types` -- `chat.attachments.disallowed_extensions` -- `chat.attachments.max_size_mb` -- `chat.attachments.presigned_upload_ttl_seconds` +| Поле | Обяз. | Тип | Описание | +|---|---|---|---| +| `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` | -Оператор: **`operator.call.phone`**. +У гостевых записей **нет**: `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 нет: гостевой режим — демонстрационный, длительного использования не предполагает. -Seed `notification.*`: - -| Ключ | Смысл | Default | -|---|---|---| -| `notification.gotit.default_ttl_days` | Got it без `date_expired` → now+N дней | `3` | -| `notification.docs_ready.default_ttl_days` | После скачивания документов, если `date_expired` пуст → now+N дней | `3` | -| `notification.home.max_items` | Лимит карусели | `15` | -| `notification.center.max_items` | Лимит Центра (ЛК) | `15` | -| `notification.carousel.autoplay_enabled` | `Y`/`N` | `N` | -| `notification.carousel.autoplay_interval_ms` | Интервал | `5000` | - -Мнемоники UI (`text_resources`) — **принято**: - -- `notification.center.empty.title` / `.text` — пустой Центр (ЛК) -- `notification.guest_center.title` / `.text` / `.cta` — гостевой Центр -- `notification.detail.not_found.title` / `.text` — 404 деталки («На главную») - -API при чужом/`N`/отсутствии → **404**; UI — нейтрально «Уведомление недоступно». - -### 5.7. Гостевые уведомления (контур G) - -- Одна таблица гостевых уведомлений; `record_status` + актуальность **`A`/`N`** (без per-user state). -- Public API отдаёт все активные (`A`); фронт **не фильтрует** по ОС/PWA в v1. -- Наполнение v1 — insert/SQL; public API — read-only. -- Типы в G: `authorize`, `install_app`, `ads`, `promo` (и при необходимости другие broadcast-типы позже). -- Крестика нет. Деталки у `ads`/`promo` в G **нет**. - -**`install_app` — один тип, адаптивный CTA (принято, технически возможно):** +### 5.9. `install_app` — адаптивный CTA 1. Пользователь нажал CTA. -2. Если среда поддерживает установку PWA (`beforeinstallprompt` / эквивалент) — запускаем установку. -3. Иначе — показываем инструкцию установки (локальный экран/модалка на фронте; отдельный тип в API не нужен). +2. Если среда поддерживает установку PWA (`beforeinstallprompt` или эквивалент) — запускается установка. +3. Иначе — открывается экран инструкции по адресу из `instruction_url` (§6.4). Отдельный вид уведомления для этого не нужен. -Fallback (если адаптивный CTA окажется нереализуем на конкретной платформе сборки): две записи в G (`install` для Android-потока и для iOS/Harmony) — только как запасной план реализации, не целевая модель данных. +Запись с `cta_action='install_app_prompt'` без `instruction_url` считается невалидной: шаг 3 стал бы тупиком. -**CTA прочих G:** +### 5.10. Константы и тексты -- `authorize` → сценарий авторизации. -- `ads` / `promo` → как «популярные вопросы»: старт auth; после успеха в чат уходит `chat_message_text` кампании от лица клиента. Кампания для других гостей остаётся `A`. +Правила вложений переиспользуются из чата (`chat.attachments.*`, arch-04): `allowed_extensions`, `allowed_mime_types`, `disallowed_extensions`, `max_size_mb`, `presigned_upload_ttl_seconds`. Отдельных лимитов у уведомлений нет. -### 5.8. Персональные (контур P) — кратко +Телефон оператора: `operator.call.phone`. -Создаются Internal **Create**; полная модель §5.4–5.5. Поведение CTA — §7. +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`. --- @@ -241,253 +396,746 @@ Fallback (если адаптивный CTA окажется нереализу **ЛК (P):** -- Выборка: `record_status=A`, `lifecycle_status=A`, `visibility=V`. -- Лимит `notification.home.max_items` (15). -- Сортировка: `priority` ↑, затем `notification_datetime` ↓. -- Крестик → `visibility=I` (синхрон на все устройства). -- Карусель: свайп; автопрокрутка по settings. -- CTA → action типа + эффекты §7.1. +- Выборка: `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):** -- Список = ответ public API; лимит 15; сортировка: `priority` ↑, затем datetime ↓ (поля сортировки — в таблице G / типе). -- Крестика нет. Один источник — public API. +- Выборка: `record_status='A'`, `lifecycle_status='active'`, `(date_expired IS NULL OR date_expired > now())`. +- Лимит `notification.home.max_items`, та же сортировка — применяет сервер. +- Крестика нет. -### 6.2. Центр +### 6.2. Центр уведомлений -**Гость:** колокольчик без бейджа; экран auth-gate + «Авторизоваться» (мнемоники §5.6). Списка нет. +**Гость:** колокольчик без бейджа; экран auth-gate с кнопкой «Авторизоваться». Списка нет. **ЛК:** -- `record_status=A`, `lifecycle_status=A` (в т.ч. `visibility=I`), лимит 15, без архива `N`. +- Выборка: `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. -- Сортировка: `priority` ↑ → непрочитанные выше → datetime ↓. -- Бейдж = непрочитанные countable (`lifecycle_status=A`); sync через бэкенд. -- Empty state — мнемоники §5.6. +- **Бейдж считается по тому же окну, что и список:** число непрочитанных countable среди первых `notification.center.max_items` записей выборки. Записи, не попавшие в окно, в бейдже не учитываются — бейдж и список всегда сходятся. +- Значение бейджа отдаёт бэкенд (§9.1) и обновляет по WS; локально фронт его не пересчитывает. +- **Пагинации нет — это осознанное решение.** Если активных уведомлений больше лимита, клиент видит только самые приоритетные, остальные недоступны до тех пор, пока верхние не будут закрыты или скрыты. Лимит здесь — техническая страховка: избыточное число одновременно активных уведомлений считается проблемой бизнес-логики продюсеров и решается на их стороне, а не прокруткой длинного списка. Метрика доли пользователей, упирающихся в лимит, — в §12. -### 6.3. Деталка (контур P; инструкция `install_app` — локальный UI) +### 6.3. Деталка -- SPA: `/notification/{uuid}`. -- Чужой / `N` / deleted → **404** (не 403). -- Upload: quarantine → Message Safety → working (`han-chat-attachments`) → **триггер БД** → `sync_queue`. -- Company `documents[]`: тот же бакет **`han-chat-attachments`**; preview/download + audit как в чате. -- `button_done` → `lifecycle_status=N`, `close_reason=user_done`. -- `button_gotit` → `visibility=I`; при пустом `date_expired` — now + `notification.gotit.default_ttl_days`; `is_read=true` (got it = действие пользователя, см. §7.1). -- Обе кнопки допустимы одновременно; исход по нажатой. Обе false → только back. +- 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. Завершение по типам (P) +### 6.4. Экран инструкции по установке -| Тип | → `lifecycle_status=N` | -|---|---| -| `payment_pending` | Cancel после callback оплаты, `close_reason=user_done` | -| `docs_required` | Успешный upload → `user_done` | -| Другой тип с `send_documents` | Upload сам не закрывает | -| `ads` / `promo` | CTA → `N` (§7.2) | -| `status_changed` | «Выполнено»; с главной уходит уже по CTA (`visibility=I`) | -| `docs_ready` | После скачивания — `I` + TTL/`date_expired` (§7.1); в `N` уходит по expire (или Cancel/done) | -| Прочие | done / expired / cancelled | +Единственный сценарий с внешней страницей. Применяется только к механике `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` — раздельно +### 7.1. `is_read` и `visibility` -**Правило прочтения (единое):** +**Правило прочтения:** > Любое **CTA** → `is_read = true` (идемпотентно). -CTA = первичное действие карточки: кнопка CTA на баннере, тап по строке Центра ведущий к action типа, «Оплатить», переход в деталку по CTA, отправка в чат по ads/promo. -Не CTA: системный back, крестик (крестик не ставит `is_read`). +CTA = первичное действие карточки: кнопка CTA на баннере, тап по строке Центра, ведущий к механике вида, переход в деталку по CTA, отправка сообщения в чат. +Не CTA: системный back, крестик и кнопки деталки — крестик `is_read` не ставит. -**Матрица `visibility` / `lifecycle` (избирательно):** +**Матрица не перечисляет виды: эффекты выводятся из атрибутов справочников.** Это и есть условие расширяемости данными — иначе каждый новый вид требовал бы новой строки в спецификации и в коде. -| Тип | Событие | `is_read` | `visibility` | `lifecycle_status` | +| Событие | Источник эффекта | `is_read` | `visibility` | `lifecycle_status` | |---|---|---|---|---| -| любой | Крестик на главной | — | `I` | — | -| любой с `button_gotit` | Got it | `true` | `I` (+ TTL если нужно) | — | -| любой с `button_done` | «Выполнено» | `true` | — | `N` / `user_done` | -| `urgent` | CTA → деталка | `true` | — | — | -| `reminder` | CTA → деталка | `true` | — | — | -| `status_changed` | CTA → деталка | `true` | `I` | — | -| `news` | CTA → деталка | `true` | `I` | — | -| `ads` / `promo` | CTA (чат) | `true` | `I` | `N` (+ сообщение в чат) | -| `docs_required` | CTA → деталка | `true` | — | — | -| `docs_required` | Успешный upload | `true` | — | `N` / `user_done` | -| `docs_ready` | CTA → деталка | `true` | — | — | -| `docs_ready` | Скачивание документов | `true` | `I` | — (+ TTL, см. ниже) | -| `payment_pending` | CTA «Оплатить» | `true` | `I` | — (далее Cancel→`N` при оплате) | +| Крестик на главной | фиксированное поведение | — | `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 | — | Пояснения: -- Прочерк «—» = поле этим событием не меняется. -- Для `ads`/`promo`: одновременно `is_read`, `visibility=I` и `lifecycle_status=N`. В UI запись исчезает из-за `N`; `I` фиксирует намерение «убрать с главной» на случай гонок/аудита. -- Открытие деталки из Центра по тапу строки = CTA → всегда `is_read=true`; `visibility` меняется только если тип/событие есть в матрице выше. -- `status_changed`: CTA → деталка сразу уводит карточку с главной (`visibility=I`); в Центре остаётся, пока `lifecycle_status=A`. -- `docs_ready`, скачивание документов: `is_read=true`, `visibility=I`; если `date_expired` пуст — выставить `date_expired = now + notification.docs_ready.default_ttl_days` (default 3). Если `date_expired` уже задан продюсером — **не перезаписывать**. Далее сработает общий expired-job → `lifecycle_status=N`, `close_reason=expired`. +- «—» = поле этим событием не меняется. +- **У вида с деталкой 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. `ads` / `promo` (P) +### 7.2. Механика `send_chat_message` (виды `ads_*` / `promo_*`) -1. CTA отправляет в чат `chat_message_text` (обязательное поле). -2. `is_read=true`, `visibility=I`, `lifecycle_status=N`. -3. Деталки у персональных ads/promo на MVP **нет** (симметрично G). +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 (**принято**) +### 7.3. Валидация Internal Create -| Тип | Обязательно | Не ожидается | Примечания | -|---|---|---|---| -| `urgent`, `reminder`, `news`, `status_changed` | `header`; `details` если нужна деталка | `payment_url` | `button_*` по необходимости | -| `ads`, `promo` | `header`; **`chat_message_text`** | `payment_url` | `price` и `old_price` — разные опц. поля; `price` без `old_price` допустим; `old_price` без `price` — нет; деталки нет | -| `docs_required` | `header`; `send_documents=true` | `payment_url` | | -| `docs_ready` | `header`; `documents[]` ≥1 | `payment_url` | | -| `payment_pending` | `header`; `payment_url` | — | `price` **не** обязателен; `details` **разрешены** | -| любой | `user_id`, `notification_type`, `source`, `external_id` | неизвестный тип / user | | +Правила выводятся из справочников, а не из перечня видов. Хардкод кодов видов в валидаторе запрещён — это ломало бы расширение данными. -Ошибка валидации → `400` (OpenAPI). +Общие для всех: -Типы контура G через Internal Create **не** создаются (только insert/public table). +| Обязательно | Запрещено | +|---|---| +| `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=A`) запись с той же парой → **409 Conflict** (контент не обновляется). | -| **Cancel** | `lifecycle_status=N` + `close_reason` (`cancelled` \| `user_done`). | -| **Смена контента** | Upsert / update content **нет**. Сценарий: **Cancel** старого → **Create** нового (новый `external_id` или та же пара после `N` — допустим Create, т.к. активной записи с ключом больше нет; политика ключа после `N` — в OpenAPI: разрешить Create с тем же ключом только если нет активной). | +| **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`. Кто закрывает: -| Сценарий | Кто | reason | +| Сценарий | Кто | `close_reason` | |---|---|---| -| Оплата успешна | Внешний → Cancel | `user_done` | -| Upload `docs_required` | api-backend | `user_done` | -| CTA ads/promo | api-backend по действию клиента | (закрытие как `user_done` или отдельный reason — по OpenAPI; default `user_done`) | -| «Выполнено» | Клиент | `user_done` | -| Отзыв | Внешний → Cancel | `cancelled` | -| TTL | Scheduler | `expired` | +| Оплата подтверждена | Внешний сервис → 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. Идентификация -- Id P и G — **UUID v7** (генерация приложением / seed-скриптом, не клиентом). -- SPA: `/notification/{uuid}` (только P; гостевая инструкция install — локальный роут без обязательного id в API). -- Deep link — с push (later). -- Идемпотентность Create — §7.4 (без upsert). +- Идентификаторы записей обоих контуров — **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 one / hide / mark-read·done·gotit / upload / counter — по §6–§7. -Владелец = `user_id` из JWT. Чужое/`N` → 404. +| Метод и путь | Назначение | +|---|---| +| `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` списка активных гостевых уведомлений (без JWT). +| Метод и путь | Назначение | +|---|---| +| `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 -- Path: **`/internal/notifications/v1/...`** -- Service token — как у прочих internal (arch-02). -- Callers: сервисы приватной сети облака (в т.ч. другие ВМ). Интернет — нельзя. -- Операции: **Create**, **Cancel** только. +- Мнемоника сервиса: **`notifications`**; путь `/internal/notifications/v1/...`. Мнемонику необходимо добавить в реестр arch-00. +- Аутентификация: `Authorization: Bearer ` — тот же стиль, что у остальных эндпоинтов, которыми владеет `api-backend` (`/internal/openlines/v1/inbox`, `/internal/settings/v1/otp`). Сравнение токена — constant-time. Заголовок `X-Service-Token` здесь не используется. +- **Токен выдаётся отдельно каждому продюсеру.** Хэш токена хранится в `notification_sources` (§10.9) и однозначно определяет `source` вызывающего. Правила авторизации: + - `source` в теле запроса обязан совпадать с `source`, к которому привязан токен; иначе `403 forbidden`; + - Cancel разрешён только по записям своего `source`; чужой ключ неотличим от несуществующего и даёт `404 not_found`; + - добавление продюсера — строка в `notification_sources` и одна env-переменная вида `NOTIFICATIONS_TOKEN_`; ротация токена не затрагивает остальных. +- Callers: сервисы приватной сети облака. Из интернета путь недоступен (nginx отдаёт `404`). +- Операции: -### 9.4. Realtime (**подписка**) +| Метод и путь | Операция | +|---|---| +| `POST /internal/notifications/v1/notifications` | Create (§7.4) | +| `POST /internal/notifications/v1/notifications/cancel` | Cancel по `{source, external_id, close_reason}` | -Желательно в релизе, иначе polling. +### 9.4. Realtime — обязателен -После connect на `WS /api/v1/realtime` клиент выполняет явный **`subscribe` на уведомления** (например `subscribe: { notifications: true }`), отдельно от `dialog_ids`. -События: `notification.created` / `notification.updated` / `notification.closed`. -Reconnect → reconciliation GET списком. Гостю WS для G не требуется. +После connect на `WS /api/v1/realtime` клиент подписывается на уведомления явно, тем же сообщением `subscribe`: -### 9.5. Готовность к Web Push +```json +{"type":"subscribe","dialog_ids":["uuid"],"notifications":true} +{"type":"subscribed","dialog_ids":["uuid"],"notifications":true} +``` -Стабильный UUID v7; события жизненного цикла; `header`/`text` для текста push; path `/notification/{uuid}`. Реализация push — вне scope. +Поле `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. Смежные сервисы +## 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); карусель; Центр; адаптивный `install_app`; Чат/Оператор | -| **api-backend** | Модель G+P; public/JWT/internal; scheduler expired; WS subscribe notifications; upload | -| **App DB** | Две таблицы; types+priority; триггер → `sync_queue`; seed | -| **nginx** | Internal не в интернет; rate limit; SPA | -| **Redis/WS** | События notification.* по подписке | -| **message-safety** | Как chat attachments | -| **bitrix-sync** | Новый task_type | -| **S3** | Бакет вложений уведомлений (клиент и компания): **`han-chat-attachments`** | -| **settings** | `notification.*`, `chat.attachments.*`, `operator.call.phone`, мнемоники | +| **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) модель + API + UI → (2) Internal Create/Cancel + seed G → (3) документы + триггер → (4) WS subscribe. +Порядок: (1) модель данных + четыре справочника каталога + Internal Create/Cancel → (2) клиентские API + публичный каталог видов + карусель и Центр + бейдж → (3) деталка, кнопки, экран инструкции, оплата → (4) документы: получение, черновики, отправка, триггер → (5) WS-подписка и события → (6) фоновые джобы (§11). + +Справочники идут первым шагом сознательно: если начать с UI, поведение неизбежно осядет в коде фронта, и вернуть его в данные будет уже дороже, чем заложить сразу. --- -## 11. Критерии приёмки +## 15. Влияние на arch-документы -1. Колокольчик; ЛК — бейдж непрочитанных, sync `is_read`/`visibility` между устройствами; гость — без бейджа, Центр = «Авторизоваться». -2. ЛК главная: лимит 15, sort priority→datetime, только `A`+`V`; крестик → `I`; `N` не в UI. -3. Гость: только public API, без клиентских фильтров ОС/PWA; один тип `install_app` с адаптивным CTA. -4. Типы каталога отличимы; `message` нет; после логина только P. -5. Деталка/кнопки по флагам; docs_* CTA → карточка; ads/promo P: чат + `is_read`+`I`+`N`. -6. Internal только Create/Cancel; дубликат активного `source`+`external_id` → 409; смена контента = Cancel+Create. -7. Expired → `N`, UI исчезает. -8. Upload → quarantine → working → триггер → `sync_queue`; company docs в `han-chat-attachments`. -9. Оператор — только `operator.call.phone`. -10. Константы из `app_settings`; UUID v7; 404 на чужое/`N`. -11. Валидация Create по §7.3. -12. WS: события после `subscribe` на notifications. -13. Push не обязателен; модель push-ready. -14. Архива `N` нет; поля `opened` нет. - ---- - -## 12. Решения D1–D13 (закрыты) - -| # | Решение | +| Документ | Что добавить или изменить | |---|---| -| **D1** | Два контура: **G** (всё через public API) и **P** (JWT). Без локальных карточек и без условий показа в v1. `install_app` — один тип, адаптивный CTA (установка / инструкция). Fallback — два баннера в данных, не целевая модель. | -| **D2** | Схема справочника §5.2 принята. | -| **D3** | Мнемоники empty/404 и поведение 404 приняты (§5.6). | -| **D4** | ads/promo CTA: `is_read` + `visibility=I` + `lifecycle_status=N` + чат. | -| **D5** | Любое CTA → `is_read=true`. `visibility` — только по матрице §7.1. | -| **D6** | Валидация §7.3: `chat_message_text` обязателен для ads/promo; `price` у payment необязателен; `details` у payment разрешены. | -| **D7** | `/internal/notifications/v1/...` + service token. | -| **D8** | WS: явная подписка на notifications. | -| **D9** | Бакет company/client файлов уведомлений: `han-chat-attachments`. | -| **D10** | `opened` / impression удалены; достаточно `is_read` от CTA. | -| **D11** | Upsert нет; механика **Cancel + Create**; конфликт активного ключа → 409. | -| **D12** | IDOR: единый 404. | -| **D13** | Одновременные done+gotit допустимы без доп. правил продюсеру. | +| `arch-00-glossary.md` | Сущности `Notification`, `GuestNotification`, `NotificationType`, `NotificationCtaAction`, `NotificationButton`, `NotificationColorToken`, `ClientDocument`; internal-мнемоника **`notifications`** в реестр; термины `lifecycle_status`, `visibility`, `close_reason`, `cta_action` | +| `arch-01-system-architecture.md` | Уведомления как домен `api-backend`; поток «продюсер → Internal Create → WS → клиент»; поток отправки документов клиентом; фиксация назначения `han-chat-documents` (документы компании из всех каналов) | +| `arch-02-api-contracts.md` | Пути §9.1–§9.3, включая публичный каталог видов и единый эндпоинт нажатия кнопки; расширение `subscribe` полем `notifications` и три новых WS-события; новые коды ошибок `notification_conflict`, `notification_closed`, `button_not_allowed`, `attachment_invalid`; `task_type` `document.client_uploaded`; правило дедупликации по бизнес-ключу как исключение из общей политики `Idempotency-Key`; **модель «токен на продюсера»** — первый internal-эндпоинт с несколькими токенами и разрешением идентичности вызывающего по токену | +| `arch-03-docker-compose-blueprint.md` | Env `NOTIFICATIONS_TOKEN_` — по одной переменной на продюсера; GRANT `bitrix_sync_user` на `client_documents` | +| `arch-04-settings-and-content.md` | Seed `notification.*` (§5.10) с флагами `is_public`; новые ключи `rate_limit.notification*`; фиксация `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, ожидаемое поведение при включении сервиса | --- -## 13. История решений (Q) +## 16. Критерии приёмки -| # | Решение | -|---|---| -| **Q1** | Непрочитанный чат / `message` — вне ТЗ. | -| **Q2** | `N` в БД, не в UI; архива v1 нет. | -| **Q3** | `payment_pending`: CTA «Оплатить» → `is_read`+`I`; деталка не обязательна. | -| **Q4** | Гостевые — отдельная таблица; insert v1. | -| **Q5** | Документы через S3; клиент quarantine→working. | -| **Q6** | `sync_queue` только триггером БД. | -| **Q7** | Лимит 15; sort priority→datetime. | -| **Q8** | Подсказки установки/входа — в контуре G через API (не локальный FE). | -| **Q9** | UUID v7; `/notification/{uuid}`. | -| **Q10** | `message` исключён. | -| **Q11** | Владелец = `user_id`. | -| **Q12** | `record_status` ≠ `lifecycle_status`. | -| **Q13** | `operator.call.phone`. | -| **Q14** | Got it = `I` (+TTL), не обязательно `N`. | -| **Q15** | Два контура G/P (вместо A/B/C). | -| **Q16** | Гостевой Центр = auth-gate. | -| **Q17** | Reuse `chat.attachments.*`. | -| **Q18** | Поле `opened` снято (D10). | -| **Q19** | Модель push-ready; push вне scope. | +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 |