Архитектура уведомлений и напоминаний
Каждое сообщение, которое участник церкви видит вне страницы, на которую он сейчас смотрит, — счётчик значка, push-уведомление, дайджест по email — проходит через одну из двух дверей в MessagingApi. Эта страница описывает воронку, движок напоминаний, который питает её по расписанию, и модель предпочтений, которая решает, что на самом деле доходит до человека.
Обзор — две двери
scheduled anything ──▶ ReminderEngine (definitions → occurrences → scan) ─┐
chat / requests / workflow / bulk sends ──────────────────────────────────┼─▶ createNotifications()
│ in_app gate → socket → push → email (→ sms slot)
account/legal mail ──▶ TransactionalEmailHelper.sendTransactional() [allowlisted, lint-enforced]
- Всё, что сообщает человеку что-либо, проходит через
NotificationHelper.createNotifications()в модуле messaging. Он сохраняет строкуnotificationsи эскалирует по цепочке сокет → push → email, оцениваяPreferenceGateHelperдля каждого канала — включаяin_appна уровне 0. - Всё запланированное — это
reminderDefinition(на уровне сущности или на уровне области), разворачиваемое вreminderOccurrencesи отправляемоеReminderEngine.scan()по повторяющемуся таймеру. Один разворачиватель, один диспетчер, один журнал отправок (reminderSentLog). - Прямая отправка email существует только за
TransactionalEmailHelper.sendTransactional(). Правило ESLint обеспечивает это на этапе компиляции — см. ниже.
Api/tools/eslint-rules/email-door.cjs определяет no-direct-email-helper: любой вызов EmailHelper.sendTemplatedEmail() или EmailHelper.sendEmail() вне NotificationHelper.ts или TransactionalEmailHelper.ts проваливает линтинг. Если вам нужно отправить email, направьте его через воронку (createNotifications с emailImmediate) или через TransactionalEmailHelper.sendTransactional() — третьего пути, проходящего CI, не существует.
Воронка уведомлений
NotificationHelper.createNotifications() — единая точка входа для всего, что не запланировано и не транзакционно:
createNotifications(
peopleIds: string[],
churchId: string,
contentType: string,
contentId: string,
message: string,
link?: string,
triggeredByPersonId?: string,
options?: {
deliveryStartLevel?: number; // 0 socket (default), 1 push, 2 email-only
category?: string; // preference axis; derived from contentType if omitted
emailByPerson?: Record<string, { subject: string; html: string }>;
emailImmediate?: boolean; // send email now instead of waiting for the digest
}
)
Для каждого получателя сохраняется строка в notifications и вызывается attemptDeliveryWithEscalation, который проходит по цепочке каналов ниже. Всё ещё непрочитанная строка для той же пары (contentType, contentId) подавляет повторное создание — эта защита от дублирования пропускается для отправок emailImmediate (сдвиги напоминаний, «отправить всем» персонала, шаги рабочего процесса имеют собственную дедупликацию) и для прямых сообщений, которые всегда пингуют сокет.
shared/helpers/NotificationService.ts отражает ту же сигнатуру (NotificationServiceOptions) для вызывающих сторон вне модуля messaging и регистрируется вместе с модулем messaging при загрузке.
Цепочка эскалации по каналам
Доставка начинается с определённого уровня (0 по умолчанию, либо выше для напоминаний/явных отправок) и переходит к следующему каналу, только если предыдущий не удался. Каждый уровень проверяется PreferenceGateHelper, прежде чем что-либо предпринимается.
| Уровень | Канал | Поведение |
|---|---|---|
| 0 | in_app / socket | Сначала проверяется шлюз in_app. Если подавлено (заглушено), строка сохраняется с isNew=false, и доставка полностью останавливается — ни пинга сокета, ни значка, ни дальнейшей эскалации. В противном случае сервер ищет открытые сокет-соединения для комнаты alerts человека и отправляет фрейм notification (или privateMessage). Для обычных уведомлений успешная доставка через сокет останавливает цепочку здесь — 30-минутный таймер позже перепроверяет непрочитанные элементы и эскалирует их. Прямые сообщения никогда не останавливаются на сокете: установленное PWA может держать сокет alerts открытым в фоне, что иначе подавило бы push на уровне ОС. |
| 1 | push | Ограничивается через allowPush / отказ от категории / тихие часы. Отправляется как на токены Expo push, так и на подписки Web Push, найденные в строках devices человека, с дедупликацией по конечной точке и удалением устаревших токенов по пути. |
| 2 | Ограничивается через emailFrequency и отказ от категории. Немедленные отправки (emailImmediate) рендерятся сразу и записывают строку deliveryLogs; в противном случае уведомление остаётся в ожидании пакетного дайджеста, описанного ниже. | |
| — | sms | Инфраструктура предпочтений (allowSms, списки каналов по категориям) уже учитывает канал SMS, но сегодня ни один производитель не отправляет через него — он остаётся зарезервированным для продукта массовых SMS, который работает как отдельный, изолированный поток через TextingController / @churchapps/texting. |
Непрочитанные уведомления, оставшиеся на уровне сокета или push, эскалируются 30-минутным таймером (NotificationHelper.escalateDelivery). Пакетная почта отправляется NotificationHelper.sendEmailNotifications(frequency), управляемым предпочтением emailFrequency каждого человека: individual запускается 30-минутным таймером, daily — ночным таймером. (weekly — допустимое значение предпочтения, но выделенного пакетного запуска для него пока нет.)
Движок напоминаний
Запланированные напоминания — напоминания о мероприятиях, сроки задач, напоминания о назначениях на служение/план — все проходят через один обобщённый движок, а не через специальную cron-логику для каждой функции.
reminderDefinitions ──expand──▶ reminderOccurrences ──scan (30 min)──▶ createNotifications()
│ │ │
▼ ▼ ▼
entity- или scope-level one строка per (definition, deliveryStartLevel: 1
offsets/channels/message entity, occurrence, offset) + reminderSentLog ledger
Определения (reminderDefinitions) бывают либо на уровне сущности (задан entityId — конкретное мероприятие, задача или план), либо на уровне области (entityId пуст, задан scopeId — например, каждый план под определённым типом плана служения). Определение несёт CSV минутных сдвигов (offsets, например "1440,60" для одного дня и одного часа заранее), локальное время отправки (sendLocalTime), CSV каналов (channels — включение email вызывает немедленное развёрнутое письмо в момент отправки), recipientMode и опциональное пользовательское message.
Разворачивание материализует строки срабатывания на предстоящий горизонт (скользящее многодневное окно). Оно запускается ночным таймером и синхронно при каждом сохранении определения, поэтому напоминание для мероприятия в последнюю минуту всё равно срабатывает. Определения на уровне области разворачиваются через loadScopeEntities адаптера, производя один набор срабатываний на каждую конкретную сущность; срабатывания на уровне сущности используют ключ definitionId:occurrenceISO:offset, а срабатывания на уровне области помечены пространством имён по id сущности, чтобы они никогда не сталкивались. Upsert срабатывания воскрешает ранее отменённую строку — «отменить, затем развернуть заново» является стандартным способом пересинхронизировать напоминание после изменения базовой сущности; строки, уже помеченные sent, failed или processing, остаются нетронутыми.
Отправка (ReminderEngine.scan()) выполняется по 30-минутному таймеру. Она захватывает подошедшие срабатывания (аренда предотвращает двойную обработку), загружает получателей через адаптер сущности, отфильтровывает тех, кто уже зафиксирован в reminderSentLog для этого срабатывания, и вызывает createNotifications с deliveryStartLevel: 1 (сразу к push) плюс emailImmediate/emailByPerson, когда каналы определения включают email.
Внутренняя событийная шина реагирует на мутации сущностей, не дожидаясь ночного разворачивания: события контента (через диспетчер вебхуков) и события обновления планов/задач вызывают немедленное повторное разворачивание или отмену для затронутой сущности, а обновление плана также повторно разворачивает любые определения на уровне области, привязанные к его типу плана.
Адаптеры
Движок не зависит от конкретной сущности; каждый поддерживаемый тип сущности подключается через адаптер (helpers/adapters/):
| Тип сущности | Адаптер | Примечания |
|---|---|---|
event | EventReminderAdapter | Получатели ограничены зарегистрированными участниками или членами группы в зависимости от мероприятия и recipientMode. |
plan | PlanReminderAdapter | Получатели — назначения на план со статусом «Принято» + «Не подтверждено». buildEmails обращается к DoingModuleGateway.buildPlanReminderEmails, который рендерит позиции, заметки и пользовательское сообщение через doing/helpers/PlanReminderEmailHelper, включая кнопки Принять/Отклонить, подписанные ReminderTokenHelper, которые отправляют запрос на публичную конечную точку ответа на назначение. |
task | TaskReminderAdapter | Получатели — исполнитель(и) задачи. |
Конечные точки
| Метод | Путь | Назначение |
|---|---|---|
GET / POST | /messaging/reminders/:entityType/:entityId | Загрузить или сохранить определение напоминания для одной сущности. |
GET / POST | /messaging/reminders/scope/:entityType/:scopeId | Загрузить или сохранить определение напоминания на уровне области (наследуемое). |
DELETE | /messaging/reminders/:defId | Удалить определение и отменить его ожидающие срабатывания. |
GET | /messaging/reminders/event/:eventId/preview | Предпросмотр количества получателей и следующих времён срабатывания для напоминания о мероприятии перед сохранением. |
GET | /messaging/reminders/log | Недавняя история срабатываний напоминаний для церкви. |
POST | /messaging/reminders/mute | Заглушить напоминания для конкретной сущности. |
Сохранение определения вызывает синхронное повторное разворачивание для этой сущности или области, поэтому редакторы видят актуальные «следующие срабатывания», не дожидаясь ночной задачи.
Прямые сообщения
Прямые сообщения проходят через ту же воронку, что и всё остальное, а не через отдельный путь эскалации. Каждая непрочитанная беседа получает одну теневую строку в notifications (contentType='privateMessage', contentId = id личного сообщения, category='direct_messages'), которая владеет всем состоянием доставки — эскалацией сокет/push/email, отслеживанием прочтения, всем. Сама таблица privateMessages хранит полезную нагрузку сообщения и столбец notifyPersonId, который является источником значка непрочитанного и очищается, когда получатель читает беседу.
Теневые строки невидимы для колокольчика уведомлений: они исключены из запроса счётчика непрочитанных, запроса списка уведомлений и запросов отметки прочитанным/удаления, все из которых фильтруют contentType <> 'privateMessage'. Каждый пинг личного сообщения попадает в сокет независимо от состояния непрочитанности (семантика живого чата — без дедупликации), и личные сообщения никогда не останавливаются на доставке через сокет, как это делают обычные уведомления, поскольку свёрнутое в фон PWA может держать сокет открытым, всё ещё нуждаясь в push на уровне ОС. Если человек заглушает уведомления о личных сообщениях, теневая строка паркуется (isNew=false, notifyPersonId очищается) — всё ещё видна внутри самой беседы, просто без значков и оповещений.
Предпочтения и ограничения
Каждая отправка проходит через PreferenceGateHelper.evaluate(), чистую функцию (всё состояние передаётся снаружи, никаких обращений к БД на горячем пути), которая возвращает allow, suppress или defer. Уровни выполняются по порядку, и решение принимает первый, кто его принял:
- Заблокированная категория — некоторые категории обязательны (уровень 0) и обходят все остальные уровни.
- Общее отключение / отключение канала —
masterMute,allowPush,allowSmsилиemailFrequency='never'подавляют безоговорочно. - Тихие часы — только push и SMS (email считается ненавязчивым). Если текущее время по настенным часам в часовом поясе человека попадает в его тихое окно, транзакционная категория всё равно проходит; нетранзакционная откладывается до конца тихого окна, вычисляемого как корректный по переходу на летнее время момент UTC через
TimezoneHelper.wallClockToUtc. - Переопределение предпочтения по категории — явный отказ для пары категория × канал; отсутствие означает значение категории по умолчанию.
- Заглушение по конкретной сущности — заглушение, зафиксированное для конкретной сущности (например, одного мероприятия, одного плана), ограничивает сильнее, чем настройка на уровне категории, но применяется только когда вызывающая сторона передаёт id/тип сущности вместе с уведомлением.
Задействованные таблицы: notificationPreferences (глобальные — masterMute, emailFrequency со значениями individual|daily|weekly|never, allowPush, окно тихих часов + часовой пояс, allowSms), notificationPreferenceOverrides (по категории × каналу) и notificationEntityMutes (по сущности).
Это ограничение применяется для in-app (уровень 0), push (уровень 1) и email (уровень 2) внутри воронки — включая немедленные письма-напоминания/дайджесты. Транзакционная почта (коды аутентификации, сброс пароля, приглашения, квитанции о пожертвованиях) обходит его по замыслу; в этом весь смысл второй двери.
Планирование
И движок напоминаний, и дайджест уведомлений используют существующие запланированные таймеры, а не вводят новую инфраструктуру:
| Таймер | Расписание | Выполняет |
|---|---|---|
| 30-минутный таймер | каждые 30 минут | Эскалация непрочитанных уведомлений; отправка дайджест-писем с частотой individual; отправка подошедших срабатываний напоминаний (ReminderEngine.scan); дайджесты одобрений; исполнения автоматизаций по расписанию |
| Ночной таймер | 05:00 UTC | Напоминания о посещаемости групп; продвижение повторяющихся потоковых служений; обновление списков автообновления; разворачивание срабатываний напоминаний на следующий горизонт (ReminderEngine.expandAll); отправка дайджест-писем с частотой daily |
Локально ту же логику можно запустить по требованию командами npm run timer:30min и npm run timer:midnight из проекта Api.
Перечень файлов
| Область | Файлы |
|---|---|
| Воронка | Api/src/modules/messaging/helpers/NotificationHelper.ts, PreferenceGateHelper.ts, NotificationCategoryHelper.ts, WebPushHelper.ts, ExpoPushHelper.ts, SocketHelper.ts, DeliveryHelper.ts |
| Общая точка входа | Api/src/shared/helpers/NotificationService.ts |
| Транзакционная дверь | Api/src/shared/helpers/TransactionalEmailHelper.ts, правило линтера Api/tools/eslint-rules/email-door.cjs |
| Движок напоминаний | Api/src/modules/messaging/helpers/ReminderEngine.ts, ReminderBootstrap.ts, helpers/adapters/*, controllers/ReminderController.ts |
| Репозитории напоминаний | Api/src/modules/messaging/repositories/ReminderDefinitionRepo.ts, ReminderOccurrenceRepo.ts, ReminderSentLogRepo.ts |
| Email служения/плана | Api/src/modules/doing/helpers/PlanReminderEmailHelper.ts, ReminderTokenHelper.ts, Api/src/shared/modules/DoingModuleGateway.ts |
| Редакторы напоминаний (B1Admin) | serving/components/PlanTypeReminderEdit.tsx, calendars/components/EventReminderEdit.tsx, serving/tasks/components/TaskReminderEdit.tsx |
| Редактор напоминаний / предпочтения (B1App) | EventReminderEdit.tsx, NotificationPrefsPage.tsx, useRealtimeNotifications.ts |
Связанные страницы
- Архитектура реального времени — протокол WebSocket и клиентские примитивы (
SocketHelper,SubscriptionManager,ConversationStore), на которых держится уровень доставки внутри приложения - Веб-push-уведомления — настройка VAPID и путь браузерного Push API, используемый уровнем эскалации push
- Конечные точки Messaging — полная REST-поверхность для сообщений, бесед, соединений и маршрутов уведомлений/напоминаний