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.

  1. Consulta HabitExecution onde: 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
  2. Coleta um PushMessage(device, title, body, data) por device por execução
  3. Chama send_batch(messages) — uma única chamada FCM send_each por 500 mensagens
  4. bulk_update de 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 retornam UnregisteredError, InvalidArgumentError ou NotFoundError são marcados is_expired=True via bulk_update em PushDevices
  • Gated por settings.ENABLE_FIREBASE_NOTIFICATIONS (depois de ensure_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_at setados após o envio inicial
  • Lembretes só disparam quando is_notified=True e param quando o status sai de pending
  • Envios de áudio/admin continuam criando Notification e disparando via post_save

Referências