Skip to content

ТЗ — Внутренние уведомления (cms/notifications-inbox)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: freelance (freelance/project/src/app/Domains/Communication/) Статус: ТЗ к разработке

Назначение и возможности

Колокольчик уведомлений в Filament: список непрочитанных, группировка по типу/источнику, пометка прочитанным. Использует канал database шины cms/notifications-bus как хранилище.

  • Виджет-колокольчик с счётчиком непрочитанных в шапке админки
  • Группировка уведомлений по типу и источнику (модуль, событие)
  • Пометка одного/всех уведомлений прочитанными
  • Ссылка перехода на связанную сущность из уведомления
  • Ретеншн прочитанных уведомлений с автоочисткой
  • Фильтр по типу уведомления и периоду в полноэкранном списке

Зависимости и выключение

requires: cms/notifications-bus · suggests: cms/realtime (живое обновление счётчика непрочитанных без перезагрузки страницы)

Поведение при выключении: колокольчик и список уведомлений скрываются из интерфейса, сами уведомления продолжают создаваться через cms/notifications-bus (канал database) и не теряются — деградация UI, не потеря данных.

Стоимость внешних API: не применимо — inbox не вызывает сторонние API, читает только внутреннее хранилище cms/notifications-bus.

Модель данных

Своих таблиц нет — использует стандартную таблицу notifications канала database Laravel-нотификаций, управляемую cms/notifications-bus. Модуль не вводит собственных миграций; индексы под группировку/фильтрацию ((notifiable_id, read_at), (type, created_at)) — зона ответственности cms/notifications-bus.

ПДн-паспорт: inbox не создаёт и не хранит ПДн — таблицей канала database владеет cms/notifications-bus (см. блок «⚠️ Противоречие» в «Крайние случаи»). Модуль ОТОБРАЖАЕТ то, что в неё пишет владелец: data (json) уведомления может содержать имя получателя, детали заказа и любые персональные данные из payload резолвнутого события — де-факто инбокс работает как канал доставки ПДн получателя (пункт матрицы v2.2 для notifications-модулей). Срок хранения на стороне инбокса = notifications-inbox.retention_days (60 дней, только для прочитанных, см. «Настройки»); непрочитанные верхним ретеншном не ограничены, только аварийным max_items_per_user. Участие в «выгрузить всё по субъекту» / «забыть по запросу» (152-ФЗ): инбокс не владеет данными и сам не реализует выгрузку/анонимизацию — это делает cms/notifications-bus по user_id; обязательство инбокса — не показывать устаревшую персональную версию data дольше TTL кеша счётчика после того, как шина анонимизировала запись (тег notifications-inbox:user:<id>, короткий TTL + событийная инвалидация — см. «Производительность и кеш»; полноэкранный список вообще не кешируется, поэтому синхронизирован всегда).

Входные и выходные данные

Входы (whitelist-принцип: всё, что не перечислено, отвергается):

ИсточникДанные/поляЧем валидируется
database-канал cms/notifications-bus (сервис-вызов/чтение хранилища, канал 4 requires)notifiable_id, notifiable_type, type, data (json), read_at, created_atСхему и целостность контролирует cms/notifications-bus как владелец данных; inbox только читает, не пишет
API PATCH /notifications-inbox/{id}/readid уведомления (route param)FormRequest + Policy: уведомление принадлежит текущему пользователю (notifiable_id), иначе 404
API PATCH /notifications-inbox/read-allтело пустоеАутентификация + permission notifications-inbox.view
API GET списокfilter[type], filter[period_from], filter[period_to], cursorFormRequest whitelist фильтров/сортировки; неизвестный параметр → 422
Настройки (settings-store, группа notifications-inbox)enabled, group_by, retention_days, badge_max_count, max_items_per_user, default_enabled_typesСхема настроек (тип, дефолт, валидатор)

Выходы:

