Unsubscribe de tópicos FCM ao expirar dispositivo

TLDR: Sempre que um PushDevices for marcado como expirado, remover seu token dos tópicos FCM (ex.: all), para acabar com a duplicação de notificações do canal “All”.

Contexto

Notificações do canal “All” chegam duplicadas (às vezes triplicadas) nos aparelhos.

O canal “All” é enviado como mensagem para o tópico FCM all (all_staging em staging) — o Firebase faz o fan-out para todos os tokens inscritos no tópico (apps/notifications/services/notification.py:78-79,90-91). Esse envio ignora a flag is_expired do banco, porque quem mantém a lista de inscritos é o Firebase, não o nosso banco.

Hoje, quando um device é marcado como is_expired=True, o token continua inscrito no tópico all no lado do Firebase — nada chama o unsubscribe. Enquanto o token antigo ainda for entregável pelo FCM (tokens rotacionados seguem válidos por um período), o mesmo aparelho recebe a notificação em cada token que continua no tópico → 2 ou 3 cópias.

A função unsubscribe() já existe (apps/notifications/services/topic_subscription.py:72-98), mas é código morto — só é referenciada em teste, nunca em produção.

Existem 4 pontos que marcam is_expired=True e nenhum faz unsubscribe:

  1. apps/notifications/tasks.py:63-71 — save_device, ao registrar novo device_id, expira em massa os devices antigos do mesmo usuário/plataforma. É o ponto que mais causa duplicação (o token antigo em geral ainda está vivo)
  2. apps/notifications/services/notification.py:161-166 — erro de envio direto (token morto)
  3. apps/notifications/services/push_batch.py:78-83 — erro em lote (token morto)
  4. apps/notifications/services/topic_subscription.py:57-63 — rejeição no próprio subscribe (token morto)

Nos pontos 2, 3 e 4 o token já foi rejeitado pelo FCM (morto), então o unsubscribe é praticamente inócuo — mas por consistência todos passam pelo mesmo helper.

Objetivos

  • Ao expirar um device, remover seu token dos tópicos FCM em que ele está inscrito
  • Reaproveitar a função unsubscribe já existente, chamando-a em cada ponto que marca is_expired (sem helper novo)
  • Eliminar a duplicação de notificações do canal “All” na origem (tokens novos que se acumulam no tópico)

Fora de escopo

  • Task periódica (Celery beat) de reconciliação do passivo já órfão — decisão do usuário: não criar
  • Alterações no app (onion-app) — a inscrição direta no tópico no login é redundante, mas não é o multiplicador
  • Observação (não corrigido aqui): o caminho de “reativação” do save_device (tasks.py:55-61) troca o token na mesma linha sem reinscrever o token novo nem remover o antigo. Como ele não marca como expirado, fica fora do princípio desta spec

Mudanças

Abordagem: sem helper novo. Em cada ponto que marca is_expired, chamar a função unsubscribe(device, device.topics) já existente em topic_subscription.py.

  • apps/notifications/tasks.py — save_device: trocar o .update(is_expired=True, ...) em massa por iteração dos devices antigos — para cada device, unsubscribe(device, device.topics) e marcar is_expired
  • apps/notifications/services/notification.py — no except de token inválido, chamar unsubscribe(push_device, push_device.topics) (import lazy para evitar import circular com topic_subscription)
  • apps/notifications/services/push_batch.py — _mark_expired_by_token: buscar os devices por token e, para cada um, unsubscribe(device, device.topics) e marcar is_expired (import lazy)
  • apps/notifications/services/topic_subscription.py — na rejeição dentro do subscribe, chamar unsubscribe(device, device.topics) (mesmo módulo)

Nota de import: topic_subscription importa NotificationService; para não criar ciclo, os usos em notification.py e push_batch.py fazem import lazy de unsubscribe dentro da função.

Fases de implementação

  1. test: cobrir que expirar um device chama unsubscribe_from_topic do FCM para o token — arquivos: tests/notifications/test_expired_fcm_token.py, tests/notifications/test_topic_subscription.py
    • save_device com novo device_id: device antigo é expirado e desinscrito dos tópicos
    • NotificationService.send() com token inválido: device expirado e desinscrito
    • push_batch._mark_expired_by_token: devices expirados e desinscritos
    • topic_subscription.subscribe com rejeição: device expirado e desinscrito
    • expire_device com Firebase desabilitado: marca expirado sem quebrar
  2. fix: chamar unsubscribe(device, device.topics) nos 4 pontos que marcam is_expired
  3. refactor: garantir ausência de import circular e limpar
  4. perf: (pular) sem gargalo medido; o unsubscribe em lote poderia ser agrupado, mas não há necessidade agora

Como verificar

  • pytest tests/notifications verde
  • Manual: registrar o device A (token1) → inscrito em all; registrar novo device_id (token2) para o mesmo usuário → o device A é expirado e messaging.unsubscribe_from_topic é chamado para token1; enviar para o canal “All” → cada aparelho físico recebe uma cópia

Documentação