Skip to content

ТЗ — Шина уведомлений (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_templatesevent_class, channel, locale, subject, body, variables (json)Шаблон на пару событие+канал
cms_notification_preferencesuser_id, event_class, channel, enabled (bool)Персональные переключатели
cms_notification_loguser_id, event_class, entity_id, channel, status, sent_at, errorЖурнал доставки
cms_notification_digestsuser_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_idconstrained()->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/preferencesevent_class, channel, enabled (bool)FormRequest, whitelist известных event_class/channel (пересечение зарегистрированных событий и активных notification-channel)
Filament: карточка шаблонаevent_class, channel, locale, subject, body, variablesFilament-валидация полей + 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, NotificationFailedPayload события (см. «События и обмен»)

Настройки (группа notifications-bus)

КлючТипДефолтaffectsPageCacheОписание
notifications-bus.default_channelsarray["mail"]Каналы по умолчанию для событий без preferences
notifications-bus.digest_enabledbooltrueВключить агрегацию low-priority в дайджест
notifications-bus.digest_schedulestring"daily"Периодичность дайджеста (hourly/daily/weekly)
notifications-bus.quiet_hoursarray[]Диапазон тихих часов (не применяется к critical)
notifications-bus.retry_attemptsint3Число повторов при отказе канала
notifications-bus.critical_fallback_chainarray[]Каскад каналов для critical-приоритета после исчерпания retry_attempts основного канала (пусто = fallback выключен, все каналы шлются параллельно независимо друг от друга)
notifications-bus.rate_limit_per_hourint1000Лимит отправок в час на инсталляцию; превышение — постановка в очередь с задержкой, не отказ
notifications-bus.log_ttl_daysint90Срок хранения cms_notification_log, старше — плановая очистка
notifications-bus.kill_switchboolfalseАварийное отключение рассылки без выключения модуля: все вызовы 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/lognotifications-bus.manageЖурнал доставки (keyset-пагинация; фильтры: пользователь, канал, статус)
CRUD/api/v1/admin/notifications-bus/templatesnotifications-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/messengersprovides-контракт notification-channelinШина резолвит доступные реализации через DI, вызывает send() каждого включённого пользователем канала
cms/api-tokens, cms/session, cms/domains и др. потребителиcore-contract NotificationDispatchinМодуль-потребитель вызывает NotificationDispatch::send(); при выключенном notifications-bus — log-fallback ядра
cms/email-templatesсервис-вызов (suggests)outРендер HTML-письма по шаблону; при отсутствии модуля — собственный body из cms_notification_templates
cms/notifications-inboxnotification-channel (inbox)outДоставка «в личный кабинет» — ещё один канал-поставщик, не отдельная интеграция
cms/healthhealth-чек + событие NotificationFailedoutАлерт при исчерпании ретраев канала или отсутствии зарегистрированных 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/preferencesnotal (проектная документация модуля уведомлений)

Легаси-импорт (не блокер приёмки без донора у клиента): 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, но не расширяется сверх декларации события.

Внутренняя база знаний студии