From a6fd505223a7b5066700d379a3bea616cc0c177b Mon Sep 17 00:00:00 2001 From: Hirley Date: Mon, 31 Aug 2026 21:51:50 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Corrige=20coment=C3=A1rio=20do=20WebhookDel?= =?UTF-8?q?iveryJob=20que=20ficou=20desatualizado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O cabeçalho da classe ainda dizia que o adapter era o :async e que trocá-lo por um backend persistente seria "assunto de outra issue" — mas essa outra issue (#77) já foi entregue, e em produção a fila é o Solid Queue desde então. Um comentário que descreve o código anterior é pior que nenhum: quem lesse este ia concluir que as tentativas reagendadas ainda se perdem num deploy, que é justamente o que deixou de acontecer. Co-Authored-By: Claude Opus 5 --- app/jobs/webhook_delivery_job.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/jobs/webhook_delivery_job.rb b/app/jobs/webhook_delivery_job.rb index 413012f..71fcd6e 100644 --- a/app/jobs/webhook_delivery_job.rb +++ b/app/jobs/webhook_delivery_job.rb @@ -4,12 +4,12 @@ # resposta de um serviço de terceiro. Ver WebhookDispatcher (quem # enfileira) e WebhookDelivery (quem sabe fazer o POST de fato). # -# O adapter ainda é o padrão do Rails (:async), que guarda a fila na -# memória do processo web — jobs pendentes se perdem num restart, e as -# tentativas reagendadas abaixo também. Trocar por um backend persistente -# é assunto de outra issue; o retry aqui é útil de qualquer forma, porque -# a maioria das instabilidades de rede passa em segundos, muito antes de -# um deploy. +# Em produção o adapter é o Solid Queue, que guarda a fila no próprio +# banco (ver config/environments/production.rb): as tentativas +# reagendadas abaixo sobrevivem a restart e deploy, que é justamente o +# que o antigo adapter :async — fila na memória do processo web — não +# fazia. O backoff polinomial da quinta tentativa passa longe do tempo de +# um deploy, então isso não é detalhe. class WebhookDeliveryJob < ApplicationJob queue_as :default From 511d627b89110e65353b42cdaad9009bfa8b17ca Mon Sep 17 00:00:00 2001 From: Hirley Date: Mon, 31 Aug 2026 21:52:17 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Tira=20as=20integra=C3=A7=C3=B5es=20com=20o?= =?UTF-8?q?=20Telegram=20da=20requisi=C3=A7=C3=A3o=20do=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duas telas falavam com a API do Telegram dentro do ciclo de request/response: o envio do relatório semanal (que ainda gerava o PDF antes) e a redefinição de senha por Telegram. A latência das duas dependia do tempo de resposta de um serviço de terceiro, e com RAILS_MAX_THREADS no default de 5 poucos pedidos presos bastavam para degradar a aplicação inteira — inclusive telas sem relação nenhuma com Telegram. Agora as duas enfileiram (RelatorioSemanalTelegramJob e TelegramPasswordResetJob) e respondem na hora. Como a fila é persistente desde a #77, o que sai da requisição não vira o que se perde num restart. Decisões que valem registro: A checagem "dá pra enviar?" ficou na TELA, não no job. Os dois motivos previsíveis de falha (servidor sem TELEGRAM_BOT_TOKEN, usuário sem Chat ID) são exatamente o que o alerta da tela explica, e nenhum dos dois precisa tocar a rede — então o aviso continua imediato e útil. Virou TelegramNotifier#pode_enviar_para?, que também passou a ser a guarda única dos três métodos de envio, no lugar da mesma condição repetida três vezes. O que se perde com isso: uma falha no envio em si (Telegram fora do ar, timeout) não aparece mais na tela — quem pediu vê "chega em instantes" e nada chega; fica no log do job em nível warn. Mostrar isso de volta exigiria persistir o resultado de cada envio e uma tela para consultar o status, desproporcional para o caso raro; a alternativa era continuar prendendo um worker do Puma por causa dele. Na redefinição de senha, enfileirar REFORÇA a resposta genérica em vez de enfraquecê-la. Antes, o e-mail cadastrado com Chat ID esperava o Telegram responder e o e-mail desconhecido voltava na hora: a mensagem era a mesma, mas o cronômetro entregava a diferença. Agora os dois caminhos fazem um SELECT, e um deles um INSERT. Foi descartado enfileirar sempre, passando o e-mail digitado para o job (resposta rigorosamente constante): guardaria e-mail arbitrário de visitante anônimo na tabela de jobs e deixaria o formulário encher a fila — e o throttle por IP/e-mail já é o que barra esse uso. Relatorios::GerarPdfSemanal nasceu porque os dois caminhos que geram o PDF de verdade (o download, que segue síncrono porque o PDF É a resposta, e o envio por Telegram, agora no job) precisam fazer exatamente a mesma coisa: montar os dados, anunciar "relatorio_gerado" e renderizar. Repetir a montagem do payload nos dois era garantia de eles divergirem no primeiro campo novo. Verificado no container: RuboCop 110 arquivos sem ofensas, zeitwerk:check limpo, RSpec 384 exemplos e 0 falhas. Closes #78 Co-Authored-By: Claude Opus 5 --- README.md | 16 +++- app/controllers/relatorios_controller.rb | 84 +++++++++---------- .../telegram_password_resets_controller.rb | 19 ++++- app/jobs/relatorio_semanal_telegram_job.rb | 43 ++++++++++ app/jobs/telegram_password_reset_job.rb | 31 +++++++ app/services/relatorios/gerar_pdf_semanal.rb | 45 ++++++++++ app/services/relatorios/semanal_pdf.rb | 9 ++ app/services/telegram_notifier.rb | 26 ++++-- .../relatorio_semanal_telegram_job_spec.rb | 52 ++++++++++++ spec/jobs/telegram_password_reset_job_spec.rb | 35 ++++++++ spec/requests/relatorios_spec.rb | 47 ++++++----- .../requests/telegram_password_resets_spec.rb | 29 ++++--- 12 files changed, 353 insertions(+), 83 deletions(-) create mode 100644 app/jobs/relatorio_semanal_telegram_job.rb create mode 100644 app/jobs/telegram_password_reset_job.rb create mode 100644 app/services/relatorios/gerar_pdf_semanal.rb create mode 100644 spec/jobs/relatorio_semanal_telegram_job_spec.rb create mode 100644 spec/jobs/telegram_password_reset_job_spec.rb diff --git a/README.md b/README.md index 980bc02..9864959 100644 --- a/README.md +++ b/README.md @@ -189,11 +189,11 @@ A suíte RSpec cobre: - **Models**: `User` e `Demanda` (`spec/models`), incluindo a validação do `telegram_chat_id`, o reset de `atraso_notificado_em`, o default de `must_change_password` e o `#reset_password` sobrescrito; - **Política de autorização**: `Ability` (`spec/models/ability_spec.rb`), validando cada combinação de papel (executor/líder/admin) × ação para `Demanda`, `User` e `WebhookSubscription`; - **Serviços** (`spec/services`): `TelegramNotifier` — mensagem, envio (incluindo `#enviar_documento`, usado pelo relatório semanal, e `#enviar_redefinicao_senha`, usado pela redefinição de senha por Telegram) e os casos de "não enviar" (sem token, sem chat_id, erro de rede), usando um dublê de transporte HTTP injetado no serviço (sem depender de gem de mock de rede); `WebhookDelivery`/`WebhookDispatcher` — montagem do payload, entrega (com o mesmo padrão de dublê de transporte), e quais assinaturas são notificadas por evento; `Users::Destroy` — a regra de exclusão de usuário (exclusão da própria conta/demandas vinculadas), testada uma única vez e reaproveitada pela tela web e pela API; `Users::SendPasswordResetViaTelegram` — geração do token de redefinição e montagem do link, validando que o Devise reconhece o token gerado; `Relatorios::Semanal` — período considerado, filtro por período/status das demandas criadas/concluídas, contagens e carga por responsável; -- **Job** (`spec/jobs`): `WebhookDeliveryJob` — busca a assinatura e delega a entrega, sem quebrar se ela já não existir mais; +- **Jobs** (`spec/jobs`): `WebhookDeliveryJob` — busca a assinatura, delega a entrega e decide quando reagendar, sem quebrar se ela já não existir mais; `RelatorioSemanalTelegramJob` — gera o PDF, envia pro Chat ID de quem pediu, dispara `relatorio_gerado` e registra no log quando o envio falha; `TelegramPasswordResetJob` — delega pro serviço de envio, e não gera token nenhum se o usuário sumiu ou perdeu o Chat ID entre o pedido e a execução; - **Tarefa agendada**: a rake task `demandas:notificar_atrasos` (`spec/tasks`) — idempotência, filtro por status/data/chat_id cadastrado; - **API** (`spec/requests/api/v1`): `demandas` e `users`; -- **Telas web** (`spec/requests`): `demandas` (menu Demandas, incluindo filtro por múltiplos status/termos), `users` (menu Acessos, incluindo filtro múltiplo, ordenação por todas as colunas, e a restrição de `telegram_chat_id` a admin), `webhooks` (menu Webhooks — acesso restrito ao admin, cadastro/edição/exclusão, bloqueio de URL privada/local), `dashboard` (painel inicial/Início), `relatorios` (menu Relatórios — acesso restrito a líder/admin, download do PDF, envio por Telegram) e a página pública `/acessibilidade`; -- **Primeiro acesso e redefinição de senha** (`spec/requests`): `definir_senha_spec.rb` — redirecionamento obrigatório enquanto `must_change_password` for `true`, formulário, sucesso/falha de validação, e que o logout continua funcionando nesse estado; `telegram_password_resets_spec.rb` — aciona (ou não) `Users::SendPasswordResetViaTelegram` conforme o e-mail/Chat ID cadastrados, sempre com a mesma mensagem genérica; `esqueci_minha_senha_spec.rb` — links na tela de login e o e-mail de redefinição do Devise sendo efetivamente enviado, com um link válido; +- **Telas web** (`spec/requests`): `demandas` (menu Demandas, incluindo filtro por múltiplos status/termos), `users` (menu Acessos, incluindo filtro múltiplo, ordenação por todas as colunas, e a restrição de `telegram_chat_id` a admin), `webhooks` (menu Webhooks — acesso restrito ao admin, cadastro/edição/exclusão, bloqueio de URL privada/local), `dashboard` (painel inicial/Início), `relatorios` (menu Relatórios — acesso restrito a líder/admin, download do PDF, e o envio por Telegram sendo **enfileirado** sem gerar o PDF dentro da requisição) e a página pública `/acessibilidade`; +- **Primeiro acesso e redefinição de senha** (`spec/requests`): `definir_senha_spec.rb` — redirecionamento obrigatório enquanto `must_change_password` for `true`, formulário, sucesso/falha de validação, e que o logout continua funcionando nesse estado; `telegram_password_resets_spec.rb` — enfileira (ou não) `TelegramPasswordResetJob` conforme o e-mail/Chat ID cadastrados, sempre com a mesma mensagem genérica, e sem falar com o Telegram dentro da requisição; `esqueci_minha_senha_spec.rb` — links na tela de login e o e-mail de redefinição do Devise sendo efetivamente enviado, com um link válido; - **Traduções pt-BR** (`spec/requests/devise_i18n_spec.rb`): regressão para a mensagem `Translation missing` do Devise (ver seção "Mensagens em pt-BR"). - **Compatibilidade do Devise com Turbo Drive** (`spec/requests/devise_turbo_spec.rb`): regressão para um login inválido responder `200` em vez de `422` (ver "Devise + Turbo Drive" abaixo). @@ -273,6 +273,8 @@ Além do Telegram, o **admin** pode cadastrar webhooks genéricos em `/webhooks` Em **produção** a fila é o **Solid Queue**, gravando nas tabelas `solid_queue_*` do próprio PostgreSQL da aplicação (ver `config/environments/production.rb` e `config/queue.yml`). Isso exige um **processo separado** rodando `bin/jobs` — é o serviço `worker` do `docker-compose.yml`. Sem ele os jobs ficam enfileirados no banco esperando, em vez de sumir. + A mesma fila carrega tudo que sai da requisição, não só webhook: `RelatorioSemanalTelegramJob` (envio do relatório semanal) e `TelegramPasswordResetJob` (link de redefinição de senha por Telegram) também dependem do `worker` estar de pé. Num deploy sem ele, essas duas telas respondem normalmente e **nada é entregue** — os jobs se acumulam em `solid_queue_jobs` até alguém subir o processo, e aí saem todos. + O default do Rails, `:async`, guarda a fila na **memória do processo web**: todo restart, deploy ou OOM descartava em silêncio o que ainda não tinha rodado — incluindo as retentativas de webhook agendadas com backoff, que por definição ficam pendentes por algum tempo. Em **desenvolvimento** o `:async` continua valendo, pra que `bin/rails server` sozinho siga funcionando sem exigir um segundo processo; em **teste**, o adapter é o `:test`. Optamos pelo mesmo banco da aplicação, e não pelo banco separado que o instalador do Solid Queue assume: o projeto tem um PostgreSQL só, e adotar múltiplos bancos obrigaria a reescrever o `config/database.yml` inteiro — incluindo o caminho de `DATABASE_URL`, que é o que o Railway injeta — para resolver um problema de escala que não existe aqui. @@ -298,9 +300,13 @@ Tela em `/relatorios` (menu **Relatórios**), visível pra líder e admin (`can? **Geração é sob demanda** — quem acessa decide quando gerar, não há envio automático agendado (diferente do lembrete de atraso, que roda periodicamente por natureza). Duas formas de obter o relatório, ambas na mesma tela: -- **Baixar PDF** (`GET /relatorios/semanal.pdf`): gerado com [Prawn](https://github.com/prawnpdf/prawn) + `prawn-table` (`Relatorios::SemanalPdf`, em `app/services/relatorios/semanal_pdf.rb`) — puro Ruby, sem depender de um binário externo tipo wkhtmltopdf ou Chrome headless (mesma filosofia de manter dependências leves já usada no restante do projeto). Dispara o webhook `relatorio_gerado` (ver "Webhooks de saída"). +- **Baixar PDF** (`GET /relatorios/semanal.pdf`): gerado com [Prawn](https://github.com/prawnpdf/prawn) + `prawn-table` (`Relatorios::SemanalPdf`, em `app/services/relatorios/semanal_pdf.rb`) — puro Ruby, sem depender de um binário externo tipo wkhtmltopdf ou Chrome headless (mesma filosofia de manter dependências leves já usada no restante do projeto). Dispara o webhook `relatorio_gerado` (ver "Webhooks de saída"). Este continua síncrono: o PDF **é** a resposta da requisição, não há como devolvê-lo depois. - **Enviar por Telegram** (`POST /relatorios/enviar_telegram`): envia o mesmo PDF como documento (`sendDocument` da API do Telegram) pro Chat ID de **quem pediu** — não pra outros usuários, mesmo que também tenham Chat ID cadastrado. Reaproveita a mesma configuração (`TELEGRAM_BOT_TOKEN`) e o mesmo padrão de "sem token/chat_id configurado não é erro, só não envia" já usado pelo lembrete de atraso; ver `TelegramNotifier#enviar_documento`. Também dispara `relatorio_gerado`. + Este **roda em background** (`RelatorioSemanalTelegramJob`): a tela responde na hora com "chega no seu Telegram em instantes", e a geração do PDF e o upload acontecem fora da requisição. Antes os dois rodavam dentro do `POST`, então a latência da tela dependia do tempo de resposta da API do Telegram — e, com `RAILS_MAX_THREADS` no default de 5, poucos pedidos simultâneos prendendo um worker do Puma bastavam para degradar a aplicação inteira, inclusive telas sem relação nenhuma com relatório. + + As duas condições previsíveis de falha (servidor sem `TELEGRAM_BOT_TOKEN`, usuário sem Chat ID) continuam sendo checadas **na tela**, antes de enfileirar — são verificáveis sem tocar na rede, então o aviso de configuração faltando continua aparecendo na hora, como antes. O que a tela deixou de mostrar é uma falha no envio em si (Telegram fora do ar, timeout): quem pediu vê "em instantes" e nada chega; fica registrado no log do job em nível `warn`. Trazer isso de volta pra tela exigiria persistir o resultado de cada envio e uma tela pra consultar o status — desproporcional pro caso raro, e a alternativa era continuar prendendo um worker do Puma no tempo do Telegram por causa dele. + ⚠️ Como o model `Demanda` não tem um campo dedicado de "concluída em", "demandas concluídas na semana" é uma aproximação baseada em `updated_at` das demandas já concluídas — pode incluir uma demanda que só teve outro campo editado depois de já estar concluída, não necessariamente a que virou concluída nesta semana exata. Documentado também no comentário de `Relatorios::Semanal#concluidas_no_periodo`. ## Tela web de demandas @@ -336,6 +342,8 @@ Como não há autocadastro, todo usuário novo entra pela primeira vez com a sen - esqueceu a senha depois disso? Duas opções, ambas sem exigir login e sem revelar se o e-mail informado existe (mensagem sempre genérica — mesma postura do Devise, `send_paranoid_instructions`): - **por e-mail** — `/users/password`, fluxo padrão do Devise (`:recoverable`), com views próprias em `app/views/devise/passwords/` no mesmo estilo visual da tela de login; - **por Telegram** — `/senha/telegram` (`TelegramPasswordResetsController`), pra quem já tem o **Chat ID do Telegram** cadastrado (ver seção "Notificação de atraso via Telegram"): `Users::SendPasswordResetViaTelegram` gera o mesmo token de redefinição do Devise e `TelegramNotifier#enviar_redefinicao_senha` entrega o link por lá em vez de e-mail; sem Chat ID cadastrado, nada é enviado (mesmo padrão de "silenciosamente pula" já usado no lembrete de atraso); + + O envio é **enfileirado** (`TelegramPasswordResetJob`) — é uma tela pública, alcançável sem login, e a chamada à API do Telegram rodava dentro dela. Isso também **reforça** a resposta genérica em vez de enfraquecê-la: antes, um e-mail cadastrado com Chat ID esperava o Telegram responder enquanto um e-mail desconhecido voltava na hora, ou seja, o cronômetro entregava o que a mensagem tenta esconder. Agora os dois caminhos fazem um `SELECT`, e um deles um `INSERT` na fila. Foi descartado enfileirar sempre (passando o e-mail digitado pro job, o que deixaria a resposta rigorosamente constante): guardaria e-mail arbitrário de visitante anônimo na tabela de jobs e deixaria o formulário encher a fila — e o throttle por IP/e-mail já é o que barra esse uso; - os dois fluxos terminam na mesma tela (`/users/password/edit?reset_password_token=...`) e, ao definir a nova senha, `User#reset_password` (sobrescrito) também marca `must_change_password` como `false`. **Proteção das telas de autenticação.** Como a primeira senha de todo usuário é escolhida por um líder/admin (e não pelo dono da conta), ela tende a ser o elo mais fraco — por isso duas travas se apoiam uma na outra: diff --git a/app/controllers/relatorios_controller.rb b/app/controllers/relatorios_controller.rb index b2a51ba..d580469 100644 --- a/app/controllers/relatorios_controller.rb +++ b/app/controllers/relatorios_controller.rb @@ -10,34 +10,56 @@ class RelatoriosController < ApplicationController before_action :authorize_relatorio! + ALERTA_TELEGRAM_INDISPONIVEL = 'Não foi possível enviar pelo Telegram. Confira se o servidor tem ' \ + 'TELEGRAM_BOT_TOKEN configurado e se você tem um Chat ID do Telegram ' \ + 'cadastrado (em Acessos → Editar permissões).' + + AVISO_ENVIO_ENFILEIRADO = 'Relatório em preparação. Ele chega no seu Telegram em instantes.' + def show @relatorio = Relatorios::Semanal.new.gerar end # GET /relatorios/semanal.pdf — baixa o mesmo relatório em PDF. + # + # Este continua síncrono: o PDF É a resposta, não há como devolvê-lo + # depois. Só o envio por Telegram saiu da requisição, porque lá o + # documento não vai pro navegador de quem pediu. def semanal_pdf - pdf = gerar_pdf - send_data pdf, filename: nome_arquivo, type: 'application/pdf', disposition: 'inline' + send_data Relatorios::GerarPdfSemanal.call, + filename: Relatorios::SemanalPdf.nome_arquivo, + type: 'application/pdf', + disposition: 'inline' end - # POST /relatorios/enviar_telegram — envia o PDF pro Telegram do próprio - # líder que pediu (não pra outros usuários — ver README). + # POST /relatorios/enviar_telegram — enfileira a geração do PDF e o + # envio pro Telegram do próprio líder que pediu (não pra outros + # usuários — ver README). + # + # Gerar o documento e fazer o upload rodavam aqui dentro: a tela ficava + # presa no tempo de resposta da API do Telegram, e um serviço de + # terceiro lento prendia um worker do Puma por requisição. Ver + # RelatorioSemanalTelegramJob. + # + # A checagem de disponibilidade continua AQUI, e não no job, porque os + # dois motivos previsíveis de não conseguir enviar (servidor sem + # TELEGRAM_BOT_TOKEN, usuário sem Chat ID) são exatamente o que o alerta + # abaixo explica — e nenhum dos dois precisa tocar a rede pra ser + # verificado. A tela continua dando o mesmo aviso útil de antes, sem + # esperar o Telegram responder. + # + # O que se perde: uma falha no envio em si (Telegram fora do ar, + # timeout) não aparece mais na tela — quem pediu vê "em instantes" e + # nada chega; fica só no log do job. Trazer isso de volta pra tela + # exigiria persistir o resultado de cada envio e uma tela pra consultar + # o status, o que é desproporcional pro caso raro. A alternativa era + # continuar prendendo um worker do Puma no tempo do Telegram por causa + # dele. def enviar_telegram - pdf = gerar_pdf - enviado = TelegramNotifier.new.enviar_documento( - current_user, - filename: nome_arquivo, - conteudo: pdf, - legenda: 'Relatório semanal — Task Keeper API' - ) + return redirect_to relatorios_path, alert: ALERTA_TELEGRAM_INDISPONIVEL unless envio_por_telegram_disponivel? - if enviado - redirect_to relatorios_path, notice: 'Relatório enviado no seu Telegram.' - else - redirect_to relatorios_path, - alert: 'Não foi possível enviar pelo Telegram. Confira se o servidor tem TELEGRAM_BOT_TOKEN ' \ - 'configurado e se você tem um Chat ID do Telegram cadastrado (em Acessos → Editar permissões).' - end + RelatorioSemanalTelegramJob.perform_later(current_user.id) + redirect_to relatorios_path, notice: AVISO_ENVIO_ENFILEIRADO end private @@ -46,29 +68,7 @@ def authorize_relatorio! authorize! :read, :relatorio end - # Dispara o webhook "relatorio_gerado" aqui (não em #show) porque #show - # é a tela de pré-visualização, visitada toda vez que o líder abre - # /relatorios — disparar um evento externo a cada visita seria ruído. - # #gerar_pdf só roda quando o líder efetivamente baixa ou envia o PDF - # (ações deliberadas), ver #semanal_pdf e #enviar_telegram. - def gerar_pdf - relatorio = Relatorios::Semanal.new.gerar - WebhookDispatcher.dispatch('relatorio_gerado', relatorio_webhook_payload(relatorio)) - Relatorios::SemanalPdf.new(relatorio).render - end - - def relatorio_webhook_payload(relatorio) - { - periodo_inicio: relatorio.periodo_inicio.iso8601, - periodo_fim: relatorio.periodo_fim.iso8601, - total_criadas: relatorio.criadas.size, - total_concluidas: relatorio.concluidas.size, - total_atrasadas: relatorio.atrasadas, - status_counts: relatorio.status_counts - } - end - - def nome_arquivo - "relatorio-semanal-task-keeper-#{Date.current.iso8601}.pdf" + def envio_por_telegram_disponivel? + TelegramNotifier.new.pode_enviar_para?(current_user) end end diff --git a/app/controllers/telegram_password_resets_controller.rb b/app/controllers/telegram_password_resets_controller.rb index e5a4c3b..469aa1b 100644 --- a/app/controllers/telegram_password_resets_controller.rb +++ b/app/controllers/telegram_password_resets_controller.rb @@ -27,9 +27,26 @@ class TelegramPasswordResetsController < ApplicationController def new; end + # O envio é enfileirado (ver TelegramPasswordResetJob): a chamada à API + # do Telegram acontecia aqui dentro, prendendo um worker do Puma no + # tempo de resposta de um serviço de terceiro — numa tela pública, que + # qualquer um alcança sem login. + # + # Isso melhora a resposta genérica em vez de enfraquecê-la: antes, o + # e-mail cadastrado esperava o Telegram responder e o desconhecido + # voltava na hora, então o cronômetro entregava o que a mensagem tenta + # esconder. Agora os dois caminhos fazem um SELECT, e um deles um + # INSERT na fila. + # + # Descartado enfileirar sempre, passando o e-mail digitado pro job (que + # deixaria a resposta rigorosamente constante): guardaria e-mail + # arbitrário de visitante anônimo na tabela de jobs, e deixaria o + # formulário encher a fila. O throttle por IP e por e-mail + # (AuthThrottling) já é o que barra o uso do formulário como + # metralhadora. def create usuario = User.find_by(email: params[:email].to_s.strip.downcase) - Users::SendPasswordResetViaTelegram.call(user: usuario) if usuario&.telegram_chat_id.present? + TelegramPasswordResetJob.perform_later(usuario.id) if usuario&.telegram_chat_id.present? redirect_to new_telegram_password_reset_path, notice: GENERIC_NOTICE end diff --git a/app/jobs/relatorio_semanal_telegram_job.rb b/app/jobs/relatorio_semanal_telegram_job.rb new file mode 100644 index 0000000..9720517 --- /dev/null +++ b/app/jobs/relatorio_semanal_telegram_job.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Gera o PDF do relatório semanal e envia pro Telegram de quem pediu, +# fora da requisição. +# +# Antes isso rodava inteiro dentro do POST /relatorios/enviar_telegram: a +# tela ficava presa montando o documento E esperando o upload pra API do +# Telegram terminar. Com RAILS_MAX_THREADS no default de 5, um Telegram +# lento prendia um worker do Puma por requisição — poucos pedidos +# simultâneos bastavam pra degradar a aplicação inteira, inclusive as +# telas que não têm nada a ver com relatório. +# +# Recebe o id, e não o registro: o job pode rodar depois de o usuário ser +# excluído, e nesse caso a deserialização de um GlobalID levantaria +# erro em vez de simplesmente não ter o que fazer. +class RelatorioSemanalTelegramJob < ApplicationJob + queue_as :default + + LEGENDA = 'Relatório semanal — Task Keeper API' + + def perform(user_id) + usuario = User.find_by(id: user_id) + return if usuario.nil? + + enviado = TelegramNotifier.new.enviar_documento( + usuario, + filename: Relatorios::SemanalPdf.nome_arquivo, + conteudo: Relatorios::GerarPdfSemanal.call, + legenda: LEGENDA + ) + return if enviado + + # Os dois motivos previsíveis de não enviar (servidor sem + # TELEGRAM_BOT_TOKEN, usuário sem Chat ID) já foram barrados na tela, + # antes de enfileirar — ver RelatoriosController#enviar_telegram. + # Chegar aqui com false é falha no envio em si, e quem pediu já + # recebeu "chega em instantes": não há mais tela pra avisar. Sobra o + # log, que é o motivo de ele existir. + Rails.logger.warn( + "[RelatorioSemanalTelegramJob] não consegui enviar o relatório semanal pro usuário ##{usuario.id}" + ) + end +end diff --git a/app/jobs/telegram_password_reset_job.rb b/app/jobs/telegram_password_reset_job.rb new file mode 100644 index 0000000..3940aaa --- /dev/null +++ b/app/jobs/telegram_password_reset_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Entrega o link de redefinição de senha por Telegram fora da requisição +# — ver TelegramPasswordResetsController#create, que responde na hora com +# a mesma mensagem genérica de sempre. +# +# Efeito colateral bem-vindo pro fluxo: como a chamada HTTP saiu da +# requisição, o tempo de resposta do formulário deixou de depender de o +# e-mail existir. Antes, um e-mail cadastrado com Chat ID esperava a API +# do Telegram responder e um e-mail desconhecido voltava na hora — a +# mensagem era a mesma nos dois casos, mas o cronômetro entregava a +# diferença que ela tenta esconder. +# +# O token do Devise é gerado aqui dentro (por +# Users::SendPasswordResetViaTelegram), não no controller: assim a +# validade do link (reset_password_within) conta a partir do envio, e não +# do momento em que o job entrou na fila. +class TelegramPasswordResetJob < ApplicationJob + queue_as :default + + def perform(user_id) + usuario = User.find_by(id: user_id) + # O usuário pode ter sido excluído, ou ter perdido o Chat ID, entre o + # POST e o job rodar. Sem Chat ID não há pra onde enviar — e gerar o + # token à toa invalidaria um link legítimo que ele tenha pedido por + # e-mail nesse meio-tempo (o Devise guarda um token por usuário). + return if usuario.nil? || usuario.telegram_chat_id.blank? + + Users::SendPasswordResetViaTelegram.call(user: usuario) + end +end diff --git a/app/services/relatorios/gerar_pdf_semanal.rb b/app/services/relatorios/gerar_pdf_semanal.rb new file mode 100644 index 0000000..624d284 --- /dev/null +++ b/app/services/relatorios/gerar_pdf_semanal.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Relatorios + # Geração sob demanda do relatório semanal em PDF: monta os dados + # (Relatorios::Semanal), anuncia o evento "relatorio_gerado" pros + # webhooks cadastrados e devolve os bytes do PDF + # (Relatorios::SemanalPdf). + # + # É uma classe separada do SemanalPdf, que é só o renderizador, porque + # anunciar o evento não é assunto de quem desenha o documento — e + # porque os dois caminhos que geram o PDF de verdade (o download em + # RelatoriosController#semanal_pdf e o envio por Telegram em + # RelatorioSemanalTelegramJob) precisam fazer exatamente a mesma coisa. + # Repetir a montagem do payload nos dois era garantia de eles + # divergirem no primeiro campo novo. + # + # RelatoriosController#show não passa por aqui de propósito: a + # pré-visualização é aberta toda vez que alguém entra em /relatorios, e + # disparar um evento externo a cada visita seria ruído. Só o download e + # o envio — ações deliberadas — contam como "relatório gerado". + class GerarPdfSemanal + def self.call + new.call + end + + def call + relatorio = Semanal.new.gerar + WebhookDispatcher.dispatch('relatorio_gerado', webhook_payload(relatorio)) + SemanalPdf.new(relatorio).render + end + + private + + def webhook_payload(relatorio) + { + periodo_inicio: relatorio.periodo_inicio.iso8601, + periodo_fim: relatorio.periodo_fim.iso8601, + total_criadas: relatorio.criadas.size, + total_concluidas: relatorio.concluidas.size, + total_atrasadas: relatorio.atrasadas, + status_counts: relatorio.status_counts + } + end + end +end diff --git a/app/services/relatorios/semanal_pdf.rb b/app/services/relatorios/semanal_pdf.rb index a121ee0..db6a24e 100644 --- a/app/services/relatorios/semanal_pdf.rb +++ b/app/services/relatorios/semanal_pdf.rb @@ -8,6 +8,15 @@ module Relatorios # usado tanto pro download direto (RelatoriosController#semanal_pdf) # quanto pro envio via Telegram (TelegramNotifier#enviar_documento). class SemanalPdf + # Nome do arquivo entregue ao usuário — mesmo no download direto e no + # envio por Telegram. Fica aqui, e não em cada chamador, porque a data + # é resolvida na hora da geração: num envio que ficou um tempo na fila + # (ver RelatorioSemanalTelegramJob), o nome certo é o do dia em que o + # relatório foi de fato montado, que é o dia dos dados dentro dele. + def self.nome_arquivo + "relatorio-semanal-task-keeper-#{Date.current.iso8601}.pdf" + end + def initialize(relatorio) @relatorio = relatorio end diff --git a/app/services/telegram_notifier.rb b/app/services/telegram_notifier.rb index 8ba1a37..7303112 100644 --- a/app/services/telegram_notifier.rb +++ b/app/services/telegram_notifier.rb @@ -61,9 +61,20 @@ def initialize( @document_transport = document_transport end + # Precondição de qualquer envio: token do bot no servidor e Chat ID no + # destinatário. É público porque quem chama às vezes precisa decidir + # ANTES de enfileirar um job — ver RelatoriosController#enviar_telegram, + # que usa isto pra continuar mostrando na tela o mesmo aviso de sempre + # sobre configuração faltando. As duas condições são verificáveis sem + # tocar na rede, que é o que torna a checagem barata o bastante pra + # ficar dentro da requisição. + def pode_enviar_para?(usuario) + @bot_token.present? && usuario&.telegram_chat_id.present? + end + def notify_atraso(demanda) responsavel = demanda.user - return false if @bot_token.blank? || responsavel&.telegram_chat_id.blank? + return false unless pode_enviar_para?(responsavel) enviar(responsavel.telegram_chat_id, mensagem_atraso(demanda)) rescue StandardError => e @@ -77,7 +88,7 @@ def notify_atraso(demanda) # token que o link por e-mail usaria. Mesma filosofia de #notify_atraso: # sem token/chat_id configurado, não é erro — só não envia. def enviar_redefinicao_senha(usuario, reset_url) - return false if @bot_token.blank? || usuario&.telegram_chat_id.blank? + return false unless pode_enviar_para?(usuario) enviar(usuario.telegram_chat_id, mensagem_redefinicao_senha(usuario, reset_url)) rescue StandardError => e @@ -88,10 +99,15 @@ def enviar_redefinicao_senha(usuario, reset_url) # Envia um arquivo (ex.: o PDF do relatório semanal — ver # Relatorios::SemanalPdf) como documento pro chat_id de +usuario+. Mesma # filosofia de #notify_atraso: sem token configurado, ou sem chat_id - # cadastrado, não é erro — só não envia (retorna false), quem chama - # decide como avisar quem pediu o envio (ver RelatoriosController). + # cadastrado, não é erro — só não envia (retorna false). + # + # Quem chama é RelatorioSemanalTelegramJob, que roda fora da + # requisição: quando o retorno é false ali, não há mais tela pra avisar + # quem pediu (ela já respondeu). Por isso a tela checa + # #pode_enviar_para? antes de enfileirar — os dois motivos previsíveis + # de falha são justamente os que dá pra verificar sem rede. def enviar_documento(usuario, filename:, conteudo:, legenda: nil) - return false if @bot_token.blank? || usuario&.telegram_chat_id.blank? + return false unless pode_enviar_para?(usuario) uri = URI("#{API_BASE}/bot#{@bot_token}/sendDocument") response = @document_transport.call(uri, usuario.telegram_chat_id, filename, conteudo, legenda.to_s) diff --git a/spec/jobs/relatorio_semanal_telegram_job_spec.rb b/spec/jobs/relatorio_semanal_telegram_job_spec.rb new file mode 100644 index 0000000..2999e52 --- /dev/null +++ b/spec/jobs/relatorio_semanal_telegram_job_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RelatorioSemanalTelegramJob, type: :job do + let(:usuario) { create(:user, :lider, telegram_chat_id: '111222333') } + let(:notifier) { instance_double(TelegramNotifier) } + + before do + allow(TelegramNotifier).to receive(:new).and_return(notifier) + allow(notifier).to receive(:enviar_documento).and_return(true) + end + + describe '#perform' do + it 'gera o PDF e manda como documento pro Chat ID de quem pediu' do + described_class.new.perform(usuario.id) + + expect(notifier).to have_received(:enviar_documento).with( + usuario, + filename: Relatorios::SemanalPdf.nome_arquivo, + conteudo: start_with('%PDF'), + legenda: described_class::LEGENDA + ) + end + + it 'dispara o webhook "relatorio_gerado" (a geração acontece aqui agora, não mais na tela)' do + allow(WebhookDispatcher).to receive(:dispatch) + + described_class.new.perform(usuario.id) + + expect(WebhookDispatcher).to have_received(:dispatch) + .with('relatorio_gerado', hash_including(:periodo_inicio, :periodo_fim)) + end + + it 'não tenta enviar nada se o usuário já não existe mais' do + described_class.new.perform(0) + + expect(notifier).not_to have_received(:enviar_documento) + end + + # Quem pediu já viu "chega em instantes" — a tela não existe mais pra + # avisar. O log é a única trilha que sobra, então ele precisa existir. + it 'registra no log quando o envio falha' do + allow(notifier).to receive(:enviar_documento).and_return(false) + allow(Rails.logger).to receive(:warn) + + described_class.new.perform(usuario.id) + + expect(Rails.logger).to have_received(:warn).with(/não consegui enviar o relatório semanal/) + end + end +end diff --git a/spec/jobs/telegram_password_reset_job_spec.rb b/spec/jobs/telegram_password_reset_job_spec.rb new file mode 100644 index 0000000..2d02f16 --- /dev/null +++ b/spec/jobs/telegram_password_reset_job_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TelegramPasswordResetJob, type: :job do + describe '#perform' do + it 'delega o envio pra Users::SendPasswordResetViaTelegram' do + usuario = create(:user, telegram_chat_id: '555111222') + allow(Users::SendPasswordResetViaTelegram).to receive(:call) + + described_class.new.perform(usuario.id) + + expect(Users::SendPasswordResetViaTelegram).to have_received(:call).with(user: usuario) + end + + # O job recebe um id justamente porque o registro pode mudar (ou + # sumir) entre o POST e a execução — ver o comentário na classe. + it 'não faz nada e não levanta erro se o usuário já não existe mais' do + allow(Users::SendPasswordResetViaTelegram).to receive(:call) + + expect { described_class.new.perform(0) }.not_to raise_error + expect(Users::SendPasswordResetViaTelegram).not_to have_received(:call) + end + + it 'não gera token nenhum se o Chat ID foi removido depois do pedido' do + usuario = create(:user, telegram_chat_id: nil) + allow(Users::SendPasswordResetViaTelegram).to receive(:call) + + described_class.new.perform(usuario.id) + + expect(Users::SendPasswordResetViaTelegram).not_to have_received(:call) + expect(usuario.reload.reset_password_token).to be_nil + end + end +end diff --git a/spec/requests/relatorios_spec.rb b/spec/requests/relatorios_spec.rb index 0dac6ba..7ed2716 100644 --- a/spec/requests/relatorios_spec.rb +++ b/spec/requests/relatorios_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'rails_helper' -require 'net/http' RSpec.describe 'Relatório semanal (tela web)', type: :request do let(:lider) { create(:user, :lider) } @@ -85,40 +84,38 @@ expect(response).to redirect_to(root_path) end - it 'envia o PDF pro Telegram do próprio líder e mostra uma mensagem de sucesso' do + # O envio saiu da requisição (ver RelatorioSemanalTelegramJob), então + # o que a tela deve garantir aqui é: enfileirou, respondeu na hora e + # não gerou o PDF nem tocou na rede no caminho. + it 'enfileira o envio e responde na hora, sem gerar o PDF dentro da requisição' do original_token = ENV.fetch('TELEGRAM_BOT_TOKEN', nil) ENV['TELEGRAM_BOT_TOKEN'] = 'token-de-teste' lider.update!(telegram_chat_id: '111222333') - - chamadas = [] - transport = lambda do |uri, chat_id, filename, _conteudo, legenda| - chamadas << { uri: uri, chat_id: chat_id, filename: filename, legenda: legenda } - Net::HTTPOK.allocate - end - # Substitui TelegramNotifier.new por uma instância com o transporte - # dublê injetado, pra este request spec não fazer nenhuma chamada de - # rede real (RelatoriosController#enviar_telegram chama - # `TelegramNotifier.new` sem argumentos — ver app/controllers/relatorios_controller.rb). - allow(TelegramNotifier).to receive(:new).and_return(TelegramNotifier.new(document_transport: transport)) + allow(Relatorios::GerarPdfSemanal).to receive(:call) sign_in lider - post '/relatorios/enviar_telegram' + expect { post '/relatorios/enviar_telegram' } + .to have_enqueued_job(RelatorioSemanalTelegramJob).with(lider.id) + expect(Relatorios::GerarPdfSemanal).not_to have_received(:call) expect(response).to redirect_to(relatorios_path) follow_redirect! - expect(response.body).to include('Relatório enviado no seu Telegram.') - expect(chamadas.size).to eq(1) - expect(chamadas.first[:chat_id]).to eq('111222333') + expect(response.body).to include('Ele chega no seu Telegram em instantes.') ensure ENV['TELEGRAM_BOT_TOKEN'] = original_token end - it 'mostra uma mensagem de erro clara quando não é possível enviar (ex.: sem chat_id cadastrado)' do + # Os dois motivos previsíveis de falha (sem token no servidor, sem + # Chat ID no usuário) continuam sendo decididos na tela — é o que + # permite manter a mensagem de erro útil mesmo com o envio assíncrono. + # Ver RelatoriosController#enviar_telegram. + it 'mostra uma mensagem de erro clara e não enfileira nada quando falta configuração' do original_token = ENV.fetch('TELEGRAM_BOT_TOKEN', nil) ENV['TELEGRAM_BOT_TOKEN'] = nil sign_in lider - post '/relatorios/enviar_telegram' + expect { post '/relatorios/enviar_telegram' } + .not_to have_enqueued_job(RelatorioSemanalTelegramJob) expect(response).to redirect_to(relatorios_path) follow_redirect! @@ -126,5 +123,17 @@ ensure ENV['TELEGRAM_BOT_TOKEN'] = original_token end + + it 'não enfileira nada quando o líder não tem Chat ID do Telegram cadastrado' do + original_token = ENV.fetch('TELEGRAM_BOT_TOKEN', nil) + ENV['TELEGRAM_BOT_TOKEN'] = 'token-de-teste' + lider.update!(telegram_chat_id: nil) + sign_in lider + + expect { post '/relatorios/enviar_telegram' } + .not_to have_enqueued_job(RelatorioSemanalTelegramJob) + ensure + ENV['TELEGRAM_BOT_TOKEN'] = original_token + end end end diff --git a/spec/requests/telegram_password_resets_spec.rb b/spec/requests/telegram_password_resets_spec.rb index 5a0120e..9c779c6 100644 --- a/spec/requests/telegram_password_resets_spec.rb +++ b/spec/requests/telegram_password_resets_spec.rb @@ -13,18 +13,20 @@ end describe 'POST /senha/telegram' do - it 'aciona o envio por Telegram quando o e-mail existe e tem Chat ID cadastrado' do + it 'enfileira o envio por Telegram quando o e-mail existe e tem Chat ID cadastrado' do usuario = create(:user, email: 'comtelegram@task-keeper.local', telegram_chat_id: '555111222') - allow(Users::SendPasswordResetViaTelegram).to receive(:call) - - post telegram_password_resets_path, params: { email: usuario.email } - expect(Users::SendPasswordResetViaTelegram).to have_received(:call).with(user: usuario) + expect { post telegram_password_resets_path, params: { email: usuario.email } } + .to have_enqueued_job(TelegramPasswordResetJob).with(usuario.id) expect(response).to redirect_to(new_telegram_password_reset_path) end - it 'não aciona nada quando o e-mail existe mas não tem Chat ID do Telegram cadastrado' do - usuario = create(:user, email: 'semtelegram@task-keeper.local', telegram_chat_id: nil) + # O critério da issue #78: nenhuma chamada a terceiro dentro do ciclo + # de request/response. Quem fala com o Telegram é + # Users::SendPasswordResetViaTelegram, e ela só deve ser alcançada + # pelo job (ver TelegramPasswordResetJob). + it 'não fala com o Telegram dentro da requisição' do + usuario = create(:user, email: 'comtelegram@task-keeper.local', telegram_chat_id: '555111222') allow(Users::SendPasswordResetViaTelegram).to receive(:call) post telegram_password_resets_path, params: { email: usuario.email } @@ -32,18 +34,21 @@ expect(Users::SendPasswordResetViaTelegram).not_to have_received(:call) end - it 'não aciona nada e não quebra quando o e-mail não existe' do - allow(Users::SendPasswordResetViaTelegram).to receive(:call) + it 'não enfileira nada quando o e-mail existe mas não tem Chat ID do Telegram cadastrado' do + usuario = create(:user, email: 'semtelegram@task-keeper.local', telegram_chat_id: nil) - post telegram_password_resets_path, params: { email: 'ninguem@task-keeper.local' } + expect { post telegram_password_resets_path, params: { email: usuario.email } } + .not_to have_enqueued_job(TelegramPasswordResetJob) + end - expect(Users::SendPasswordResetViaTelegram).not_to have_received(:call) + it 'não enfileira nada e não quebra quando o e-mail não existe' do + expect { post telegram_password_resets_path, params: { email: 'ninguem@task-keeper.local' } } + .not_to have_enqueued_job(TelegramPasswordResetJob) expect(response).to redirect_to(new_telegram_password_reset_path) end it 'mostra sempre a mesma mensagem genérica, exista ou não o e-mail (evita vazar quem está cadastrado)' do create(:user, email: 'comtelegram@task-keeper.local', telegram_chat_id: '555111222') - allow(Users::SendPasswordResetViaTelegram).to receive(:call) post telegram_password_resets_path, params: { email: 'comtelegram@task-keeper.local' } follow_redirect!