Tópicos de plataforma para notificações

TLDR: Adicionar o campo topics em PushDevices, criar um service puro para inscrever/desinscrever devices em tópicos FCM e fazer o backfill automático via data migration, cobrindo os tópicos de plataforma (novos) e os legados já gerenciados pelo mobile.

Contexto

Hoje os devices mobile assinam apenas três tópicos FCM (allTopic, user_<id>, refresh_home), todos via mobile. O admin precisa enviar notificações segmentadas por plataforma (apenas iOS ou apenas Android) sem fazer fanout manual sobre milhares de tokens — o uso natural para isso é o sistema de tópicos do FCM (envio único, o FCM cuida da entrega para todos os assinantes).

Objetivos

  • Rastrear em PushDevices.topics (ArrayField) os tópicos em que cada device está inscrito
  • Service puro para subscribe/unsubscribe (1 device, N tópicos) — reutilizável em qualquer fluxo futuro (signal, jornada, view)
  • Ignorar silenciosamente tokens mortos — não deletar do banco nesta spec
  • Backfill automático via data migration — popula o tópico de plataforma novo (com chamada ao FCM) e marca no banco os tópicos legados (all_<env>, refresh_home, user_<id>) que o mobile já gerencia
  • Script de teste versionado para subscribe + envio de notificação para um usuário específico via shell

Fora de escopo

Esta spec cobre apenas o lado backend. A subscrição automática no app no momento do registro (Platform.OS) e a UI de envio segmentado no admin ficam para specs separadas.

Mudanças

apps/notifications/models.py

Adicionar em PushDevices: python topics = ArrayField(models.CharField(max_length=100), blank=True, default=list, verbose_name="Tópicos")

apps/notifications/migrations/0016_pushdevices_topics.py (novo)

Schema migration que adiciona o campo topics com default list.

apps/notifications/migrations/0017_backfill_platform_topics.py (novo)

Data migration que, para cada PushDevices com token e platform em (ios, android): 1. Chama subscribe(device, [f"platform_{platform}"]) no FCM via service 2. Adiciona ao campo topics o platform_<os> + os tópicos legados que o mobile já gerencia: all_<env> (all_staging em staging, all em prod), refresh_home, user_<id> 3. Falhas FCM (firebase_exceptions.*, ValueError) são apenas logadas — o banco ainda recebe o estado pretendido 4. reverse_code zera topics (volta ao estado inicial)

apps/notifications/services/topic_subscription.py (novo)

Service puro, sem conhecimento de batch ou infraestrutura: - subscribe(device: PushDevices, topics: list[str]) -> bool — chama messaging.subscribe_to_topic([token], topic) por tópico; em sucesso, faz append idempotente em device.topics e salva; loga e retorna False em falha - unsubscribe(device: PushDevices, topics: list[str]) -> bool — análogo, removendo do array - Reutiliza NotificationService._ensure_firebase_initialized() para garantir o Firebase pronto

scripts/test_platform_topic_subscription.py (novo)

Script standalone para teste manual no shell: constantes USER_EMAIL e TARGET_PLATFORM no topo, busca os devices do usuário, inscreve em platform_<TARGET_PLATFORM> via service e envia uma messaging.Message(topic=...) para confirmar a entrega real.

tests/notifications/test_topic_subscription.py (novo)

Testes unitários Triple-A com firebase_admin.messaging mockado: - subscribe em sucesso adiciona o tópico em device.topics e persiste - subscribe é idempotente (chamar duas vezes não duplica) - subscribe ignora token rejeitado pelo FCM sem persistir - subscribe captura exceções (FirebaseError, ValueError) sem propagar - subscribe em device sem token retorna False - unsubscribe remove o tópico do array - subscribe em múltiplos tópicos persiste todos em sucesso

apps/common/management/commands/seed.py

Atualizar _seed_push_devices para criar PushDevices com topics populado realisticamente: platform_<os>, all_<env>, refresh_home, user_<id>.

Como verificar

  1. Migrations: python manage.py migrate notifications aplica 0016 (schema) e 0017 (backfill automático)
  2. Banco pós-backfill: PushDevices.objects.first().topics contém algo como ["all_staging", "platform_ios", "refresh_home", "user_42"]
  3. Idempotência: rodar migrate novamente é no-op e o estado do banco não muda
  4. Script de teste: ajustar email/plataforma e rodar python manage.py shell < scripts/test_platform_topic_subscription.py; confirmar recebimento no device físico
  5. Testes: pytest tests/notifications/test_topic_subscription.py — todos passam
  6. Seed: make seed roda sem erro e os devices criados têm topics populado

Documentação