Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,22 @@ Quando uma demanda de um executor fica atrasada (data no passado e ainda não co

- `TelegramNotifier` (`app/services/telegram_notifier.rb`) monta a mensagem e chama a API do Telegram (`sendMessage`) via `Net::HTTP` puro, sem depender de nenhuma gem adicional.
- A mensagem é empática e objetiva: cita o título da demanda e há quantos dias está atrasada, sem tom de cobrança, e diz o que fazer a seguir (atualizar o status, ou avisar o líder se precisar de mais tempo/ajuda).
- Como "ficar atrasada" é um estado que muda com o tempo (não com uma ação do usuário), o envio não acontece automaticamente na aplicação — é a tarefa `bin/rails demandas:notificar_atrasos` (`lib/tasks/telegram_notifications.rake`) que precisa rodar periodicamente (ex.: 1x ao dia). No Railway, isso é feito criando um segundo serviço do tipo **Cron Job** no mesmo projeto, rodando esse comando.
- Como "ficar atrasada" é um estado que muda com o tempo (não com uma ação do usuário), o envio não acontece automaticamente na aplicação — é a tarefa `bin/rails demandas:notificar_atrasos` (`lib/tasks/telegram_notifications.rake`) que precisa rodar periodicamente.
- Cada atraso é notificado **uma única vez** (campo `Demanda#atraso_notificado_em`) — se a demanda deixar de estar atrasada (data adiada ou marcada como concluída) e depois atrasar de novo, um novo aviso é enviado.
- Sem `TELEGRAM_BOT_TOKEN` configurado, ou sem `telegram_chat_id` no usuário, a notificação é simplesmente pulada (não é um erro).

### Agendamento (parte do deploy, não detalhe da task)

A tarefa não roda sozinha: **um deploy sem agendamento tem a funcionalidade desligada**, e nada na aplicação avisa isso. Uma vez por dia, em horário comercial do fuso da equipe, é a cadência para a qual a mensagem foi escrita ("está há N dias com o prazo vencido").

No **Railway**, isso é um segundo serviço do tipo **Cron Job** no mesmo projeto, apontando para o mesmo repositório e rodando `bin/rails demandas:notificar_atrasos`. Em `docker compose`, um `cron` do host chamando `docker compose exec web bin/rails demandas:notificar_atrasos`.

Existe hoje uma terceira via que não existia quando a task foi escrita: desde a adoção do Solid Queue (ver "Webhooks de saída"), o projeto tem `config/recurring.yml` e um processo `worker` de pé, que agendaria isso sem scheduler externo nenhum. É a evolução natural — exigiria embrulhar a task num job — e ainda não foi feita.

**A garantia é "no máximo uma vez", e ela é do banco, não da disciplina de quem agenda.** A task reivindica cada demanda com um `UPDATE ... WHERE atraso_notificado_em IS NULL` **antes** de enviar: quem escreve a linha é quem envia. Duas execuções simultâneas — cron disparado duas vezes, retry do scheduler, uma réplica a mais — não notificam a mesma demanda duas vezes, e um processo que morra depois do envio não faz a execução seguinte repetir a mensagem.

O preço é que uma notificação pode ser *gasta sem sair*, se o processo morrer entre a reivindicação e o envio. Uma falha declarada do envio (o Telegram respondeu erro) devolve a demanda para a próxima execução; uma queda no meio, não. Perder um lembrete numa queda é melhor do que mandar o mesmo lembrete duas vezes em toda execução concorrente.

## Webhooks de saída

Além do Telegram, o **admin** pode cadastrar webhooks genéricos em `/webhooks` — uma URL que recebe um `POST` com JSON toda vez que um dos eventos escolhidos acontece. Serve tanto pra notificar um canal de chat (Slack/Teams/Discord, apontando pra um webhook incoming deles) quanto pra disparar uma automação no-code (n8n, Knime) — o mecanismo é o mesmo, só muda quem recebe o `POST`.
Expand Down
52 changes: 49 additions & 3 deletions lib/tasks/telegram_notifications.rake
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,51 @@
# `bin/rails demandas:notificar_atrasos`. Não é chamado automaticamente
# pela aplicação, porque "ficar atrasada" é um estado que só muda com a
# passagem do tempo, não com uma ação do usuário — precisa de alguém
# perguntando periodicamente "o que está atrasado agora?".
# perguntando periodicamente "o que está atrasado agora?". Ver README,
# seção "Notificação de atraso via Telegram".
namespace :demandas do
desc 'Envia um lembrete no Telegram para o responsável de cada demanda atrasada (uma vez por atraso)'
task notificar_atrasos: :environment do
# Reivindica a demanda ANTES de enviar, com um UPDATE condicional:
# quem escreve a linha é quem envia. É isso que torna "uma vez por
# atraso" — a promessa do README — verdade também fora do caminho
# feliz de execução única.
#
# Antes a ordem era selecionar, enviar, e só então gravar. Duas
# janelas de duplicação vinham daí: duas execuções simultâneas (cron
# disparado duas vezes, retry do scheduler, réplica extra) selecionavam
# a mesma demanda antes de qualquer gravação e notificavam as duas; e
# uma morte de processo entre o envio e a gravação fazia a execução
# seguinte notificar de novo.
#
# O UPDATE ... WHERE atraso_notificado_em IS NULL resolve as duas de
# uma vez, porque a checagem e a escrita acontecem no mesmo comando: o
# banco decide quem venceu, e só o vencedor recebe 1 linha afetada.
reivindicar = lambda do |demanda|
Demanda.where(id: demanda.id, atraso_notificado_em: nil)
.update_all(atraso_notificado_em: Time.current) == 1
end

# Devolve a reivindicação quando o envio falha de forma declarada
# (TelegramNotifier#notify_atraso devolveu false), para que a próxima
# execução tente de novo — é o comportamento que já existia, e não há
# motivo para perdê-lo junto com a correção da corrida.
#
# O que NÃO é devolvido: uma demanda cujo processo morra entre a
# reivindicação e o envio. Aquela notificação é gasta sem sair, e essa
# é a troca consciente de reivindicar antes de enviar. Perder um
# lembrete numa queda é melhor que mandar o mesmo lembrete duas vezes
# em toda execução concorrente.
#
# Sobra uma janela estreita: um transporte que devolva false depois de
# a mensagem ter sido entregue (timeout na leitura da resposta, por
# exemplo) faz a próxima execução notificar de novo. É estritamente
# menor que a janela de antes, e o modo de falha é o mesmo que já
# existia.
devolver = lambda do |demanda|
Demanda.where(id: demanda.id).update_all(atraso_notificado_em: nil)
end

if ENV['TELEGRAM_BOT_TOKEN'].blank?
puts 'TELEGRAM_BOT_TOKEN não configurado — nada a fazer.'
next
Expand All @@ -31,11 +72,16 @@ namespace :demandas do
next
end

unless reivindicar.call(demanda)
puts "Pulei ##{demanda.id} (#{demanda.title}): outra execução reivindicou a notificação antes."
next
end

if TelegramNotifier.notify_atraso(demanda)
demanda.update_column(:atraso_notificado_em, Time.current)
puts "Notifiquei ##{demanda.id} (#{demanda.title}) -> #{demanda.user.name}"
else
puts "Falha ao notificar ##{demanda.id} (#{demanda.title})"
devolver.call(demanda)
puts "Falha ao notificar ##{demanda.id} (#{demanda.title}) — devolvida para a próxima execução."
end
end
end
Expand Down
52 changes: 52 additions & 0 deletions spec/tasks/telegram_notifications_rake_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,58 @@
expect(demanda.reload.atraso_notificado_em).to be_nil
end

# A marca passou a ser escrita ANTES do envio (ver o comentário na
# task): é isso que impede duas execuções simultâneas de notificarem a
# mesma demanda. Sem a devolução aqui verificada, uma falha de rede
# gastaria a notificação para sempre.
it 'devolve a demanda para a próxima execução quando o envio falha' do
responsavel = create(:user, :executor, telegram_chat_id: '123456')
demanda = create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente)

