Тема
ТЗ — Внутренние уведомления (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}/read | id уведомления (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], cursor | FormRequest 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-inbox | data[] (id, type, source, read_at, выжимка data, ссылка на сущность) | конверт {data, meta}, meta.next_cursor |
Событие NotificationMarkedRead | notification_id, user_id | доменное событие самого модуля (издаёт inbox), слушатели — cms/realtime (если включён), аудит |
cms/realtime (suggests) | обновлённое значение счётчика непрочитанных | broadcast через контракт realtime-transport |
Настройки (группа notifications-inbox)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
notifications-inbox.enabled | bool | true | нет | Показ колокольчика и списка в админке |
notifications-inbox.group_by | string | type | нет | Группировка списка (type/source) |
notifications-inbox.retention_days | int | 60 | нет | Хранение прочитанных уведомлений |
notifications-inbox.badge_max_count | int | 99 | нет | Порог отображения счётчика («99+») |
notifications-inbox.max_items_per_user | int | 5000 | нет | Аварийный лимит хранимых уведомлений на пользователя; сверх лимита досрочно чистятся только прочитанные (kill-switch от неограниченного роста) |
notifications-inbox.default_enabled_types | array | [] (все) | нет | 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-inbox | admin (notifications-inbox.view) | Список уведомлений с группировкой |
| PATCH | /api/v1/admin/notifications-inbox/{id}/read | admin (notifications-inbox.view) | Пометить одно прочитанным |
| PATCH | /api/v1/admin/notifications-inbox/read-all | admin (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-bus | in (косвенно) | Ядро издаёт факт; 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-transport | out | Живое обновление счётчика непрочитанных; без модуля — счётчик обновляется по перезагрузке/навигации |
| 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,datajson,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запрещён