Push de hábitos e lembretes via FCM em lote
TLDR: Hábitos e lembretes enviam push direto ao FCM em lote, sem criar linhas
Notification;is_notifiedé o gate que libera as ondas de lembrete.
Visão geral
As execuções de hábito (HabitExecution) têm scheduled_date e scheduled_time. Quando esse horário chega, uma push é enviada para todos os devices ativos do usuário. Uma segunda onda de até 4 lembretes dispara de 2 em 2 horas enquanto a execução permanecer pendente.
Princípio de design: as pushes de hábito/lembrete contornam o model Notification e vão direto ao FCM em lote. O model Notification fica reservado a envios pontuais (lançamentos de áudio, mensagens criadas no admin, envios direcionados por usuário).
Fluxo
```mermaid
graph TD
A[“send_habit_notifications
(a cada 30 min)”] –> B{“execução pending,
is_notified=False,
na janela de 10 min?”}
B –>|não| Z[“ignora”]
B –>|sim| C[“monta PushMessage
por device”]
C –> D[“send_batch → messaging.send_each”]
D –> E[“bulk_update
is_notified=True
notified_at=now”]
F["send_habit_reminders<br/>(a cada 60s)"] --> G{"is_notified=True<br/>e status=pending?"}
G -->|não| Z
G -->|sim| H{"passaram 2h desde<br/>a onda anterior?"}
H -->|não| Z
H -->|sim| I["send_batch da onda<br/>1ª → 2ª → 3ª → 4ª"]
I --> J["grava timestamp<br/>da onda"]
style A fill:#1f2937,color:#fff
style F fill:#1f2937,color:#fff
style Z fill:#374151,color:#fff ```
Notificação inicial
HabitNotificationService.send_pending_notifications roda a cada 30 minutos (crontab 0,30) e cobre uma janela de look-back de 10 minutos, para tratar execuções cujo horário agendado caiu entre duas execuções da task.
- Consulta
HabitExecutiononde:scheduled_date=today,scheduled_time <= now,status=pending,is_notified=False,habit.status=active,habit.deleted_at=null,user.enable_notifications=True, dentro da janela de 10 minutos - Coleta um
PushMessage(device, title, body, data)por device por execução - Chama
send_batch(messages)— uma única chamada FCMsend_eachpor 500 mensagens bulk_updatede todas as execuções encontradas:is_notified=True,notified_at=now
Ondas de lembrete
ExecutionService.send_pending_reminders roda a cada 60 segundos e despacha 4 ondas em paralelo via ThreadPoolExecutor(max_workers=4).
Cada onda (_send_first/second/third/fourth_reminders) consulta execuções onde is_notified=True, o respectivo campo *_reminder é nulo e passaram pelo menos 2 horas desde a onda anterior.
_bulk_send_reminders monta os PushMessage usando os templates REMINDER_FLOWS[reminder_variation][reminder_index] e chama send_batch. Atualiza o timestamp do lembrete e, no primeiro lembrete, atribui um reminder_variation aleatório (1–8).
Contratos
Campos de gate em HabitExecution
| Campo | Tipo | Propósito |
|---|---|---|
is_notified |
BooleanField(default=False, db_index=True) |
Vira True quando a push inicial do hábito é enviada. Lembretes só disparam com is_notified=True |
notified_at |
DateTimeField(null=True) |
Timestamp do envio da push inicial |
Os campos de lembrete (first_reminder, second_reminder, third_reminder, fourth_reminder) continuam rastreando o timestamp de cada onda.
Helper de envio em lote
apps/notifications/services/push_batch.py — send_batch(messages: Sequence[PushMessage]) -> dict:
- Ignora devices sem token ou com
is_expired=True - Envia em chunks de 500 via
messaging.send_each - Inspeciona o
BatchResponse: tokens que retornamUnregisteredError,InvalidArgumentErrorouNotFoundErrorsão marcadosis_expired=Trueviabulk_updateemPushDevices - Gated por
settings.ENABLE_FIREBASE_NOTIFICATIONS(depois deensure_firebase_initialized) - Retorna
{"sent": N, "failed": N}
Onde o fluxo vive
| Arquivo | Papel |
|---|---|
apps/notifications/services/push_batch.py |
Helper de envio FCM em lote |
apps/habits/services.py — HabitNotificationService |
Service da notificação inicial |
apps/habits/services.py — ExecutionService |
Service dos lembretes |
apps/habits/tasks.py — send_habit_notifications |
Task Celery (a cada 30 min) |
apps/habits/tasks.py — send_habit_reminders |
Task Celery (a cada 60s) |
apps/habits/models/execution.py |
Model HabitExecution com is_notified/notified_at |
apps/habits/migrations/0013_* |
Migration: adiciona is_notified/notified_at, faz backfill e remove notification_ids |
Invariantes observáveis
- Nenhuma linha
Notificationé criada por hábitos ou lembretes - Uma chamada FCM em lote por janela, não uma por device
is_notified/notified_atsetados após o envio inicial- Lembretes só disparam quando
is_notified=Truee param quando o status sai depending - Envios de áudio/admin continuam criando
Notificatione disparando viapost_save
Referências
- specs/20260615095011_habit_push_batch_fcm.md — spec que introduziu o envio em lote
- specs/20260525120000_habit_reminder_notifications.md — spec das ondas de lembrete
- specs/20260602000000_notification_10min_window.md — janela de 10 minutos
- rules/notifications/notification_model_point_in_time_only.md — R-010
- rules/notifications/habit_notification_gate.md — R-011
- learnings/notifications_fcm_send_each_batch_expired_tokens.md