ПотребительДанныеФормат
Filament-виджет «Колокольчик»Счётчик непрочитанных, N последних уведомленийLivewire-компонент, число + короткий список
Filament-страница полноэкранного спискаСгруппированный список уведомлений (по group_by), фильтрыkeyset-пагинация, серверный рендер
API GET /api/v1/admin/notifications-inboxdata[] (id, type, source, read_at, выжимка data, ссылка на сущность)конверт {data, meta}, meta.next_cursor
Событие NotificationMarkedReadnotification_id, user_idдоменное событие самого модуля (издаёт inbox), слушатели — cms/realtime (если включён), аудит
cms/realtime (suggests)обновлённое значение счётчика непрочитанныхbroadcast через контракт realtime-transport

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

КлючТипДефолтaffectsPageCacheОписание
notifications-inbox.enabledbooltrueнетПоказ колокольчика и списка в админке
notifications-inbox.group_bystringtypeнетГруппировка списка (type/source)
notifications-inbox.retention_daysint60нетХранение прочитанных уведомлений
notifications-inbox.badge_max_countint99нетПорог отображения счётчика («99+»)
notifications-inbox.max_items_per_userint5000нетАварийный лимит хранимых уведомлений на пользователя; сверх лимита досрочно чистятся только прочитанные (kill-switch от неограниченного роста)
notifications-inbox.default_enabled_typesarray[] (все)нетWhitelist типов уведомлений, видимых в списке/фильтре по умолчанию

max_items_per_user + badge_max_count — это и есть покрытие пункта матрицы v2.2 «Лимиты и квоты» (лимит хранимых записей + порог отображения счётчика); отдельных настроек не требуется.

notifications-inbox.enabled — kill-switch показа UI (пункт матрицы v2.2 «Kill-switch»): отключает только отображение колокольчика/списка, не создание уведомлений в database-канале (см. «Зависимости и выключение»); отличается от kill-switch на стороне cms/notifications-bus, который может останавливать сам канал доставки.

API

МетодПутьДоступНазначение
GET/api/v1/admin/notifications-inboxadmin (notifications-inbox.view)Список уведомлений с группировкой
PATCH/api/v1/admin/notifications-inbox/{id}/readadmin (notifications-inbox.view)Пометить одно прочитанным
PATCH/api/v1/admin/notifications-inbox/read-alladmin (notifications-inbox.view)Пометить все прочитанными

Список уведомлений — keyset-пагинация (не OFFSET).

Ошибки эндпоинтов следуют единому конверту ядра {"error": {"type", "code", "message", "fields", "doc_url"}} (API-контракт, ревизия ядра 14.07.2026, п.1): 404 при чужом/несуществующем уведомлении → code: "notification_not_found"; 422 при неизвестном параметре фильтра списка → code: "unknown_filter"; клиенты ветвятся по code, не по тексту message.

Компоненты

Виджеты: Filament-виджет «Колокольчик уведомлений». Filament: полноэкранная страница списка с фильтрами. Команды: cms:notifications-inbox:cleanup --json.

Демо-сидер: наполняет инбокс 5–10 демо-уведомлениями разных типов, часть помечена прочитанной, часть — нет; обязателен для показа виджета «Колокольчик» и полноэкранного списка в playground/галерее без реальных бизнес-событий (матрица v2.2 «Демо-контент»).

События и обмен

СобытиеКогдаPayload
NotificationMarkedReadуведомление помечено прочитаннымnotification_id, user_id

Слушает: уведомления канала database из cms/notifications-bus (по requires) — единственный источник данных, собственных провайдеров уведомлений модуль не создаёт. FilterBus и provides-контракты не используются.

Инбокс не публикует и не потребляет persistent-роуты ядра (ревизия ядра 14.07.2026, п.14 — One-Click Unsubscribe и подобные комплаенс-роуты) — это зона cms/notifications-bus (владельца preferences/отписки); инбокс лишь отображает факт уведомления, не управляет подпиской на события.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
Ядро (LeadCreated, OrderPaid, PagePublished и т.п.)событие → резолвится cms/notifications-busin (косвенно)Ядро издаёт факт; inbox на события ядра не подписывается напрямую — их резолвит notifications-bus, и только попадание в database-канал делает запись видимой в инбоксе
cms/notifications-busпрямой сервис-вызов / чтение хранилища (канал 4, requires)inЕдинственный источник данных инбокса; владелец схемы database-канала
cms/notifications-busсобытие NotificationDelivered (channel = database)inТриггер инвалидации кеша счётчика непрочитанных (новая запись появилась)
cms/notifications-busсобытие NotificationMarkedRead (издаёт inbox)outФакт прочтения — шина может использовать для дедупликации digest/аналитики доставки
cms/realtime (suggests)provides-контракт realtime-transportoutЖивое обновление счётчика непрочитанных; без модуля — счётчик обновляется по перезагрузке/навигации
Filament (ядро)компонент (виджет «Колокольчик», страница списка)outРендер только через сервис модуля, не прямые запросы из Blade (§8 стандарта)

