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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
84 changes: 42 additions & 42 deletions app/controllers/relatorios_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
19 changes: 18 additions & 1 deletion app/controllers/telegram_password_resets_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/jobs/relatorio_semanal_telegram_job.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading