Push-уведомления

Устройства и согласие пользователя, автоматические события, кастомные рассылки, аудитории, права кураторов и гарантии доставки.

Как устроена доставка

Веб-клиент получает FCM token только после явного действия пользователя и сохраняет его через /notifications/me/devices. Сервер шифрует token, создаёт push-outbox по событию или кампании, разворачивает запись в доставки по активным устройствам и отправляет data-only сообщение через Firebase Cloud Messaging. Service Worker показывает системное уведомление; в открытом приложении сообщение отображается как toast.

  • notification_preferences.push_enabled — общий выключатель аккаунта. Он не заменяет разрешение браузера.
  • push_devices.enabled — выключатель конкретного устройства. Одна установка определяется стабильным installation_id; повторная регистрация обновляет ротированный FCM token.
  • Обычный выход удаляет binding текущей установки и локальный Firebase token best-effort. Другие устройства аккаунта не затрагиваются.
  • Plaintext token никогда не возвращается API и хранится на сервере только в зашифрованном виде; для поиска используется SHA-256 hash.
Разрешение браузера
Prompt можно показывать только по прямому нажатию пользователя. Если permission = denied, приложение не может открыть его повторно — разрешение меняется в настройках сайта. На iOS Web Push работает для web-app, добавленного на экран «Домой» (iOS/iPadOS 16.4+). Push требует HTTPS; localhost допускается браузерами для разработки.

Настройки пользователя

На странице «Настройки» ученик, куратор и администратор могут включить или выключить push для аккаунта, зарегистрировать текущий браузер, отключить отдельное устройство или удалить его. При включении клиент одновременно запрашивает browser permission, получает FCM token, выполняет идемпотентный upsert устройства и включает preference. GET /notifications/me отдаёт пагинированную историю успешно доставленных push без дублей между устройствами.

Доступ администратора и куратора

Администратор всегда видит страницу /notifications. Куратору администратор выдаёт отдельное право can_manage_notifications в разделе «Команда». Все management endpoints защищены тем же server-side permission; скрытие пункта меню не является защитой.

Curator scope
Preview, options и создание кампании для куратора автоматически ограничиваются его учениками. Даже фильтр без условий не превращается в рассылку всей школе. Снимок получателей строится на сервере в момент создания кампании.

Каталог автоматических событий

Каталог содержит 63 стабильных кода, и у всех текущих событий producer подключён. Поле implemented остаётся частью API-контракта: если в будущем событие появится раньше источника, оно будет показано как «Запланировано» и останется недоступным для включения.

КодКогда возникаетСтатус
account.welcomeДобро пожаловать / аккаунт созданРаботает · device_registration
account.activatedАккаунт снова активенРаботает · audit_sink
account.deactivatedАккаунт отключёнРаботает · audit_sink
account.role_changedПользователю назначена другая рольРаботает · audit_sink
account.password_changedПароль успешно изменёнРаботает · audit_sink
group.member_addedСтудент добавлен в группуРаботает · audit_sink
group.member_removedСтудент удалён из группыРаботает · audit_sink
group.curator_assignedКуратору назначена группаРаботает · audit_sink
group.curator_unassignedКуратор снят с группыРаботает · audit_sink
student.curator_assignedКуратору индивидуально назначен ученикРаботает · audit_sink
student.curator_unassignedС куратора снято индивидуальное назначение ученикаРаботает · audit_sink
course.enrolledЗачисление на курсРаботает · audit_sink
course.unenrolledОтчисление с курсаРаботает · audit_sink
course.publishedДоступный курс опубликованРаботает · audit_sink
course.unpublishedКурс снят с публикацииРаботает · audit_sink
course.content_addedВ курс добавлен материалРаботает · audit_sink
course.content_updatedМатериал курса обновлёнРаботает · audit_sink
course.availableНаступило окно доступа к курсуРаботает · schedule_scan
module.availableНаступило окно доступа к модулюРаботает · schedule_scan
lesson.availableНаступило окно доступа к урокуРаботает · schedule_scan
lesson.deadline_reminderЕжедневное напоминание за N днейРаботает · deadline_scan
lesson.deadline_todayПоследний день доступа к урокуРаботает · deadline_scan
lesson.access_closedОкно доступа к уроку закрытоРаботает · deadline_scan
assignment.assignedЗадание стало доступноРаботает · audit_sink
assignment.updatedЗадание измененоРаботает · audit_sink
assignment.submittedРабота отправлена на проверкуРаботает · audit_sink
assignment.acceptedРабота принята кураторомРаботает · audit_sink
assignment.rejectedРабота возвращена на доработкуРаботает · audit_sink
assignment.score_updatedОценка задания измененаРаботает · audit_sink
assignment.reopenedОткрыта повторная отправкаРаботает · audit_sink
assignment.deadline_reminderСкоро закроется урок с заданиемРаботает · deadline_scan
quiz.assignedТест стал доступенРаботает · audit_sink
quiz.attempt_startedСтудент начал попытку тестаРаботает · audit_sink
quiz.submittedТест завершёнРаботает · audit_sink
quiz.passedТест пройденРаботает · audit_sink
quiz.failedТест не пройденРаботает · audit_sink
quiz.review_requiredНужна ручная проверкаРаботает · audit_sink
quiz.score_updatedРезультат изменён после проверкиРаботает · audit_sink
quiz.auto_finalizedПопытка завершена по таймеруРаботает · audit_sink
exam.publishedПробный ЕНТ опубликованРаботает · audit_sink
exam.attempt_startedСтудент начал пробный ЕНТРаботает · audit_sink
exam.submittedПробный ЕНТ завершёнРаботает · audit_sink
exam.passedПробный ЕНТ сданРаботает · audit_sink
exam.failedПробный ЕНТ не сданРаботает · audit_sink
exam.auto_finalizedПробный ЕНТ завершён по таймеруРаботает · audit_sink
progress.block_completedЗавершён блок урокаРаботает · audit_sink
progress.lesson_completedЗавершён урокРаботает · audit_sink
progress.module_completedЗавершён модульРаботает · audit_sink
progress.course_completedЗавершён курсРаботает · audit_sink
progress.milestoneДостигнут процент прогрессаРаботает · audit_sink
progress.stalledСтудент давно не училсяРаботает · schedule_scan
learning_path.course_unlockedОткрыт следующий курс траекторииРаботает · audit_sink
learning_path.completedТраектория завершенаРаботает · audit_sink
gamification.points_awardedАвтоматически начислены баллыРаботает · audit_sink
gamification.manual_adjustmentБаллы изменены вручнуюРаботает · audit_sink
gamification.rank_changedИзменена позиция в рейтингеРаботает · audit_sink
billing.expiry_reminderПодписка школы скоро закончитсяРаботает · deadline_scan
billing.expiredПодписка закончиласьРаботает · audit_sink
billing.activatedПодписка активированаРаботает · audit_sink
billing.extendedПодписка продленаРаботает · audit_sink
billing.suspendedШкола приостановленаРаботает · audit_sink
billing.unsuspendedДоступ к школе восстановленРаботает · audit_sink
media.processing_failedОшибка обработки медиаРаботает · audit_sink
media.delete_failedОшибка фонового удаления медиаРаботает · audit_sink
campaign.customКастомная рассылкаРаботает · admin_campaign

