Tratamento resiliente de token FCM expirado

TLDR: Marcar PushDevices como expirado quando o FCM rejeitar o token e fazer o app re-registrar automaticamente quando o token FCM rotacionar, evitando envios para tokens mortos e restabelecendo a comunicação de forma transparente.

Contexto

Hoje o backend não tem como saber que um token FCM armazenado deixou de ser válido:

  • PushDevices (apps/notifications/models.py:9-22) só guarda token, device_id, platform, etc. — sem flag de expiração
  • NotificationService.send() (apps/notifications/services/notification.py:143-166) captura firebase_exceptions.FirebaseError de forma genérica, grava error_message na Notification e segue tentando em todos os envios futuros
  • topic_subscription.py loga response.failure_count, mas nunca limpa o token problemático

No lado do app (onion-app/services/push.ts):

  • registerDevice() é chamado apenas após login bem-sucedido (stores/loginStore.ts:151)
  • Não existe listener onTokenRefresh nem revalidação ao voltar para foreground
  • Se o FCM rotacionar o token no meio da sessão (reinstalação do SO, limpeza de dados do app, longa inatividade), o backend continua usando o token velho indefinidamente

Resultado: falha silenciosa na entrega de push, sem caminho de recuperação.

Objetivos

  • Backend consegue marcar uma linha de PushDevices como expirada quando o FCM rejeita o token
  • Queries de envio de push ignoram devices expirados
  • O app re-registra o device de forma proativa quando o token FCM muda
  • Reativação automática: quando o app reenvia o mesmo device_id com token novo, a linha volta a ficar ativa

Fora de escopo

— (não registrado na spec original)

Mudanças

onion-backend (Django)

  • apps/notifications/models.py — adicionar em PushDevices:
    • is_expired: BooleanField(default=False, db_index=True)
    • expired_at: DateTimeField(blank=True, null=True)
  • Nova migration em apps/notifications/migrations/ para as duas colunas
  • apps/notifications/services/notification.py — em send():
    • Capturar firebase_admin.messaging.UnregisteredError e firebase_admin.exceptions.InvalidArgumentError separadamente
    • Quando levantado num envio para token individual, setar is_expired=True e expired_at=timezone.now() no PushDevices correspondente
    • Manter o branch genérico de FirebaseError para os demais casos
  • apps/notifications/services/topic_subscription.py — quando o FCM retornar falhas por token com unregistered/invalid-argument, marcar esses PushDevices como expirados (caminho em lote)
  • apps/notifications/services/notification.py (resolução de alvo) — filtrar is_expired=False ao selecionar devices/tokens para envio
  • apps/notifications/tasks.py — save_device reativa a linha no re-registro: quando já existe um PushDevices com o mesmo device_id+platform+user e chega um token novo, setar is_expired=False, expired_at=None e atualizar token
  • Testes em apps/notifications/tests/ cobrindo: migration aplica sem erro; UnregisteredError marca o device como expirado; devices expirados são excluídos dos envios; re-registro com token novo reativa a linha; PushDevicesFactory com traits opcionais is_expired/expired_at

onion-app (Expo / React Native)

  • services/push.ts:
    • Helpers getStoredFCMToken() / setStoredFCMToken() usando AsyncStorage na chave @onion/fcm-token
    • Novo ensureDeviceRegistered(): verifica autenticação, lê o token salvo, chama getFCMToken() e, se diferirem (ou o salvo for nulo), chama registerDevice() e atualiza o storage no sucesso
    • registerDevice() grava o token recém-registrado no storage no sucesso
  • app/_layout.tsx — registrar três pontos de disparo para ensureDeviceRegistered():
    1. Cold start — chamada única no mount do layout (cobre usuário já logado abrindo o app do zero)
    2. Volta de background — listener de AppState que dispara na transição para active (removido no unmount)
    3. Login — manter a chamada existente em stores/loginStore.ts:151
  • Testes em services/__tests__/push.test.ts cobrindo cold start com e sem token salvo, foreground com e sem rotação de token e usuário deslogado

Como verificar

Teste manual end-to-end:

  1. Caminho de rejeição no backend
    • Subir o backend localmente (make up) e rodar migrations (make migrate)
    • Autenticar um usuário e fazer POST /notifications/push/register com um token deliberadamente inválido e um device_id real
    • Disparar um envio para esse usuário
    • Verificar no banco que a linha correspondente tem is_expired=True e expired_at preenchido
    • Disparar outro envio e confirmar que a linha é filtrada
  2. Caminho de reativação no backend
    • Re-fazer POST /notifications/push/register com o mesmo device_id e um token novo
    • Confirmar que a linha tem token atualizado, is_expired=False e expired_at=None
  3. Re-registro do app em foreground
    • Instalar o app, fazer login e confirmar o token registrado no backend
    • No build de dev, sobrescrever o valor do AsyncStorage @onion/fcm-token e alternar o app entre background e foreground
    • Confirmar que um novo POST /notifications/push/register é disparado
  4. Cold start com usuário já logado
    • Com a sessão persistida, forçar a expiração do PushDevices ou sobrescrever o @onion/fcm-token
    • Matar o app completamente e abrir do zero
    • Confirmar que ensureDeviceRegistered() dispara no mount e a linha é reativada/atualizada

Documentação