allow(TelegramNotifier).to receive(:notify_atraso).and_return(false)
task.invoke

allow(TelegramNotifier).to receive(:notify_atraso).and_return(true)
task.reenable
task.invoke

expect(demanda.reload.atraso_notificado_em).to be_present
end

it 'reivindica a demanda antes de enviar, e não depois' do
responsavel = create(:user, :executor, telegram_chat_id: '123456')
create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente)

marca_no_momento_do_envio = nil
allow(TelegramNotifier).to receive(:notify_atraso) do |demanda|
marca_no_momento_do_envio = Demanda.where(id: demanda.id).pick(:atraso_notificado_em)
true
end

task.invoke

expect(marca_no_momento_do_envio).to be_present
end

# A corrida de verdade: outra execução da task selecionou a mesma
# demanda antes de qualquer gravação e só agora tenta reivindicá-la. O
# UPDATE condicional é quem decide — ela precisa perder.
it 'faz uma execução concorrente perder a corrida em vez de notificar de novo' do
responsavel = create(:user, :executor, telegram_chat_id: '123456')
create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente)

linhas_da_concorrente = nil
allow(TelegramNotifier).to receive(:notify_atraso) do |demanda|
linhas_da_concorrente = Demanda.where(id: demanda.id, atraso_notificado_em: nil)
.update_all(atraso_notificado_em: Time.current)
true
end

task.invoke

expect(linhas_da_concorrente).to eq(0)
end

it 'não notifica de novo uma demanda que já foi notificada' do
responsavel = create(:user, :executor, telegram_chat_id: '123456')
create(:demanda, user: responsavel, data: 3.days.ago.to_date, status: :pendente,
Expand Down
Loading