Envio direto de push de hábitos e lembretes via FCM em lote

TLDR: Hábitos e lembretes deixam de criar linhas Notification (e o post_save que dispara uma task/HTTP por device) e passam a enviar diretamente ao FCM em lote via messaging.send_each, reduzindo banco, tasks Celery e chamadas HTTP; o model Notification fica reservado a envios pontuais (áudio, admin, particular).

Contexto

Hoje todo envio de hábito e de lembrete cria uma linha Notification por device. O post_save da Notification (apps/notifications/signals.py:8) dispara send_notification.delay() — uma task Celery por device — e cada task chama messaging.send() — uma chamada HTTP ao FCM por device.

Para N usuários com M devices, cada janela gera N×M linhas no banco + N×M tasks + N×M requisições FCM. Isso é custoso e lento, e os hábitos/lembretes são justamente os fluxos de maior volume.

Fluxos afetados hoje: - HabitNotificationService.send_pending_notifications (apps/habits/services.py:273) — task send_habit_notifications, a cada 30 min - ExecutionService._bulk_send_reminders (apps/habits/services.py:432) — task send_habit_reminders, a cada 60s, em 4 ondas

Ambos hoje usam HabitExecution.notification_ids (ArrayField) como gate: - notification_ids__len=0 → notificação inicial ainda não enviada - notification_ids__len__gt=0 → libera os lembretes

A SDK firebase-admin>=7.1.0 (já instalada) oferece messaging.send_each(messages), que envia uma lista de até 500 Message distintas (cada uma com título/corpo/token próprios — necessário porque cada usuário recebe nome do hábito e variação de lembrete diferentes) numa operação, retornando BatchResponse com status por mensagem.

Objetivos

  • Hábitos e lembretes enviam push direto ao FCM em lote, sem criar Notification
  • Manter o model Notification + post_save intactos para áudio, admin e envios particulares
  • Substituir o gate baseado em notification_ids por campos próprios (is_notified + notified_at) em HabitExecution
  • Marcar PushDevices como expirado a partir do BatchResponse (paridade com o tratamento atual de token expirado)
  • Remover o campo notification_ids

Fora de escopo

apps/notifications/models.py, apps/notifications/signals.py, apps/notifications/services/notification.py e apps/notifications/tasks.py permanecem inalterados — continuam servindo o fluxo pontual de áudio/admin/particular.

Mudanças

1. Novo helper de envio em lote — apps/notifications/services/push_batch.py (novo)

  • Recebe uma lista de itens (device, title, body, data), monta os messaging.Message (token-targeted) ignorando devices sem token ou is_expired, e envia em chunks de 500 via messaging.send_each
  • Reaproveita NotificationService.ensure_firebase_initialized() e o gate settings.ENABLE_FIREBASE_NOTIFICATIONS
  • Inspeciona o BatchResponse: para respostas com UnregisteredError / InvalidArgumentError / NotFoundError, coleta os devices e marca is_expired=True / expired_at=now em bulk_update
  • Retorna um resumo (enviados / falhas) para log

2. apps/habits/models/execution.py

  • Adicionar is_notified = BooleanField(default=False, db_index=True)
  • Adicionar notified_at = DateTimeField(null=True, blank=True)
  • Remover notification_ids

3. Migrations — apps/habits/migrations/

  • Migration que adiciona is_notified e notified_at, com data migration de backfill: executions com notification_ids não vazio → is_notified=True (notified_at pode ficar nulo, pois o gate dos lembretes usa apenas is_notified)
  • Remoção do campo notification_ids (mesma migration ou subsequente)

4. apps/habits/services.py

  • HabitNotificationService.send_pending_notifications:
    • Trocar o filtro notification_ids__len=0 por is_notified=False
    • Adicionar prefetch_related('habit__user__push_devices')
    • Montar a lista (device, title, body, data) para todos os devices das execuções e chamar o helper de lote (sem Notification.objects.create)
    • Marcar is_notified=True / notified_at=now via bulk_update
  • ExecutionService._send_first_reminders: trocar notification_ids__len__gt=0 por is_notified=True
  • ExecutionService._bulk_send_reminders: montar a lista de mensagens e chamar o helper de lote (removendo Notification.objects.bulk_create e o loop send_notification.delay), mantendo a atualização de first/second/third/fourth_reminder e reminder_variation

5. Testes — tests/habits/

  • Atualizar os testes que hoje verificam criação de Notification / notification_ids para validar a chamada do envio em lote (mock de messaging.send_each) e os novos campos is_notified / notified_at
  • Cobrir: device sem token/expirado ignorado; marcação de device expirado a partir do BatchResponse; gate dos lembretes via is_notified

Como verificar

  • Unit tests dos services de hábito/lembrete passando com messaging.send_each mockado, verificando que nenhuma Notification é criada e que is_notified/notified_at são setados
  • Localmente com ENABLE_FIREBASE_NOTIFICATIONS=True: disparar send_habit_notifications e send_habit_reminders e confirmar uma chamada em lote ao FCM (não uma por device) e ausência de novas linhas em Notification
  • Confirmar que envios de áudio/admin continuam criando Notification e disparando via post_save
  • make seed e a suíte de testes de hábitos sem erro

Documentação