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_SECRETHMAC и endpoint’ами/internal/*.
Мобилка — чистый лист:
- Expo SDK 55,
kz.dimetra.app, schemedimetra://— все правильные поля для 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.
| Поле | Тип | Назначение |
|---|---|---|
id | uuid | PK |
user_id | m2o → directus_users | владелец |
expo_token | string, unique | ExponentPushToken[...] |
device_id | string | Device.osInternalBuildId / installation id |
platform | enum: ios | android | для диагностики |
app_version | string | для дебага |
last_seen_at | timestamp | обновляется при каждом логине/фокусе |
created_at | timestamp |
directus_users.push_token остаётся на первом этапе как обратная совместимость, но помечается deprecated и дропается после миграции sender’а.
Permissions:
- Client: CRUD на
push_tokensгдеuser_id = $CURRENT_USER. - Bot Service / Flow: read all.
2. Отправка — Directus Flow «Run Script»
Заголовок раздела «2. Отправка — Directus Flow «Run Script»»Три кандидата:
| Подход | Плюсы | Минусы |
|---|---|---|
| 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-push | full-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».
3. Preferences — бинарный toggle
Заголовок раздела «3. Preferences — бинарный toggle»Обязательно нужна возможность отключить пуши вообще (комплаенс / клиент в отпуске). 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-link | MVP |
|---|---|---|---|---|---|---|
| 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 фото откладываем.
Архитектура — три вертикальных слайса
Заголовок раздела «Архитектура — три вертикальных слайса»Слайс A — mobile client
Заголовок раздела «Слайс A — mobile client»Новые зависимости:
expo-notifications # core push APIexpo-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— pluginexpo-notificationsс иконкой/звуком, iOSinfoPlist.UIBackgroundModes: [remote-notification], Androidpermissions: [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().
Слайс B — backend schema + permissions
Заголовок раздела «Слайс B — backend schema + permissions»Новая коллекция 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_tokensCRUD гдеuser_id = $CURRENT_USER. - Bot Service policy:
push_tokensread all.
Новое поле notifications_enabled: boolean (default true) на directus_users.
Deprecation directus_users.push_token: оставляем до миграции sender’а, потом дропаем.
Слайс C — sender (Flow + shared script)
Заголовок раздела «Слайс C — sender (Flow + shared script)»Переиспользуемый Flow «Send Expo Push»:
- Trigger: Another Flow (можно вызывать из других Flow как subroutine).
- Input:
{ user_ids: string[], title: string, body: string, data: object }. - Operations:
- Read
push_tokenswhereuser_id in $trigger.user_idsANDuser.notifications_enabled=true. - Run Script:
- Разбить tokens на чанки по 100.
fetch('https://exp.host/--/api/v2/push/send', { method: 'POST', body: JSON.stringify(messages) }).- Залогировать в Directus Activity (опционально).
- Return tickets (для будущего receipts polling’а).
- Read
Триггерные Flow’ы — короткие, логики ноль, дёргают «Send Expo Push»:
on_new_manager_message: triggermessages.items.createfiltersender_type=manager→ read chat.project.clients → Send.on_stage_status_changed: triggerstages.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 polling | 25 активных клиентов, дохлых токенов будет единицы в месяц | когда >100 клиентов |
| Sidecar-сервис | Flow Run Script покрывает MVP | когда нужен receipts polling ИЛИ debounce ИЛИ quiet hours |
| Debounce фото | проще попросить менеджера грузить пачкой | вместе с сайдкаром |
| Granular prefs | binary off/on покрывает комплаенс | когда клиенты начнут жаловаться на шум |
| iOS badge count | можно слать badge: 1 статикой | когда будет fast query для unread |
| Quiet hours | все клиенты в Караганде = +06:00, не критично | вместе с сайдкаром |
| Блог-пуши | маркетинговый шум, клиенты отпишутся | когда появится opt-in механика |
Каждый пункт добавляется аддитивно без слома MVP.
Roadmap
Заголовок раздела «Roadmap»Vertical slice per feature:
-
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.
- Создать
-
Slice 2: mobile registration
- Install
expo-notifications+expo-device, настроитьapp.json. services/notifications.ts+usePushNotificationshook.- Wire в
AuthProvider: регистрация на signIn, очистка на signOut. - UI свитч
notifications_enabledв профиле. - Первый тестовый push на реальном iPhone.
- Install
-
Slice 3: chat push (killer feature)
- Flow
on_new_manager_message→ Send-Expo-Push. - Deep-link payload
{ screen: 'chat', chat_id }. - Обработчик в
usePushNotifications→router.push('/(app)/chat'). - FAB badge подключить к
useUnreadMessagesCount.
- Flow
-
Slice 4: stages + payments push
- Flow
on_stage_status_changed(start + done). - Flow
on_payment_created+ CRONon_payment_due_soon+ updateon_payment_overdue. - Deep-link’и в соответствующие экраны.
- Flow
-
Slice 5 (MVP+1): documents / receipts / badge real count / paid confirmation.
-
Slice 6+ (позже): sidecar + receipts polling + debounce + photos + blog opt-in.
Verification
Заголовок раздела «Verification»Каждый 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) работают как раньше.