Фоновая работа

Собственной именованной очереди нет: пометка прочитанным — синхронная операция. Ретеншн прочитанных уведомлений — джоба автоочистки по notifications-inbox.retention_days через ScheduleRegistrar ядра.

Производительность и кеш

Ожидаемые объёмы: единицы-десятки уведомлений на активного пользователя в день в обычной инсталляции, всплески до сотен при массовых событиях (импорт, рассылка); десятки-сотни одновременно активных сотрудников в админке.

Горячие пути: счётчик непрочитанных рендерится Filament-виджетом «Колокольчик» в шапке каждой страницы админки — это самый частый запрос модуля, кратно превышающий частоту открытия полноэкранного списка. На нём модуль обязан выдерживать бюджет 0 запросов к БД на большинстве переходов (по аналогии с правилом §6/§10 стандарта для горячих путей) — без этого счётчик становится источником лишней нагрузки на каждый admin-запрос.

Что кешируется: счётчик непрочитанных на пользователя — тег notifications-inbox:user:<id>, короткий TTL (страховка, например 60 сек) плюс событийная инвалидация (не только TTL). Список уведомлений и полноэкранная страница не кешируются — читаются напрямую с keyset-пагинацией (свежесть важнее для операционного UI).

Инвалидация: по собственному событию NotificationMarkedRead (пользователь прочитал — счётчик уменьшился) и по событию NotificationDelivered от cms/notifications-bus с channel = database (появилась новая запись для этого получателя). Ручной flush всего кеша запрещён (§10 стандарта).

Бюджет запросов: рендер шапки — 0 доп. запросов при попадании в кеш счётчика; список уведомлений — 1 keyset-запрос без N+1 при резолвинге ссылок на связанные сущности.

Критичные индексы (зона ответственности cms/notifications-bus как владельца таблицы, но обязательны для эксплуатации инбокса): составной (notifiable_id, read_at) — выборка непрочитанных и сам счётчик; (notifiable_type, notifiable_id, created_at) или (type, created_at) — группировка/фильтр по типу и периоду в полноэкранном списке.

Ранбук (симптом → диагностика):

СимптомДействие
Счётчик непрочитанных не совпадает с фактическим числомПроверить инвалидацию кеша тега notifications-inbox:user:<id>; форс-сброс — событие NotificationMarkedRead/NotificationDelivered либо ручной flush тега
database-канал недоступен (health красный)Проверить доступность таблицы notifications и health cms/notifications-bus (cms:doctor --json)
Ретеншн не подчищает прочитанные записиРучной прогон cms:notifications-inbox:cleanup --json --dry-run — диагностика без удаления

Безопасность

Границы входа: весь API — только для аутентифицированного admin-пользователя под notifications-inbox.view; операции «пометить прочитанным» затрагивают только уведомления, принадлежащие текущему пользователю (notifiable_id проверяется в Policy, не по параметру запроса). Массовая операция «прочитать все» — batched update, не построчный цикл.

Матрица ролей:

РольДействиеДоступ
Любой аутентифицированный сотрудник админки с notifications-inbox.viewПросмотр/управление своим инбоксом (скоуп по notifiable_id)Разрешено
Тот же сотрудникПросмотр/управление чужим инбоксом (любой другой notifiable_id)Запрещено — нет параметра/роли, снимающих скоуп
studio / супер-админПросмотр списка уведомлений другого сотрудника через API/UI модуляЗапрещено в принципе — такой роли не существует

