Перейти к содержимому

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

Стратегический документ: как устроены push-уведомления в DIMETRA и почему именно так.

Клиент запускает мобилку раз в несколько дней. Между заходами у него накапливается то, ради чего приложение вообще существует: ответ менеджера в чате, переход на новый этап, новая сумма к оплате, свежие фото с объекта. Без push-уведомлений вся эта ценность просто лежит в базе, пока клиент случайно не зайдёт.

Задача — выбрать подход, который:

  • закрывает прайм-кейс (чат + этапы + платежи) сразу, но не ломает будущего (фото, документы, блог);
  • не превращает Directus в оркестратор уведомлений (всё через Flow’ы и минимум кода);
  • не завязывает нас на Expo намертво (токены/категории остаются нашими — можно мигрировать на FCM/APNs напрямую);
  • не требует нового сервиса на старте (сайдкар добавляем только когда припрёт).

Базовый выбор — Expo Push Notifications.

Бэкенд уже частично подготовлен:

  • push_token: string | null уже есть на directus_users (backend/snapshots/schema.yaml) с комментарием «Expo push token мобильного приложения».
  • Client policy уже разрешает update своего push_token (backend/ROLES.md).
  • Bot Service policy уже читает push_token.
  • Flow on_stage_completed спроектирован с идеей «Webhook POST к Expo Push API», но не реализован.
  • Паттерн сайдкара уже установлен Telegram-ботом (bot/), с INTERNAL_SECRET HMAC и endpoint’ами /internal/*.

Мобилка — чистый лист:

  • Expo SDK 55, kz.dimetra.app, scheme dimetra:// — все правильные поля для push есть.
  • expo-notifications и expo-device не установлены.
  • Нет ни одного упоминания Notifications.* или deep-link handler’а.
  • AuthProvider (mobile/providers/AuthProvider.tsx) — готовая точка для lifecycle’а push-токена.
  • FAB badge в mobile/app/(app)/_layout.tsx захардкожен 3 — надо прицепить к реальным данным.

Схема и permissions на 80% готовы. Форма хранения (push_token строкой на юзере) ограничивает нас одним устройством на клиента — это первая архитектурная развилка.

1. Хранение токенов — отдельная коллекция push_tokens

Заголовок раздела «1. Хранение токенов — отдельная коллекция push_tokens»

Сейчас directus_users.push_token: string = один юзер = одно устройство. Это ломается в очевидных сценариях:

  • клиент сменил телефон → старый токен продолжает жить в БД, пуши уходят в пустоту, пока Expo receipt не отметит как DeviceNotRegistered;
  • клиент поставил приложение на iPad → последний логин перезаписал токен предыдущего устройства, второй девайс молчит;
  • logout на одном устройстве стирает токен → другое устройство перестаёт получать уведомления, хотя сессия там жива.

Решение: отдельная коллекция push_tokens.

ПолеТипНазначение
iduuidPK
user_idm2o → directus_usersвладелец
expo_tokenstring, uniqueExponentPushToken[...]
device_idstringDevice.osInternalBuildId / installation id
platformenum: ios | androidдля диагностики
app_versionstringдля дебага
last_seen_attimestampобновляется при каждом логине/фокусе
created_attimestamp

directus_users.push_token остаётся на первом этапе как обратная совместимость, но помечается deprecated и дропается после миграции sender’а.

Permissions:

  • Client: CRUD на push_tokens где user_id = $CURRENT_USER.
  • Bot Service / Flow: read all.

Три кандидата:

ПодходПлюсыМинусы
A. Flow → Webhook POSTнулевой код, вся логика в UIне читает response → дохлые токены не инвалидируются; нет batch’а (Expo рекомендует по 100)
B. Flow → Run Script (JS в операции Directus)batch, fetch нескольких токенов, простая логика в одном месте, коммитится в Flow exportнет retry/queue; receipts polling (15-минутный второй round-trip) неудобно делать в Flow
C. Sidecar-сервис /internal/send-pushfull-featured: retry, queue, receipts polling, logging, quiet hours+контейнер, +мейнтенанс, +INTERNAL_SECRET handshake

Решение: вариант B для MVP. Он закрывает 95% сценариев и не плодит сервисов.

Когда переходить на C:

  • Когда понадобится receipts polling — Expo возвращает ticket → через 15 мин надо опросить /getReceipts и инвалидировать DeviceNotRegistered / InvalidCredentials токены. Без этого база будет копить мёртвые токены и на каждый пуш платить time-out’ом.
  • Когда захочется debounce/batching («менеджер загрузил 5 фото — один push, а не пять»). Это требует persistent queue (Redis), который нельзя сделать чисто в Flow.
  • Когда появятся quiet hours / user-timezone-aware расписания.

Пока у нас 25 активных проектов и ручной темп — Run Script справится.

Sender-скрипт живёт в одном месте: backend/flows/scripts/send-expo-push.js (экспортируется из Directus UI, коммитится в git как источник правды). Импортируется в каждый триггерный Flow через операцию «Run Script».

Обязательно нужна возможность отключить пуши вообще (комплаенс / клиент в отпуске). Granular настройки («получать про этапы, не получать про блог») — приятно, но не на старте.

Решение:

  • Поле notifications_enabled: boolean (default true) на directus_users или client_profiles.
  • Sender-скрипт в Flow проверяет флаг перед отправкой.
  • UI — один свитч в профиле.
  • Будущее расширение: отдельная коллекция notification_preferences с ключами chat, stages, payments, content.

Blog-посты — opt-in по умолчанию off.

#СобытиеTriggerПолучателиТекстDeep-linkMVP
1Сообщение от менеджераmessages.items.create where sender_type=managerклиенты чата{manager_name}: {text[:120]}dimetra://app/chat?chat_id={id}
2Этап завершёнstages.items.update where status→doneвсе project.clientsЗавершён этап «{stage.name}»dimetra://app/project?stage_id={id}
3Этап стартовалstages.items.update where status→activeвсе project.clientsНачат этап «{stage.name}»dimetra://app/project?stage_id={id}
4Платёж созданpayments.items.createклиент проектаНовый платёж: {amount} ₸ до {due_date}dimetra://app/payments?id={id}
5Напоминание о платежеCRON daily, due_date - 3 daysклиентПлатёж {amount} ₸ через 3 дняdimetra://app/payments?id={id}
6Платёж просроченFlow auto_overdue_payments, status→overdueклиентПлатёж просрочен: {amount} ₸dimetra://app/payments?id={id}
7Платёж подтверждёнpayments.items.update where status→paidклиентПлатёж {amount} ₸ подтверждёнdimetra://app/payments?id={id}🟡
8Новый документdocuments.items.createклиенты проектаЗагружен {type}: {title}dimetra://app/project?tab=documents🟡
9Новый чекreceipts.items.createклиентЧек за {month}dimetra://app/project?tab=receipts🟡
10Новые фотоphoto_categories.items.create + debounce 30sклиентыНовые фото: {title}dimetra://app/photos?category_id={id}🟡
11Блог-постblog_posts.items.update where status→published, opt-inподписанные{title}dimetra://app/news?id={id}

MVP = 6 типов (1–6). Ими закрываются 95% реальных кейсов.

Debounce для #10 требует сайдкара или внешнего queue → в MVP фото откладываем.

Новые зависимости:

expo-notifications # core push API
expo-device # проверка «настоящий девайс» (simulator не получает push)

Новые файлы:

  • mobile/services/notifications.ts — фасад: registerForPushAsync(), unregisterAsync(token), setBadge(n), setHandler().
  • mobile/hooks/usePushNotifications.ts — хук, вешает listeners на addNotificationReceivedListener + addNotificationResponseReceivedListener, делает router.push() по deep-link’у из payload.
  • mobile/services/queries/usePushTokenMutation.ts — POST/DELETE в push_tokens коллекцию.

Изменения:

  • mobile/app.json — plugin expo-notifications с иконкой/звуком, iOS infoPlist.UIBackgroundModes: [remote-notification], Android permissions: [NOTIFICATIONS].
  • mobile/providers/AuthProvider.tsx:
    • после signIn()registerForPushAsync() → POST /items/push_tokens.
    • перед signOut() → DELETE /items/push_tokens/{id}directus.logout().
  • mobile/app/_layout.tsx — монтирование usePushNotifications() внутри AuthProvider (требует auth для роутинга).
  • FAB badge — убрать badgeCount={3}, подключить к useUnreadMessagesCount().

Критические моменты:

  • Device.isDevice проверка — на симуляторе не регистрируем (иначе ошибка и креш при первом логине).
  • Notifications.getPermissionsAsync() — запрашиваем разрешение после онбординга, не на старте (чтобы клиент не отказал рефлекторно).
  • Cold start: Notifications.getLastNotificationResponseAsync() в _layout.tsx → если пришли из нотификации, достаём data.deeplink и сразу router.replace().

Новая коллекция push_tokens (через Directus UI, потом npm run schema:snapshot):

  • Поля по таблице выше.
  • Unique index на expo_token.
  • Unique composite (user_id, device_id).

Обновление permissions в backend/ROLES.md + scripts/setup-roles.sh:

  • Client policy: push_tokens CRUD где user_id = $CURRENT_USER.
  • Bot Service policy: push_tokens read all.

Новое поле notifications_enabled: boolean (default true) на directus_users.

Deprecation directus_users.push_token: оставляем до миграции sender’а, потом дропаем.

Переиспользуемый Flow «Send Expo Push»:

  • Trigger: Another Flow (можно вызывать из других Flow как subroutine).
  • Input: { user_ids: string[], title: string, body: string, data: object }.
  • Operations:
    1. Read push_tokens where user_id in $trigger.user_ids AND user.notifications_enabled=true.
    2. Run Script:
      • Разбить tokens на чанки по 100.
      • fetch('https://exp.host/--/api/v2/push/send', { method: 'POST', body: JSON.stringify(messages) }).
      • Залогировать в Directus Activity (опционально).
    3. Return tickets (для будущего receipts polling’а).

Триггерные Flow’ы — короткие, логики ноль, дёргают «Send Expo Push»:

  • on_new_manager_message: trigger messages.items.create filter sender_type=manager → read chat.project.clients → Send.
  • on_stage_status_changed: trigger stages.items.update → read project.clients → Send.
  • on_payment_created / on_payment_overdue / on_payment_due_soon (CRON): аналогично.
  • on_document_created, on_receipt_created (MVP+1).

Куда положить исходники Flow’ов: Directus Flows не экспортируются со схемой. Создаём backend/flows/ и коммитим JSON-экспорты вручную после каждого изменения. Run-script содержимое отдельно в backend/flows/scripts/send-expo-push.js — для нормального review.

ОткладываемПочему окКогда брать
Receipts polling25 активных клиентов, дохлых токенов будет единицы в месяцкогда >100 клиентов
Sidecar-сервисFlow Run Script покрывает MVPкогда нужен receipts polling ИЛИ debounce ИЛИ quiet hours
Debounce фотопроще попросить менеджера грузить пачкойвместе с сайдкаром
Granular prefsbinary off/on покрывает комплаенскогда клиенты начнут жаловаться на шум
iOS badge countможно слать badge: 1 статикойкогда будет fast query для unread
Quiet hoursвсе клиенты в Караганде = +06:00, не критичновместе с сайдкаром
Блог-пушимаркетинговый шум, клиенты отпишутсякогда появится opt-in механика

Каждый пункт добавляется аддитивно без слома MVP.

Vertical slice per feature:

  1. Slice 1: fundament (schema + permissions + sender)

    • Создать push_tokens коллекцию + notifications_enabled поле.
    • Обновить backend/ROLES.md + setup-roles.sh.
    • Создать reusable Flow «Send Expo Push» (Run Script с fetch в Expo API).
    • Проверка: POST /flows/trigger/send-expo-push с фейковым токеном → push в Expo tool.
  2. Slice 2: mobile registration

    • Install expo-notifications + expo-device, настроить app.json.
    • services/notifications.ts + usePushNotifications hook.
    • Wire в AuthProvider: регистрация на signIn, очистка на signOut.
    • UI свитч notifications_enabled в профиле.
    • Первый тестовый push на реальном iPhone.
  3. Slice 3: chat push (killer feature)

    • Flow on_new_manager_message → Send-Expo-Push.
    • Deep-link payload { screen: 'chat', chat_id }.
    • Обработчик в usePushNotificationsrouter.push('/(app)/chat').
    • FAB badge подключить к useUnreadMessagesCount.
  4. Slice 4: stages + payments push

    • Flow on_stage_status_changed (start + done).
    • Flow on_payment_created + CRON on_payment_due_soon + update on_payment_overdue.
    • Deep-link’и в соответствующие экраны.
  5. Slice 5 (MVP+1): documents / receipts / badge real count / paid confirmation.

  6. Slice 6+ (позже): sidecar + receipts polling + debounce + photos + blog opt-in.

Каждый slice — свой e2e check. Ничего не рапортуем «готово», пока не увидели результат наружу.

Slice 1 — sender:

  • POST /flows/trigger/{send-expo-push-id} с телом { user_ids: ['<test-user-id>'], title: 'Test', body: 'Hello', data: {} }.
  • Получить 200 и ticket из Expo.
  • В Expo notifications tool увидеть отправленное сообщение.

Slice 2 — mobile registration:

  • На реальном iPhone: установить dev-build → логин → в БД push_tokens появилась строка.
  • Logout → строка удалена.
  • Перелогин → новая строка (не дубль).

Slice 3 — chat:

  • Менеджер пишет сообщение в Directus admin UI (от имени manager role).
  • Push приходит на iPhone за <5 сек.
  • Тап по пушу → приложение открывает экран чата с правильным chat_id.
  • Если приложение foreground → notification банер появляется, но не роутит.
  • FAB badge обновляется без перезапуска.

Slice 4 — stages/payments:

  • Менеджер в Directus: stages[2].status = done → пуш «Завершён этап «…»».
  • Менеджер создаёт payment → клиент получает пуш с суммой.
  • CRON-тест: due_date = NOW()+3days, запустить CRON вручную → пуш «Платёж через 3 дня».

Common:

  • Toggle notifications_enabled=false в профиле → пуши перестают приходить немедленно.
  • Удалить токен в БД вручную → sender не падает, в логе warning.
  • iOS Simulator — registerForPushAsync возвращает null без падения.

Regression:

  • Maestro suite (mobile/.maestro/) проходит без изменений.
  • Существующие Flow’ы (Telegram, Google Sheets, overdue) работают как раньше.