Напоминание о дедлайне урока

lesson.deadline_reminder использует фактическое ends_at окна урока. По умолчанию scheduler начинает за 3 дня, повторяет каждый день и отправляет в 09:00 локального времени школы. Настройки: days_before — целое 1…90, repeat_daily — boolean, send_at_localHH:MM. Dedupe key не допускает повтор одной и той же рассылки за день.

Кастомные кампании

Кампания отправляется сразу или по scheduled_at. Перед созданием UI вызывает server-side preview. После подтверждения сервер повторно вычисляет аудиторию, фиксирует immutable snapshot получателей и возвращает фактический audience_count; изменения групп, прогресса или фильтров уже не меняют эту кампанию.

Поле правилаОператорыСегмент
student_idin, not_inКонкретные ученики
group_idin, not_inТекущий состав групп
course_enrollment, course_completed, course_in_progress, course_not_completedin, not_inЗачисление и состояние прохождения курса
module_completed, lesson_completedin, not_inПолностью завершили модуль / урок
quiz_attempted, quiz_passed, quiz_failedin, not_inРезультаты конкретных тестов
quiz_score_pctgte, lteРезультат выбранных тестов 0…100% (values + value)
assignment_submitted, assignment_accepted, assignment_rejectedin, not_inСтатус конкретных заданий
assignment_score_pctgte, lteОценка выбранных заданий 0…100% (values + value)
exam_attempted, exam_passed, exam_failedin, not_inРезультаты пробных ЕНТ
exam_score_pctgte, lteРезультат выбранных ЕНТ 0…100% (values + value)
learning_path_started, learning_path_completedin, not_inНачали / завершили траекторию
learning_path_course_unlockedin, not_inОткрыли выбранный курс внутри траектории
points_totalgte, lteСуммарный баланс баллов
rankgte, lteМесто в общем рейтинге; lte 10 = топ-10
last_active_atbefore, afterАктивность до/после RFC3339
push_enabled, has_deviceeqЗначение "true" или "false"
  • match=all — ученик должен соответствовать всем правилам; match=any — хотя бы одному.
  • Пустой rules означает всех активных учеников текущей школы (с curator scope, если вызвал куратор).
  • Options endpoint отдаёт только минимальные id, label, subtitle и применяет тот же scope — UI не запрашивает чужие domain endpoints.
  • Статусы кампании: scheduled, processing, completed, cancelled, failed. Отмена не отзывает уже доставленные сообщения.

Шаблоны и ограничения payload

  • Заголовок кампании или event template — не более 160 байт UTF-8; body — не более 2000 байт UTF-8. Счётчик UI показывает байты, а не количество JavaScript-символов.
  • Итоговое сериализованное FCM-сообщение должно быть не больше 4096 байт. Превышение после подстановки переменных считается terminal delivery error и не ретраится.
  • deep_link — только same-origin относительный путь, начинающийся с одного /; scheme, host, //, backslash и управляющие символы запрещены.
  • FCM отправляется data-only, чтобы Service Worker показал ровно одно уведомление. notification_id используется как browser notification tag.

Конфигурация веб-клиента

.env.local
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=...
NEXT_PUBLIC_FIREBASE_PROJECT_ID=...
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=...
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...
NEXT_PUBLIC_FIREBASE_APP_ID=...
NEXT_PUBLIC_FIREBASE_VAPID_KEY=...

Firebase client config и public VAPID key не являются секретами. Service Account credentials и путь PUSH_TOKEN_KEY_FILE к 32-byte ключу относятся только к backend/worker и никогда не должны попадать в NEXT_PUBLIC_*.