notifications-inbox.view по сути равнозначно «есть доступ к админке вообще», не отдельная привилегия — модуль не вводит градации прав внутри инбокса. Отсутствие роли с доступом к чужому инбоксу — осознанное архитектурное решение (приватность инбокса как личного канала сотрудника), не недосмотр; диагностика чужого инбокса при инциденте — не через модуль, а прямым доступом к БД (DBA), вне UI/API.

Конкретные векторы:

  • Доступ к чужому инбоксу через подмену id (IDOR) — последовательный/угадываемый id уведомления в URL позволил бы читать/помечать чужие записи; закрыто Policy на notifiable_id (см. выше); чужой id отдаёт 404, не 403 — не раскрывать сам факт существования уведомления другого пользователя.
  • Утечка ПДн в тексте уведомления — payload события может содержать email/телефон/ адрес; в логи, метрики и общую аналитику модуль передаёт только идентификаторы (notification_id, user_id), не содержимое data (§11 стандарта).
  • Отображение устаревших ПДн после анонимизации на стороне notifications-bus — шина анонимизирует записи журнала по user_id (право на забвение); инбокс не должен показывать прежнюю версию data дольше TTL кеша счётчика (notifications-inbox:user:<id>); полноэкранный список не кешируется и синхронизирован всегда (см. ПДн-паспорт в «Модель данных» и «Производительность и кеш»).
  • XSS в тексте уведомления, если он рендерится как HTML — обязательна санитизация тем же барьером rich-text ядра, что и везде; вывод — только , {!! !!} для пользовательских/шаблонных данных уведомления запрещён.

UX-требования

Админ / оператор модуля (настройка ретеншна, диагностика):

  • пустое состояние страницы настроек/журнала — подсказка «уведомления появляются автоматически при первом факте от notifications-bus», не голая пустая таблица;
  • массовые действия в полноэкранном списке: «отметить всё прочитанным», ручной запуск очистки (cms:notifications-inbox:cleanup --json) с подтверждением перед необратимым удалением;
  • человеческая ошибка при недоступности database-канала (health-чек красный) — баннер «уведомления временно не обновляются», а не молчаливо пустой список.

Пользователь (владелец инбокса — любой сотрудник с доступом в админку):

  • пустое состояние инбокса — иконка + текст «пока нет уведомлений» вместо пустого списка;
  • отметка прочитанным — по клику на уведомление (переход по ссылке = прочитано) и отдельным явным действием без перехода; сам факт открытия dropdown-колокольчика не помечает уведомления прочитанными автоматически;
  • массовое «прочитать всё» — одна кнопка в колокольчике и в полноэкранном списке, мгновенный отклик (optimistic UI) до подтверждения от API;
  • доступность: счётчик непрочитанных передаётся screen reader'у через aria-label (например «14 непрочитанных уведомлений»), не только визуальным badge; список и колокольчик доступны с клавиатуры (Tab/Enter), фокус возвращается в колокольчик после закрытия dropdown.

