## MG-6 — Sincronização e lembretes no Cloud Scheduler

**PRD:** `docs/prd/migracao-firebase.md`

**Como** participante, **quero** que o placar chegue sozinho e o lembrete de prazo
me alcance, **para** não perder rodada por não ter aberto o app.

**Prioridade:** Must — é a task de infraestrutura que ficou aberta desde o começo,
e é a que mais facilmente falha em silêncio.

**Critérios de aceite:**

- Dado que a fonte oficial mudou horário ou placar, quando a sincronização roda,
  então a mudança é aplicada e os gatilhos de `PP-6` e `PR-6` disparam.
- Dado que a fonte está indisponível, então **nada** do que já está armazenado é
  sobrescrito, e a falha é registrada.
- Dado que duas sincronizações concorrem, então só uma executa — a outra é
  ignorada, sem duplicar correção nem aviso.
- Dado que o job ficou horas parado, então a primeira execução após a volta aplica
  **todas** as mudanças acumuladas.
- Dado que o prazo de um jogo está próximo e não palpitei, então recebo lembrete —
  e **não** recebo se já palpitei, nem duas vezes pelo mesmo jogo.
- Dado que saí ou fui removido do bolão, então paro de receber lembrete dele.
- Dado que o processo não roda, então isso **aparece** — job dormindo em silêncio é
  o modo de falha mais caro deste produto.

**Tasks:**

_Backend_
- [x] `BE-1` Function agendada de sincronização com a `football-data.org`, reusando o adaptador atual (depende de MG-2 BE-1)
- [x] `BE-2` Lock em documento com transação, no lugar da trava em processo (depende de BE-1)
- [ ] `BE-3` Function agendada de lembrete de prazo, idempotente por jogo e participante (depende de MG-2 BE-1)
- [ ] `BE-4` Push por FCM no lembrete, com o aviso gravado mesmo sem push (depende de BE-3)
- [x] `BE-5` Tolerância a falha: erro da fonte não sobrescreve dado bom (depende de BE-1)
- [x] `BE-6` Respeitar o limite de 10 req/min da fonte, mantendo cache e cota (depende de BE-1)

_Mobile_
- [ ] `FE-1` Pedir permissão de notificação sem travar o app quando negada (depende de BE-4)
- [ ] `FE-2` Toque no lembrete abre a tela de palpite do bolão certo, com o app fechado (depende de BE-4)

_DevOps_
- [ ] `OPS-1` Cloud Scheduler configurado: sincronização horária, lembrete a cada 10 min
- [ ] `OPS-2` Alarme de job que parou de rodar — silêncio não pode ser confundido com sucesso
- [ ] `OPS-3` Credenciais do FCM e da fonte fora do repositório

_QA_
- [ ] `QA-1` Escrever o plano de casos de teste a partir desta story
- [ ] `QA-2` Executar reaproveitando `PP-6`, `PP-8` e `PP-10`, incluindo concorrência e job parado

**Riscos/Dependências:**

- Depende de `MG-2`. **Exige Blaze**: Scheduler e Functions agendadas não existem
  no plano gratuito.
- **Fecha `PP-10 OPS-1`** (hospedagem que não hiberne) e as 4 tasks de `PP-8`
  presas ao FCM.
- A trava de concorrência de 07/08 é **em processo** e deixa de servir: cada
  invocação é um processo novo. Sem `BE-2`, o defeito que já foi corrigido volta.
- iOS continua sem push: o APNs exige Apple Developer Program pago.

- **07/08 — 4 de 6 tasks de backend feitas.** Execução em
  `docs/testes/MG-6-execucao.md`. Suíte do Firebase em **132** casos. `BE-3`
  (lembrete) e `BE-4` (push) seguem abertas, e com elas as `FE-*` e `OPS-*`: push
  depende de app registrado no projeto real, que é `MG-7`.
- **O adaptador da fonte real foi portado com os 13 casos de teste**, sem alterar um
  caso: cota de 10 req/min, cache de 5 min, payload malformado, timeout, 403 e 429.
- **A trava em processo deixou de existir e virou lock em documento.** Em Functions
  cada invocação é um processo novo, e duas podem estar em máquinas diferentes —
  transação no Firestore é o único jeito de as duas concordarem sobre quem chegou
  primeiro. Com validade, porque Function que morre no meio deixaria o lock preso e a
  sincronização pararia **em silêncio**, o modo de falha que esta story existe para
  eliminar.
- **Meu teste afirmava o mecanismo quando o que importa é o invariante.** Ele exigia
  "uma das duas é ignorada", e falhou porque com a fonte instantânea a primeira
  termina e solta o lock antes de a segunda tentar de novo — as duas rodam em
  sequência, o que é legítimo (a segunda não acha nada para mudar). Ficaram os dois
  casos: o invariante (uma alteração no total) e o mecanismo (com fonte lenta,
  sobreposição real, a segunda é ignorada).
- **Acoplamento que o teste expôs:** `sincronizar()` dependia do app global do Admin
  SDK. A instância do Firestore passou a entrar por parâmetro.
- **Decisão registrada:** a sincronização não apura. Ela escreve o jogo e os gatilhos
  de `MG-5` e `MG-3` reagem — se ela apurasse, existiriam dois caminhos para a mesma
  regra e um envelheceria, e a correção manual do admin (que entra pela mesma porta)
  teria comportamento diferente.
