Тема
ТЗ — Шина уведомлений (cms/notifications-bus)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★★ · Донор: freelance, notal Статус: ТЗ к разработке
Назначение и возможности
Центральный резолвер уведомлений: принимает типизированное событие, определяет получателей и по их предпочтениям и приоритету события выбирает каналы доставки. Сами каналы (почта, SMS, Telegram, push) предоставляются другими модулями через контракт notification-channel — шина не знает деталей транспорта, только маршрутизацию и очередь.
- Резолвинг «событие → получатели → каналы» с учётом preferences пользователя;
- приоритеты уведомлений (critical/normal/low), critical игнорирует «тихие часы»;
- реестр каналов через
provides: notification-channel(модули-поставщики ниже); - шаблоны сообщений с переменными (whitelist), общие для всех каналов одного события;
- digest-режим — агрегация низкоприоритетных уведомлений в одну сводку по расписанию;
- доставка только через очереди (
ShouldQueue), retry с backoff, dead-letter при провале; - журнал отправленных уведомлений на пользователя (статус доставки по каналу);
- страница предпочтений пользователя (какие события каким каналом получать).
Зависимости и выключение
requires: — · suggests: cms/sms, cms/telegram, cms/web-push, cms/messengers (каждый — поставщик канала через provides: notification-channel), cms/email-templates (HTML-рендер письма для email-канала; не установлен — используется собственный body шаблона из cms_notification_templates) · provides: — (нет; модуль не публикует provides-контракт, а реализует ядерный core-contract NotificationDispatch напрямую — см. «События и обмен»)
При выключении события продолжают диспатчиться, но не резолвятся в уведомления — получатели их не увидят ни на одном канале; данные (журнал, preferences) сохраняются.
Сама шина не тарифицируется, но резолвит вызовы платных каналов (cms/sms, cms/web-push, cms/telegram) — передаёт объём отправок по каналу в cms/health/метрики, чтобы стоимость провайдера (зона ответственности канала) можно было соотнести с объёмом через бас.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_notification_templates | event_class, channel, locale, subject, body, variables (json) | Шаблон на пару событие+канал |
cms_notification_preferences | user_id, event_class, channel, enabled (bool) | Персональные переключатели |
cms_notification_log | user_id, event_class, entity_id, channel, status, sent_at, error | Журнал доставки |
cms_notification_digests | user_id, channel, payload (json), scheduled_at | Очередь digest-агрегации |
notifications | стандартная Laravel-схема (notifiable, type, data json, read_at) | Канал database (инбокс): шина владеет таблицей и пишет в неё; cms/notifications-inbox — читатель (см. его ТЗ) |
variables/payload — JSON-столбцы с cast array; status — enum → PHP Enum. FK user_id — constrained()->index(). Индекс на cms_notification_log(user_id, channel, sent_at) под выборку журнала; уникальный составной индекс cms_notification_log(event_class, entity_id, user_id, channel) — ключ идемпотентности доставки (повторный прогон job не создаёт дубль записи, см. «Крайние случаи»); cms_notification_log растёт неограниченно — кандидат на BRIN по sent_at при больших объёмах.
Входные и выходные данные
Whitelist-принцип: событие вне whitelist источника и параметр API вне перечня ниже — отвергаются (не игнорируются молча). Whitelist «уведомляемых» событий в конфиге модуля-источника включает event_class + дефолтный приоритет и порядок каналов типа события (LeadCreated → [mail, telegram], OrderPaid → [sms, mail]) — дефолт до применения preferences, которые могут только сузить набор каналов, не расширить его сверх декларации события.
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Событие ядра/модуля (whitelist источника) | event_class, entity_id, payload сущности, user_id получателя(ей) | Whitelist «уведомляемых» событий в конфиге модуля-источника; payload — по контракту события |
PUT /api/v1/notifications-bus/preferences | event_class, channel, enabled (bool) | FormRequest, whitelist известных event_class/channel (пересечение зарегистрированных событий и активных notification-channel) |
| Filament: карточка шаблона | event_class, channel, locale, subject, body, variables | Filament-валидация полей + whitelist переменных на тип события; body — санитайзер rich-text (доверенный HTML только под studio-ролью) |
provides: notification-channel (ответ канала) | результат доставки (success/fail/error, код ошибки провайдера) | Контракт канала (DTO результата доставки) |
| Джоба digest (внутренний, из очереди) | накопленные low-priority уведомления пользователя за период | Внутренний источник — не внешняя граница, доп. валидации не требует |
cms:notifications-bus:digest:run (CLI) | опциональный флаг --user=, --dry-run | Валидация аргументов команды |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Модули-поставщики каналов (notification-channel: cms/sms, cms/telegram, cms/web-push, cms/messengers) | получатель, тема/тело (отрендеренный шаблон), метаданные события | DTO канала (recipient, subject, body, variables) по контракту |
cms/notifications-inbox (если установлен) | уведомление для личного кабинета | доставка как ещё один notification-channel (inbox) |
Админ (Filament/API GET /admin/notifications-bus/log) | журнал доставки, статистика по каналам | конверт {data, meta}, keyset-пагинация |
Пользователь (API GET /preferences) | текущие предпочтения по событиям/каналам | конверт {data, meta} |
| Подписчики шины событий ядра | NotificationResolved, NotificationDelivered, NotificationFailed | Payload события (см. «События и обмен») |
Настройки (группа notifications-bus)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
notifications-bus.default_channels | array | ["mail"] | — | Каналы по умолчанию для событий без preferences |
notifications-bus.digest_enabled | bool | true | — | Включить агрегацию low-priority в дайджест |
notifications-bus.digest_schedule | string | "daily" | — | Периодичность дайджеста (hourly/daily/weekly) |
notifications-bus.quiet_hours | array | [] | — | Диапазон тихих часов (не применяется к critical) |
notifications-bus.retry_attempts | int | 3 | — | Число повторов при отказе канала |
notifications-bus.critical_fallback_chain | array | [] | — | Каскад каналов для critical-приоритета после исчерпания retry_attempts основного канала (пусто = fallback выключен, все каналы шлются параллельно независимо друг от друга) |
notifications-bus.rate_limit_per_hour | int | 1000 | — | Лимит отправок в час на инсталляцию; превышение — постановка в очередь с задержкой, не отказ |
notifications-bus.log_ttl_days | int | 90 | — | Срок хранения cms_notification_log, старше — плановая очистка |
notifications-bus.kill_switch | bool | false | — | Аварийное отключение рассылки без выключения модуля: все вызовы NotificationDispatch уходят в log-fallback ядра |
В дайджест по умолчанию агрегируется только low-приоритет одного пользователя за период; normal — только при явном персональном предпочтении, critical — никогда. Одна доставка на пользователя за период — один вызов канала с агрегированным шаблоном, не N писем. quiet_hours — глобальный дефолт группы; cms_notification_preferences может хранить персональное переопределение (например, часовой пояс) — при наличии используется оно, иначе дефолт; critical игнорирует тихие часы в обоих случаях.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/notifications-bus/preferences | токен | Текущие предпочтения пользователя по событиям/каналам |
| PUT | /api/v1/notifications-bus/preferences | токен | Обновление предпочтений (whitelist событий) |
| GET | /api/v1/admin/notifications-bus/log | notifications-bus.manage | Журнал доставки (keyset-пагинация; фильтры: пользователь, канал, статус) |
| CRUD | /api/v1/admin/notifications-bus/templates | notifications-bus.manage | Управление шаблонами сообщений |
| POST | /api/v1/notifications-bus/unsubscribe/{token} | без токена пользователя (подписанный одноразовый/именной токен из ссылки письма) | One-Click Unsubscribe (RFC 8058) для email-канала |
Эндпоинт unsubscribe/{token} помечен ядром persistent — работает и при выключенном notifications-bus (обработка в очередь/лог ядра, отписка не теряется, т.к. письма уже содержат рабочую ссылку); см. ревизия ядра 14.07.2026, п.14. Ошибки всех эндпоинтов модуля (preferences, templates, unsubscribe) — конверт {message, code, errors} по конвенции ядра (там же, п.1): code: "unknown_event_class", code: "unsubscribe_token_invalid".
Компоненты
Filament: ресурс шаблонов уведомлений, журнал доставки с фильтрами и массовым действием «Повторить» на выбранных failed-записях (переиспользует ключ идемпотентности, не дублирует отправку), виджет статистики доставки по каналам. Команды: cms:notifications-bus:doctor --json (проверка регистрации каналов-поставщиков), cms:notifications-bus:digest:run --json (ручной прогон дайджеста). Сидер демо-данных — 2-3 демо-шаблона на разные каналы + демо-записи журнала для playground без реальной рассылки.
Ранбук (симптом → команда):
| Симптом | Действие |
|---|---|
| Все каналы события недоступны (health красный) | cms:notifications-bus:doctor --json — проверка регистрации notification-channel |
Очередь notifications-bus отстаёт | Проверить нагрузку/воркеры очереди |
| Дайджест не запускается по расписанию | Проверить ScheduleRegistrar/digest_schedule; ручной прогон cms:notifications-bus:digest:run --json |
Массовые NotificationFailed по одному каналу | Проверить провайдера канала (cms/sms/cms/telegram и т.п.) отдельно от баса |
События и обмен
| Событие | Когда | Payload |
|---|---|---|
NotificationResolved | После определения каналов для события | event_class, user_id, channels[] |
NotificationDelivered | Канал подтвердил доставку | notification_id, channel, status |
NotificationFailed | Все попытки канала исчерпаны | notification_id, channel, error |
Потребляет provides: notification-channel от модулей-поставщиков (cms/sms, cms/telegram, cms/web-push, cms/messengers) — резолвит их через DI на отправке. Сам модуль реализует ядерный core-contract NotificationDispatch (cms/core-contracts): любой модуль-потребитель (cms/api-tokens, cms/session, cms/domains и т.д.) вызывает NotificationDispatch::send(...), не зная о notifications-bus напрямую; ядро резолвит вызов в реализацию notifications-bus, если модуль включён, иначе — в log-fallback ядра (деградация без 500, см. «Зависимости и выключение»). Слушает: все доменные события ядра и модулей, помеченные как уведомляемые (whitelist в конфиге модуля-источника).
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро (LeadCreated, PagePublished, UserRegistered, SettingChanged…) | событие | in | Триггер резолвинга уведомления, если событие в whitelist «уведомляемых» источника |
Коммерческие/доменные модули (OrderPaid и т.п.) | событие | in | Аналогично; whitelist настраивается самим модулем-источником, не бусом |
cms/sms, cms/telegram, cms/web-push, cms/messengers | provides-контракт notification-channel | in | Шина резолвит доступные реализации через DI, вызывает send() каждого включённого пользователем канала |
cms/api-tokens, cms/session, cms/domains и др. потребители | core-contract NotificationDispatch | in | Модуль-потребитель вызывает NotificationDispatch::send(); при выключенном notifications-bus — log-fallback ядра |
cms/email-templates | сервис-вызов (suggests) | out | Рендер HTML-письма по шаблону; при отсутствии модуля — собственный body из cms_notification_templates |
cms/notifications-inbox | notification-channel (inbox) | out | Доставка «в личный кабинет» — ещё один канал-поставщик, не отдельная интеграция |
cms/health | health-чек + событие NotificationFailed | out | Алерт при исчерпании ретраев канала или отсутствии зарегистрированных notification-channel |
cms/audit (если включён) | событие | out | Аудит массовых рассылок/изменения шаблонов |
Очередь notifications-bus | очередь | out | Асинхронная доставка каждого уведомления отдельной идемпотентной джобой |
Фоновая работа
Очередь notifications-bus: доставка каждого уведомления — отдельная идемпотентная джоба (ShouldQueue, retry с backoff, dead-letter при исчерпании попыток). Расписание digest-агрегации (digest_schedule) регистрируется через ScheduleRegistrar ядра. Все вызовы каналов — только из очереди, никогда синхронно в HTTP-потоке.
Производительность и кеш
Ожидаемые объёмы: типичный поток — десятки-сотни уведомлений в час на среднем проекте (лиды, регистрации, статусы заказов); пиковая нагрузка — массовая рассылка/digest тысячам получателей разом (кампания, дайджест по расписанию) — критичный сценарий для батчинга, не для бюджета обычного запроса.
Горячие пути и бюджет запросов: резолвинг одного события → preferences получателя (batch-загрузка по user_id, не N+1) + выбор шаблона (из кеша группы) — не более 2 запросов к БД на событие; выбор канала-поставщика — резолв DI-контейнера, без БД; сама доставка — вне бюджета HTTP-потока (только в очереди, синхронного пути нет).
Критичные индексы: cms_notification_preferences(user_id, event_class, channel) unique — под резолвинг без N+1; cms_notification_log(user_id, channel, sent_at) — под журнал; cms_notification_log(event_class, entity_id, user_id, channel) unique — под идемпотентность доставки; cms_notification_digests(scheduled_at) — под выборку шедулером.
Кешируется: настройки маршрутизации (default_channels, quiet_hours, critical_fallback_chain и остальная группа notifications-bus — 0 запросов на горячем пути, канон §6 стандарта); шаблоны сообщений по ключу event_class + channel + locale.
Теги кеша и инвалидация: notifications-bus.templates — сброс afterSave() в Filament-ресурсе шаблонов; notifications-bus.preferences:{user_id} — сброс при обновлении preferences через API. Журнал (cms_notification_log) не кешируется — всегда актуален. На page-cache сайта модуль не влияет — контур только доставки, не публичного рендера.
Безопасность
Входные границы: API preferences (FormRequest-whitelist событий/каналов), Filament (редактирование шаблонов — доверенный HTML только под studio-ролью), очереди (доставка без внешнего входа). Переменные шаблона подставляются строго по whitelist на тип события — без eval/произвольных выражений; значения экранируются под контекст канала (HTML-экранирование для email, plain-text для SMS/push) — иначе пользовательский ввод (например, имя в форме) превращается в HTML/script-инъекцию в письме. Rate-limit на API preferences — по общему лимиту API ядра; ручной запуск массовой рассылки/дайджеста (cms:notifications-bus:digest:run, Filament-действие) — только под notifications-bus.manage, чтобы нельзя было абьюзить бус как инструмент спама по чужим данным.
Утечка ПДн: cms_notification_log.error не должен содержать email/телефон получателя в открытом тексте ошибки — только user_id и код ошибки провайдера (канон §11 стандарта: логи и аналитика без ПДн). Метрики/алерты cms/health по каналу — тоже без адресов.
Права: notifications-bus.view, notifications-bus.manage, notifications-bus.preferences.manage.
Матрица ролей:
| Роль | Действие |
|---|---|
| Гость/пользователь | читает и правит только свои preferences (notifications-bus.preferences.manage, скоуп по user_id) |
| Менеджер | просмотр журнала доставки (notifications-bus.view) |
| Редактор | управление шаблонами сообщений (notifications-bus.manage), без доступа к ручному запуску дайджеста на весь сегмент |
| Studio | + доверенный HTML в body шаблона, ручной запуск массовой рассылки/дайджеста |
ПДн-паспорт: хранит user_id (ссылка, не сами ПДн), тексты писем/сообщений (могут содержать данные из payload события — имя, детали заказа) в cms_notification_log и cms_notification_digests. Ретеншн — notifications-bus.log_ttl_days (дефолт 90 дней). Участвует в «выгрузить всё по субъекту» (журнал по user_id) и «забыть по запросу» (анонимизация записей журнала при удалении пользователя ядром, сами события/факты не удаляются — только персонализированные поля).
UX-требования
Админ:
- Пустое состояние: ни одного зарегистрированного
notification-channel— баннер в журнале и на виджете статистики «Не подключен ни один канал доставки» со ссылкой наsuggests-модули (cms/sms,cms/telegram,cms/web-push,cms/messengers); - журнал доставки — фильтр по статусу (
sent/failed/pending) и каналу, поиск по пользователю/событию; - массовое действие в журнале: «Повторить» для выбранных
failed-записей — ставит новую джобу с тем же ключом идемпотентности (не создаёт дубль, обновляет статус существующей записи); - человеческие ошибки: «провайдер SMS не отвечает» вместо кода исключения драйвера; «шаблон ссылается на несуществующий раздел» вместо стектрейса рендера;
- подтверждение необратимых операций: ручной запуск дайджеста/массовой рассылки на большой сегмент (
cms:notifications-bus:digest:run, Filament-действие) — модальное окно с оценкой числа получателей перед стартом.
Посетитель/пользователь:
- Страница предпочтений — сохранение с явным подтверждением («Сохранено»), при ошибке API форма не теряет выбранные переключатели;
- отправка уведомления асинхронна — основной сценарий пользователя (оплата, отправка формы) не ждёт завершения рассылки и не падает при недоступности канала.
Крайние случаи и типовые баги
- Дубль уведомления при ретрае job → идемпотентность по составному ключу
event_class+entity_id+user_id+channel(уникальный индексcms_notification_log, см. «Модель данных»): повторный прогон job на уже обработанном ключе обновляет существующую запись, не создаёт вторую отправку. - Каналы-приоритеты → решение: по умолчанию все включённые в preferences каналы события отправляются параллельно (fan-out), без автоматического fallback между разными типами каналов. Для
critical-приоритета доступен явный опциональный каскад через настройкуnotifications-bus.critical_fallback_chain— следующий канал пробуется только после исчерпанияretry_attemptsтекущего. - Пользователь отписался посреди выполнения batch/дайджеста → снимок получателей снимается атомарно при постановке batch, но перед отправкой каждого уведомления проверяется актуальный
enabledв preferences: отписавшимся после старта — не отправлять (skip + запись в лог без статусаfailed), уже отправленным — не повторять и не отзывать. - Шаблон ссылается на удалённую сущность / ошибка рендера письма (в т.ч. через
cms/email-templates) → падение рендера одного письма не блокирует всю рассылку: джоба этого получателя переходит вfailedсNotificationFailed, остальные получатели обрабатываются независимо (каждый — отдельная идемпотентная джоба). - Выключение модуля посреди рассылки → уже взятые воркером джобы довыполняются; новые вызовы
NotificationDispatchпосле выключения резолвятся в log-fallback ядра — получатель уведомление не увидит, но вызывающий код не падает (см. «Зависимости и выключение»). - Отсутствие suggests-модуля-поставщика канала (например, включён только
mail,cms/smsне установлен) → resolve канала для preference сsmsвозвращает пусто: записьNotificationFailedсerror = "channel_unavailable", остальные каналы пользователя доставляются штатно; если недоступны все каналы события — health-чек модуля красный (см. «Тесты и приёмка»). - Сбой/таймаут внешнего провайдера транспорта на части получателей → каждая доставка — отдельная идемпотентная джоба с собственным retry/backoff: сбой одного получателя не влияет на остальных, рассылка продолжается.
- Огромный сегмент получателей → резолвинг и постановка доставок — батчами (
Bus::batchчанками, канон §9 стандарта), не разовый цикл на весь сегмент; digest-агрегация выбирается изcms_notification_digestsтоже батчами поscheduled_at. - Противоречивые настройки (канал включён в preferences, но его провайдер не настроен/выключен) → доставка по этому каналу невозможна:
NotificationFailedс понятной причиной в журнале, остальные каналы пользователя не затрагиваются — ошибка не блокирует событие целиком. - Дайджест попадает в тихие часы →
scheduled_atв диапазонеquiet_hoursне отменяет отправку, а сдвигает её на конец окна тихих часов (кромеcritical, который дайджестом не агрегируется по определению). - Нет шаблона на локали получателя → fallback на дефолтную локаль сайта (
RequestContext/настройкиlocaleядра), не на хардкод конкретного языка. - Клик unsubscribe после отключения модуля → persistent-роут ядра (ревизия ядра 14.07.2026, п.14) принимает запрос независимо от состояния notifications-bus: отписка уходит в очередь/лог ядра и применяется к preferences при следующем включении модуля (либо напрямую в БД, если схема ядра это позволяет) — запрос не отвечает 404/503.
- Уточнение контракта
NotificationDispatch: исходная версия ТЗ объявлялаprovides: "notification-bus"с прямым потреблением модулямиcms/api-tokens,cms/session,cms/domains. В каноническом реестреprovides(standard.md, §2) такого имени нет — точка расширения ядра «отправить уведомление» это интерфейсNotificationDispatch(cms/core-contracts), который ядро резолвит в реализацию notifications-bus при включённом модуле и в log-fallback при выключенном (см. ревизия ядра 14.07.2026). Решение: манифестprovidesмодуля пуст, реализация биндится черезServiceProvider::register(); разделы «Зависимости и выключение» и «События и обмен» приведены в соответствие.
Донорский код
| Что взять | Путь |
|---|---|
| Домен коммуникаций (резолвер, шаблоны, каналы) | freelance/project/src/app/Domains/Communication/ |
| Паттерны digest/preferences | notal (проектная документация модуля уведомлений) |
Легаси-импорт (не блокер приёмки без донора у клиента): cms:notifications-bus:import-legacy --source=<профиль> [--dry-run] идемпотентно мапит исторический журнал/preferences клиента в cms_notification_log/cms_notification_preferences по ключу event_class+entity_id+user_id+channel.
Тесты и приёмка
- [ ] Контрактные тесты резолвера: событие → корректный набор каналов по preferences и приоритету;
- [ ] идемпотентность: повторный прогон доставки на том же ключе (
event_class+entity_id+user_id+channel) не создаёт вторую запись вcms_notification_log; - [ ]
critical_fallback_chain: следующий канал пробуется только после исчерпанияretry_attemptsтекущего, для обычного приоритета каналы шлются параллельно; - [ ] частичный сбой batch/дайджеста: ошибка рендера/доставки одного получателя не прерывает обработку остальных (контрактный тест на digest и массовую рассылку);
- [ ] большие сегменты: резолвинг и постановка доставок идут батчами (
Bus::batchчанками), тест на объём получателей выше одного чанка; - [ ]
kill_switch: включение переводит все вызовыNotificationDispatchв log-fallback без выключения модуля; - [ ] health-чек модуля проверяет наличие хотя бы одного зарегистрированного
notification-channel; - [ ] деградация при выключении — события не падают с ошибкой, просто не уведомляют;
- [ ] права на управление шаблонами и просмотр журнала разделены (матрица ролей выше);
- [ ] нет N+1 при резолвинге получателей (batch-загрузка preferences);
- [ ] инвалидация кеша шаблонов и preferences при сохранении/обновлении;
- [ ] контрактный набор
cms-testingзелёный, пакет протестирован в testbench-изоляции; - [ ] feature-тест на каждый роут API; тестовая БД только
notifications-bus_test,migrate:fresh/refresh/resetзапрещены; - [ ] контрактный тест ядра (не в testbench-изоляции модуля): unsubscribe-роут отвечает 200 при выключенном notifications-bus (persistent-роут);
- [ ] дайджест агрегирует только low-приоритетные уведомления одного пользователя в одну доставку за период (
normal— только при персональном предпочтении); - [ ] персональные тихие часы пользователя переопределяют глобальные,
criticalигнорирует тихие часы в обоих случаях; - [ ] whitelist
event_class→ дефолтные каналы сужается preferences, но не расширяется сверх декларации события.