Крайние случаи и типовые баги

  • Ретрай job доставки на database-канале дублирует запись → идемпотентность обязательна: ключ (событие/event_id + получатель) уникален на стороне хранилища (владелец — notifications-bus); повторная попытка обновляет существующую запись, не вставляет вторую.
  • У уведомления несколько каналов доставки (например critical → email + database) → инбокс отображает уведомление независимо от статуса других каналов; факт «email уже отправлен» не скрывает и не блокирует появление в database-канале — inbox не знает про статусы других каналов, это зона notifications-bus.
  • Пользователь отписался от типа уведомлений во время идущей массовой рассылки → уже поставленные в очередь до отписки job'ы обрабатываются как раньше и создают запись (инбокс фиксирует факт, что уведомление было отправлено до отписки); новые постановки в очередь после отписки не создаются. ✅ Разрешено: политика зафиксирована в notifications-bus.md («Крайние случаи»: снимок получателей атомарен, перед отправкой каждого уведомления проверяется актуальный enabled; отписавшимся — skip без статуса failed).
  • Уведомление ссылается на удалённую сущность (страница/заказ удалены) → переход по ссылке отдаёт информативное сообщение «сущность больше не существует», не голый 404; ссылка резолвится заново на момент клика, не хранится как застывший URL.
  • Модуль выключен посреди начисления уведомлений → уведомления продолжают создаваться в database-канале (данные не теряются, владеет ими notifications-bus), только UI (колокольчик, список) скрыт; при повторном включении счётчик и список отражают всё накопленное за время простоя.
  • cms/realtime не установлен/выключен (suggests) → колокольчик не обновляется вживую; счётчик актуализируется при обычной навигации/перезагрузке страницы — деградация UI, не ошибка.
  • Огромное число непрочитанных у одного пользователя (тысячи) → полноэкранный список — keyset-пагинация с разумным лимитом на страницу, не «загрузить всё»; колокольчик показывает badge_max_count («99+»), не пытается отрендерить весь список в dropdown.
  • Параллельное «отметить прочитанным» с двух вкладок одного пользователя → операция идемпотентна на уровне записи (UPDATE ... WHERE read_at IS NULL), повторный клик из другой вкладки не производит побочных эффектов и не выбрасывает ошибку; счётчик в обеих вкладках сходится к одному значению после следующей синхронизации (realtime либо перезагрузка).
  • Ретеншн запускается во время активной работы пользователя со списком → удаляются только прочитанные записи старше retention_days; непрочитанные не удаляются независимо от возраста — иначе пользователь теряет уведомление, которое ещё не видел.
  • ✅ Разрешено (14.07.2026): стандартная Laravel-таблица notifications (notifiable, type, data json, read_at) внесена в модель данных cms/notifications-bus как таблица канала database: шина — владелец и писатель, этот модуль — читатель (§4 стандарта соблюдён — таблица принадлежит ровно одному модулю, и это не инбокс).

Донорский код

Что взятьПуть
Модель уведомлений и группировки для кабинетаfreelance/project/src/app/Domains/Communication/

Legacy-импорт не применим напрямую этому модулю: инбокс не владеет своими данными (таблица канала database принадлежит cms/notifications-bus), поэтому исторические уведомления клиента при миграции переносятся (если требуется) командой cms:notifications-bus:import-legacy на стороне шины, не инбокса.

Тесты и приёмка

  • [ ] Контрактный тест: счётчик непрочитанных совпадает с фактическим количеством в БД
  • [ ] Health-чек модуля проверяет доступность таблицы notifications
  • [ ] При выключении модуля уведомления продолжают создаваться, но не отображаются в UI
  • [ ] Пометка прочитанным не создаёт N+1 при массовой операции «прочитать все»
  • [ ] Ретеншн удаляет только прочитанные уведомления старше notifications-inbox.retention_days, непрочитанные не удаляются независимо от возраста
  • [ ] Право notifications-inbox.view разграничивает доступ к списку уведомлений
  • [ ] Повторный ретрай job доставки не создаёт дубль записи в database-канале (идемпотентность по событию + получателю)
  • [ ] Параллельная пометка прочитанным с двух запросов/вкладок не приводит к ошибке и не дублирует событие NotificationMarkedRead
  • [ ] Переход по ссылке на удалённую сущность отдаёт информативное сообщение, не 404 без объяснения
  • [ ] Доступ к чужому уведомлению по id отдаёт 404 (не 403), Policy проверена тестом
  • [ ] Счётчик непрочитанных инвалидируется по NotificationMarkedRead и по NotificationDelivered (channel = database) от cms/notifications-bus
  • [ ] ПДн в data уведомления перестают отображаться после анонимизации записи на стороне notifications-bus — синхронизация укладывается в TTL кеша счётчика/списка
  • [ ] Матрица ролей: ни одна роль (включая studio/супер-админа) не получает доступ к чужому инбоксу — только собственный notifiable_id, проверено тестом на уровне Policy
  • [ ] Демо-сидер создаёт корректный набор уведомлений (5–10 шт, разные типы, часть прочитана/часть нет) для колокольчика и полноэкранного списка в playground
  • [ ] Контрактный набор cms-testing пройден, пакет протестирован в testbench-изоляции
  • [ ] Feature-тест на каждый роут модуля; тестовая БД только notifications_inbox_test, migrate:fresh запрещён

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