Уведомления — Web Push, mute, mentions

Перед началом: окно диалога и группы (Модуль 9). Инструменты: Web Push (VAPID), service worker, Go. Трудоёмкость: ~6 ч.

🔥 Инцидент: «Пять пушей в минуту и веер отписок»

Группа на 300 человек — рабочая, живая. Уведомления настроили по-честному: на каждое сообщение — push всем, кто офлайн. В первый же активный час пользователи получили десятки пушей, телефон превратился в сирену — дальше посыпались отписки от уведомлений (и часть — от сервиса целиком). Второй инцидент: mute работал… наполовину. «Не беспокоить» на группу гасило пуши в вебе, но не в push — потому что правило фильтрации жило на клиенте, а пуш-воркер о нём не знал. В этом модуле: правила уведомлений на сервере, упоминания, агрегация.

📖 Теория

10.1 Кого и когда будить: правила

Правила — данные продукта, фиксируем явно (и держим на сервере):

Событиеdirectгруппа
Сообщение, получатель офлайн🔔 push🔕 тишина
Упоминание @вы🔔 push🔔 push
Ответ на ваше сообщение🔔 push🔔 push
Диалог в mute🔕🔕 (кроме упоминания)
Получатель онлайн (есть соединения)🔕🔕

Почему на сервере: клиент может быть закрыт (в этом весь смысл пуша) — значит, правила обязан применять тот, кто отправляет push. Клиентская фильтрация «на будущее» невозможна в принципе.

10.2 Кто офлайн

У нас уже есть всё: presence-ключ (Модуль 7) и таблица соединений пользователя (Модуль 6). «Офлайн» = нет активных соединений; Redis присутствие — подсказка, соединения — факт. Пуш шлём, только если соединений ноль — не дублируем живое приложение.

10.3 Подписки: endpoint + ключи

Браузер выдаёт объект подписки (endpoint, keys.p256dh, keys.auth) — храним у пользователя (одна запись на устройство):

sql
CREATE TABLE push_subscriptions (
    id         UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id    UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    endpoint   TEXT NOT NULL UNIQUE,
    p256dh     TEXT NOT NULL,
    auth       TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Отправка — библиотекой (например, SherClockHolmes/webpush-go) с VAPID-ключом: VAPID_PUBLIC отдаём фронтенду, VAPID_PRIVATE живёт в env. Ответ 404/410 от endpoint — подписка протухла, удаляем строку.

10.4 Service worker и упоминания

Фронтенд: регистрируем минимальный service worker — обработчик push (показать showNotification) и notificationclick (сфокусировать вкладку на нужном диалоге). Всё остальное (PWA-обвязку) — вне курса.

Упоминания: в тексте @username; сервер находит пользователя по display_name-слагу и добавляет получателя в правило §10.1. Полноценные mentions (автокомплит, уведомления по id, Unicode-имена) — упражнение; важно сейчас: mention — это отдельный сигнал, а не подстрока (проверяем по границам слова и списку участников диалога).

10.5 Агрегация

Окно ~30 секунд: несколько сообщений одному офлайн-получателю → один push («3 новых сообщения от «Команда»»). Учебная реализация простая: буфер в памяти воркера userID → batch c flush по таймеру; в продакшене это же делается в Redis. Анти-спам не только про нервы: у браузерных push-сервисов есть лимиты, а у пользователя — терпение.

💻 Практика

  1. Миграция 000007_push_subscriptions.{up,down}.sql по §10.3.

  2. API: POST /api/v1/push/subscriptions (upsert по endpoint от текущего пользователя), DELETE по endpoint (отписка). VAPID-ключи — в env; GET /api/v1/push/public-key для фронтенда.

  3. Воркер: расширьте обработку message.new из outbox:

    • определите получателей-офлайн (соединения = 0, правило §10.1);
    • примените правила (direct / mention / reply / mute по muted_until, §5.1);
    • отправьте push (или отложите в буфер агрегации §10.5).
  4. Фронтенд:

    • features/enable-push: запрос разрешения, pushManager.subscribe, отправка подписки на бэкенд; кнопка «Включить уведомления» в настройках;
    • public/sw.js — обработчики push и notificationclick (переход в диалог);
    • in-app: счётчик непрочитанных в document.title и Notifications при скрытой вкладке (без SW).
  5. Mute: PATCH /conversations/{id} с muted_until (+ кнопка в member-panel из Модуля 9). Проверьте инцидент: в muted-группе пуш не приходит, кроме упоминания.

  6. Тесты и сценарий:

    • Боб офлайн → Алиса пишет в direct → Боб получает push; Боб онлайн → push нет, есть WS;
    • группа: 10 сообщений за 30 секунд Бобу офлайн → один агрегированный push;
    • @bob в muted-группе → push приходит;
    • протухшая подписка (удалить руками из БД и эмулировать 410) — строка вычищается, ошибок наружу нет.

Чек-лист модуля: правила уведомлений применяет сервер; онлайн-пользователя не дублируем push'ем; mute гасит, mention пробивает; агрегация работает; протухшие подписки вычищаются.

🧩 Quiz

1. Почему правила уведомлений обязаны жить на сервере?
2. «Не беспокоить» на группу, но прилетел push. Что не так в типовой ошибке?
3. Как сервер понимает, что получатель «офлайн»?
4. Зачем агрегация push-уведомлений?
5. Почему mention — «отдельный сигнал», а не поиск подстроки «@bob»?
6. Endpoint вернул 410. Что делать?

✅ Итоги модуля

  • Правила «кого будить» — на сервере: direct/mention/reply/mute, онлайн не дублируем.
  • Подписки устройств хранятся в БД; протухшие вычищаются по 404/410.
  • Service worker показывает push и открывает нужный диалог.
  • Mute и mentions — часть единой матрицы правил, а не клиентские фильтры.
  • Агрегация окна гасит шквал и бережёт лимиты push-сервисов.

🔗 Мостик к Модулю 11

Функционально мессенджер полон. Два вопроса перед продакшеном: выдержит ли он нагрузку (10 000 соединений — это не «много», но и не «мало») и что произойдёт, когда процесс перезапустится, а все клиенты одновременно пойдут в sync. Следующий модуль — нагрузка, метрики, шторм реконнектов и деградация.