Aplicação Ruby on Rails full-stack (API REST + interface web) para times pequenos organizarem e acompanharem demandas do dia a dia: quem é responsável por quê, o que está atrasado, e um jeito rápido de saber "o que precisa da minha atenção agora?".
Três papéis com permissões diferentes (executor/líder/admin), autenticação e autorização (Devise + CanCanCan), notificação automática de atraso via Telegram, webhooks de saída (Slack/Teams/Discord/n8n), relatório semanal em PDF, e uma API JSON versionada ao lado da tela web — tudo com suíte de testes automatizados, CI (RuboCop + RSpec + build, smoke test e publish da imagem Docker) e deploy em container.
Rodando em produção: ver seção "Docker" — imagem publicada automaticamente em ghcr.io/hirley/task_keeper_api a cada merge em main.
- Devise (autenticação) + CanCanCan (autorização, papéis executor/líder/admin)
- HAML + Bootstrap (interface web) + Ransack (filtro/ordenação — ver "Identidade visual e busca/paginação")
- Prawn + prawn-table (PDF do relatório semanal — ver "Relatório semanal")
- RSpec + FactoryBot + Shoulda Matchers (testes) · RuboCop (estilo)
- Docker + GitHub Actions (CI: lint, testes, build, smoke test e publish da imagem)
- Existem três papéis de usuário: executor, líder e admin.
- Todos os papéis podem cadastrar novas demandas.
- Líder e admin podem editar ou excluir uma demanda já existente.
- O cadastro de demanda traz a data atual por padrão, mas permite escolher outra; após criada, só líder/admin pode alterar essa data.
- Líder e admin podem cadastrar, alterar a permissão (papel) e excluir usuários (não há autocadastro).
- Um usuário com permissão de gerenciar acessos não pode excluir a própria conta, nem excluir um usuário que já tenha demandas cadastradas.
- No primeiro acesso, o usuário entra com a senha provisória cadastrada pelo líder/admin, mas é obrigado a cadastrar sua própria senha antes de usar qualquer outra tela. Esqueceu a senha depois disso? Pode redefinir por e-mail ou por Telegram (ver seção "Primeiro acesso e redefinição de senha").
- Admin é o único papel que cadastra o Chat ID do Telegram de um usuário e que cadastra/gerencia Webhooks de saída — nem o líder tem esses dois privilégios, mesmo tendo
can :manage, :allpara o resto (verapp/models/ability.rb).
Requer um PostgreSQL rodando (localmente instalado, ou via docker compose up db — ver seção "Docker"). Por padrão a aplicação espera localhost:5432, usuário/senha postgres/postgres (ver config/database.yml e .env.example); ajuste DB_HOST/DB_USERNAME/DB_PASSWORD/DB_NAME (ou DATABASE_URL) se o seu Postgres local usa outras credenciais.
cp .env.example .env # ajuste as credenciais do Postgres se precisar
bundle install
bin/rails db:prepare
bin/rails db:seed # cria um usuário admin, um líder e um executor de exemplo
bin/rails serverSe você já tinha o banco criado localmente antes de alguma migration nova (ex.: campo Data em demandas, Chat ID do Telegram, coluna must_change_password), rode bin/rails db:migrate para aplicar o que estiver pendente. Depois de puxar mudanças no Gemfile, rode bundle install de novo para atualizar o Gemfile.lock.
Rodar a suíte de testes:
bundle exec rspecRodar o RuboCop (estilo de código — ver .rubocop.yml):
bundle exec rubocopUsuários de exemplo criados pelo db:seed:
| Papel | Senha | |
|---|---|---|
| admin | admin@task-keeper.local | senhaSegura123 |
| líder | lider@task-keeper.local | senhaSegura123 |
| executor | executor@task-keeper.local | senhaSegura123 |
O Dockerfile builda uma imagem de produção em 2 etapas (build → final): a etapa final não carrega compilador nem código-fonte de gems, só o necessário para rodar a aplicação — Puma servindo direto (sem Thruster/Kamal, que não fazem parte do Gemfile deste projeto).
Pra subir localmente, um único comando, sem nenhum passo manual antes:
docker compose up --buildIsso sobe três serviços, com os dados do banco persistidos num volume nomeado (sobrevivem a docker compose down, mas não a docker compose down -v):
| Serviço | O que é |
|---|---|
db |
PostgreSQL |
web |
a aplicação (Puma), na porta 3000 |
worker |
o processador de jobs (Solid Queue, bin/jobs) |
O worker roda a mesma imagem do web, e sobe só depois de o web ficar saudável — é o entrypoint do web que aplica as migrations, então o worker precisa esperar as tabelas existirem. Ele também não roda migration nenhuma de propósito: dois processos preparando o mesmo banco ao mesmo tempo é corrida, não redundância.
Não é um ambiente de desenvolvimento com hot-reload — para isso, continue usando bundle install && rails server (apontando pro serviço db ou pra um Postgres local), como na seção anterior. Lá o adapter de jobs é o :async, que roda em thread no próprio processo, então não é preciso subir um segundo processo para desenvolver.
Duas coisas acontecem sozinhas nesse modo local, e nenhuma das duas deve valer num deploy de verdade:
SECRET_KEY_BASE: se a variável não estiver definida,bin/docker-entrypointgera uma chave efêmera só pra aquela execução, e avisa no log. Isso mantém o comando acima sem nenhum passo manual, mas invalida as sessões a cada restart do container. Em produção, defina a variável (copie.env.examplepara.env) — uma chave gerada combin/rails secret. OSECRET_KEY_BASEassina o cookie de sessão: quem o conhece consegue forjar um cookie e autenticar como qualquer usuário, então ele nunca deve ser versionado nem reaproveitado entre ambientes.FORCE_SSL=false: a demo é servida emhttp://localhost, sem TLS, e o redirect deforce_ssla deixaria inacessível. Num deploy de verdade essa variável não é definida e o default deconfig/environments/production.rb(ligado) vale — é o que marca o cookie de sessão comoSecure, envia HSTS e faz os links de redefinição de senha saírem comohttps://.
Também dá pra usar a imagem já publicada em vez de buildar localmente (ver seção "Integração contínua"):
docker pull ghcr.io/hirley/task_keeper_api:latestSem docker compose (conectando a um PostgreSQL já existente em outro lugar):
docker build -t task_keeper_api .
docker run -p 3000:3000 \
-e SECRET_KEY_BASE=$(bin/rails secret) \
-e DATABASE_URL=postgresql://usuario:senha@host:5432/nome_do_banco \
task_keeper_apibin/docker-entrypoint roda bin/rails db:prepare (idempotente) toda vez que o container sobe, antes de iniciar o Puma — então o banco é criado/migrado automaticamente, sem passo manual (mas o PostgreSQL em si precisa já estar de pé e acessível; o Dockerfile não sobe um banco dentro do próprio container da aplicação).
Isso é conveniência de demonstração, e é o que faz docker compose up --build funcionar de primeira. Num deploy de verdade, o modo certo é o outro:
| Quando | Como | |
|---|---|---|
| No boot (default) | docker compose up, docker run, demo de uma réplica só |
nada a fazer — o entrypoint cuida |
| Etapa de release | qualquer deploy real, e obrigatório com mais de uma réplica | DB_PREPARE_ON_BOOT=false no serviço web, e as migrations rodam antes de subir as réplicas |
O comando da etapa de release é o mesmo binário, com outro argumento — o entrypoint só prepara o banco quando o comando é ./bin/rails server, então qualquer outro comando passa direto:
docker compose run --rm web ./bin/rails db:prepareFora do compose, é a mesma ideia: docker run --rm -e DATABASE_URL=... ghcr.io/hirley/task_keeper_api:latest ./bin/rails db:prepare. No Railway, um pre-deploy command com bin/rails db:prepare.
O que se ganha desligando não é evitar corrupção de schema. As migrations do Rails pegam um advisory lock no Postgres, então o schema está protegido de qualquer jeito. São duas outras coisas:
- com mais de uma réplica, quem perde o lock não espera: o Rails usa
pg_try_advisory_lock, que é não-bloqueante, e levantaConcurrentMigrationError. Como o entrypoint roda combash -e, odb:prepareque falha derruba o container — a réplica morre no boot em vez de simplesmente subir depois da que migrou; - a disponibilidade do web deixa de depender das migrations: uma migration longa atrasa o boot, e uma que falha impede a aplicação de subir mesmo que o código já rodasse contra o schema antigo.
O default continua sendo ligado de propósito. Inverter deixaria o docker run acima e o compose local subindo contra um banco vazio — um jeito pior de falhar do que o problema que se quer evitar. O CI exercita os dois caminhos: o smoke test sobe a stack pelo boot automático e, em seguida, roda a etapa de release isolada (ver "Integração contínua").
Este projeto não tem config/master.key/config/credentials.yml.enc, então SECRET_KEY_BASE (variável de ambiente) é obrigatória em produção — sem ela, o container não sobe. As variáveis de conexão com o banco (DATABASE_URL ou DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD/DB_NAME) e as demais (TELEGRAM_BOT_TOKEN, APP_HOST, RAILS_MAX_THREADS) são opcionais/têm default; ver .env.example para a lista completa e o que cada uma faz.
Sobre a plataforma do Gemfile.lock: o lockfile deste projeto foi gerado originalmente numa máquina Windows — a seção PLATFORMS só tem x64-mingw-ucrt, sem a plataforma Linux. Sem isso, bundle install falha dentro de um container Linux ao tentar resolver as gems com extensão nativa (pg, nokogiri). O Dockerfile já corrige isso sozinho (roda bundle lock --add-platform x86_64-linux antes do bundle install, dentro da própria imagem), então não é preciso fazer nada manualmente por causa disso — mas é bom saber que esse ajuste existe, caso apareça algum erro de plataforma ao rodar bundle install fora do Docker também (nesse caso, bundle lock --add-platform x86_64-linux resolve, e o mesmo vale se você desenvolver num Mac Apple Silicon: bundle lock --add-platform arm64-darwin).
O build é validado automaticamente a cada push/PR pelo CI, que também sobe a imagem e verifica que ela funciona (ver seção "Integração contínua"). Durante o desenvolvimento, dois bugs reais de build já apareceram e foram corrigidos:
-
COPY . .sobrescrevendo oGemfile.lockcorrigido: rodava depois dobundle lock --add-platform, apagando silenciosamente o ajuste de plataforma antes dobootsnap precompile app/ lib/seguinte (bundle execrevalida a plataforma a cada chamada). Corrigido copiando o projeto inteiro antes de mexer noGemfile.lock. -
CRLF em
bin/*: quem desenvolve no Windows normalmente temcore.autocrlf=trueno Git, que converte os scripts debin/(LF no repositório) para CRLF no checkout local; comodocker buildcopia o contexto direto do disco (não do objeto Git), o CRLF ia parar no container e o shebang#!/usr/bin/env rubydebin/railsviravaruby\r—env: 'ruby\r': No such file or directory. Corrigido normalizandobin/*para LF em tempo de build (sed -i 's/\r$//' bin/*, logo após oCOPY . .), além de um.gitattributes(* text=auto eol=lf) pra evitar isso em checkouts novos. -
bin/jobssem bit de execução: quem cria um script embin/no Windows não tem bit de execução no sistema de arquivos, então o Git registra o arquivo como100644— foi o que aconteceu combin/jobsquando o Solid Queue entrou. Umdocker buildno Windows não percebe (o contexto vem do disco, onde tudo parece executável), mas o checkout num runner Linux respeita o modo do índice, e o container doworkermorria comexit 126assim que subia. A imagem buildava, e a aplicação buildada não tinha worker. Corrigido no índice (git update-index --chmod=+x bin/jobs) e com umchmod +x bin/*em tempo de build, ao lado dosedacima.Este é o único dos três que não quebrava o
docker build— os outros dois quebravam, porque o Dockerfile executa./bin/rails assets:precompile. Ele só aparecia ao rodar a imagem, e foi encontrado pelo smoke test descrito em "Integração contínua", na primeira vez que ele rodou.
.github/workflows/ci.yml roda no GitHub Actions em todo push para main, em toda pull request e em todo push de tag de versão (v*), com três jobs:
-
rubocop —
bundle exec rubocop(usa o.rubocop.ymljá existente no repositório); -
rspec — sobe um serviço
postgres:16-alpine, rodabin/rails db:prepare, depoisbin/rails zeitwerk:check(eager load isolado num processo à parte, só pra pegar erro de autoload cedo — ver comentário emconfig/environments/test.rbsobre por que isso não é feito viaconfig.eager_load = trueno ambiente de teste) e por fimbundle exec rspeccontra o bancotask_keeper_api_test; -
docker — builda a imagem de produção, sobe a stack inteira e verifica que ela funciona, e só então publica; roda depois que
rubocoperspecpassam. Em pull request, valida o build e o smoke test sem publicar. Em push pramain, publica a imagem no GitHub Container Registry (ghcr.io/hirley/task_keeper_api), usando oGITHUB_TOKENautomático do Actions — não exige nenhum secret configurado manualmente.O smoke test existe porque validar que a imagem builda não diz nada sobre ela subir: entrypoint,
db:prepare, permissões do usuário não-root, healthcheck, publicação de porta e variável de ambiente faltando só falham em runtime.Dos três bugs listados na seção "Docker", os dois primeiros não são exemplo disso — quebravam durante o
docker build, e o job de build já os pegava. O terceiro é:bin/jobssem bit de execução buildava perfeitamente e derrubava oworkercomexit 126ao subir. Foi encontrado por este smoke test na primeira vez que ele rodou, num repositório onde a imagem publicada emmainjá estava assim havia dois merges.O job sobe
db+web+workere checa cinco coisas:Verificação O que quebraria sem ela up --wait db webvolta sem errocontainer que morre no boot, entrypoint quebrado, healthcheck quebrado, db:preparefalhandocurlem/acessibilidadede fora do containerporta não publicada, Puma escutando só em loopback a linha de chave efêmera no log do entrypoint o caminho "sem SECRET_KEY_BASE" da v2.0.0, que nada exercitavao workerainda estárunningdepois de subirworker que morre no boot — dá o exit code na hora, em vez de virar um timeout obscuro o workerexecuta um job enfileirado pelowebworker que sobe e não consome nada — invisível, porque a tela responde normal e a entrega só não acontece O
--waité pedido só paradbeweb: ele exige healthcheck em todo serviço nomeado, e oworkernão tem um (não serve HTTP — verdocker-compose.yml). Não inventamos um healthcheck de processo só para satisfazê-lo; quem prova que o worker está vivo é a última linha da tabela, que mede o trabalho em vez do pulso.O
SECRET_KEY_BASEdo job é vazio de propósito: é o que faz o entrypoint gerar a chave efêmera. O job enfileirado éWebhookDeliveryJobpara uma assinatura inexistente, queWebhookDeliveryrecusa antes de tocar a rede — o que se verifica é o percurso (web → Postgres → worker), não a entrega.A imagem é buildada uma vez e carregada no daemon local (
load), o smoke test roda contra ela, e só depois vem a etapa que publica — que acerta o cache inteiro e custa segundos, e só existe em push (em pull request seria um build descartado). Uma imagem que não sobe nunca chega ao registry.Custo medido no runner: o smoke test em si leva ~26s (18s esperando
db+webficarem saudáveis, 3s subindo o worker, 3s para o job ir dowebaoworker, 1s de teardown). O que pesa é o build comload, ~1m45s contra ~1m10s de um build sem exportar a imagem — o preço de testar exatamente o artefato que vai ser publicado. O job inteiro fica em ~2m50s, e o CI completo em ~4min.As tags da imagem dependem do que disparou o build:
Tag Quando Serve para latestsó no topo da main"me dá a última" — se move, não fixa nada sha-<commit completo>sempre apontar exatamente um commit 2.0.0push de tag v2.0.0fixar a release 2.0push de tag v2.0.xacompanhar correções dentro do minor Build de tag não mexe no
latest— publicar uma release não deve reescrever o que "a última" aponta. Isso exigiu fechar duas portas nometadata-action, e a segunda só apareceu depois que a primeira foi fechada e olatestcontinuou saindo:- a condição da regra do
latestcompara o ref com o default branch explicitamente, em vez de usar{{is_default_branch}}— essa expressão também valetrueem push de tag; flavor: latest=false, porque o defaultlatest=autoacrescenta a tag por fora das regras detags:, para toda tag semver.
Os builds da v2.0.0 e da v2.0.1 reescreveram o
latestpor causa disso. Não houve dano em nenhum dos dois, porque as duas tags saíram do topo damaine olatestjá apontava para aquele commit — mas marcar um commit antigo faria olatestregredir em silêncio.Também não é gerada tag só de major (
2) de propósito — num projeto que ainda muda comportamento entre minors, uma tag tão larga prometeria mais estabilidade do que existe. - a condição da regra do
main que publicar a imagem, é preciso ir em Package settings (na página do pacote em github.com/Hirley?tab=packages) e trocar a visibilidade pra pública, se quiser puxar a imagem (docker pull) sem autenticação.
SECRET_KEY_BASE no workflow é um valor fixo só para o boot da aplicação em CI (não é usado em nenhum ambiente real — produção continua exigindo a variável de ambiente própria, como descrito na seção "Docker"). As demais variáveis de banco seguem o mesmo padrão de .env.example/config/database.yml. O job docker sobrescreve três delas, porque o env: do workflow descreve o ambiente de teste e ali o que sobe é a imagem de produção — ver o comentário no próprio ci.yml.
Boa parte deste projeto é escrita em pares com um agente de código (Claude Code). O que está descrito aqui não é aspiracional — é o processo que sobrou depois de uma revisão de segurança de doze achados, e cada regra existe porque a ausência dela custou alguma coisa.
A configuração vive em três arquivos versionados:
| Arquivo | Papel |
|---|---|
CLAUDE.md |
contexto carregado no início de toda sessão: idioma, estilo de comentário, armadilhas do repositório, convenção de commit e PR |
.claude/skills/verificar-local/SKILL.md |
como executar RuboCop, zeitwerk:check, RSpec e a app em container |
.claude/settings.json |
permissões e um hook que roda RuboCop no que acabou de mudar |
- Investigar antes de consertar. Numa das correções desta revisão, o achado reportado estava errado na premissa: o problema só apareceu ao conferir o histórico do Git antes de escrever a migration, e a correção final tratou um grupo bem mais estreito do que o anunciado. Confirmar o diagnóstico é parte do conserto, não um passo opcional antes dele.
- Escrever, com o porquê junto. Ver a seção sobre comentários no
CLAUDE.md: aqui se documenta decisão, não assinatura de método. - Verificar de fato. Não há Ruby compatível instalado na máquina de desenvolvimento, então RuboCop e RSpec rodam num container
ruby:4.0.6-slimque espelha oci.yml. Um ciclo completo leva cerca de 20 segundos. - Abrir com o que não foi verificado dito em voz alta. A descrição do PR registra a decisão, a alternativa descartada e o que ficou sem cobertura.
O CI é a fonte de verdade — roda em Linux, com as versões travadas. Mas descobrir uma ofensa de estilo só depois do push custa um commit extra e um ciclo de dois minutos; foi o que aconteceu duas vezes antes deste fluxo existir. O container encurta o laço sem substituir o CI.
Ele também permite o que o CI não faz: subir a aplicação e olhar no navegador. Existe hoje uma cobertura automatizada de navegador (spec/system, ver "Testes"), mas ela é pequena de propósito e cobre a classe de bug que já mordeu — CSP e o JS da aplicação sobrevivendo ao Turbo. Fora disso continua sem teste: tour guiado, dropdown de busca, busca por voz e o widget do VLibras. Olhar de verdade continua valendo para mudança em JS, CSS, HAML ou CSP.
PostToolUse em Write|Edit roda RuboCop apenas nos arquivos .rb tocados nos últimos 20 segundos, dentro do container, e devolve as ofensas ao agente na hora. Sai em silêncio se o Docker estiver parado — um hook que falha quando a infraestrutura não está de pé vira ruído, e ruído se aprende a ignorar.
Duas armadilhas que ele já teve, e que valem para qualquer hook parecido: set -o pipefail não existe no shell que executa o comando, então o código de saída do RuboCop precisa ser capturado explicitamente em vez de vir pelo pipe; e o docker cp sobrescreve o Gemfile.lock do container com o do repositório, que não tem a plataforma Linux — sem um bundle lock --add-platform antes, o bundle exec falha.
O .claude/settings.json libera sem confirmação apenas o que tem superfície pequena: docker ps, docker logs e docker restart (que só operam sobre containers nomeados), os comandos gh de leitura e o gh pr create.
docker exec e docker cp ficam de fora de propósito, ainda que sejam o coração do ciclo de verificação e por isso os mais repetidos. docker exec roda comando arbitrário dentro do container e docker cp escreve arquivo arbitrário lá dentro — liberar os dois é, na prática, liberar execução e escrita sem confirmação. Como cada um aparece várias vezes por sessão, é justamente o par em que uma liberação por conveniência renderia mais e custaria mais. Cada chamada pede confirmação, e é assim que deve ser.
O hook de RuboCop não é afetado: hooks são comandos configurados pelo próprio usuário, executados direto, sem passar pelo sistema de permissões. Ele continua rodando docker exec e docker cp sozinho.
A suíte RSpec cobre:
- Models:
UsereDemanda(spec/models), incluindo a validação dotelegram_chat_id, o reset deatraso_notificado_em, o default demust_change_passworde o#reset_passwordsobrescrito; - Política de autorização:
Ability(spec/models/ability_spec.rb), validando cada combinação de papel (executor/líder/admin) × ação paraDemanda,UsereWebhookSubscription; - 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; - 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, dispararelatorio_geradoe 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):demandaseusers; - 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 detelegram_chat_ida 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 enquantomust_change_passwordfortrue, formulário, sucesso/falha de validação, e que o logout continua funcionando nesse estado;telegram_password_resets_spec.rb— enfileira (ou não)TelegramPasswordResetJobconforme 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 mensagemTranslation missingdo 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 responder200em vez de422(ver "Devise + Turbo Drive" abaixo).
Cenários validados explicitamente:
- um
executorconsegue criar uma demanda (via tela ou API), mas recebe403/é redirecionado ao tentar atualizar ou excluir; - líder e admin conseguem criar, atualizar e excluir demandas;
- líder e admin conseguem listar/criar/excluir usuários, tanto pela tela
/usersquanto por/api/v1/users— mas só admin consegue definir e ler otelegram_chat_idde um usuário (um líder que tenta definir é ignorado silenciosamente, sem erro, e o campo nem aparece na resposta da API para quem não é admin); - só admin acessa
/webhooks— um líder que tenta é redirecionado, do mesmo jeito que um executor; - um usuário com permissão de gerenciar acessos não consegue excluir a própria conta, nem um usuário com demandas vinculadas;
- o botão "Excluir" (demandas e usuários) carrega o Turbo e mostra o alerta de confirmação antes de enviar o form;
- uma demanda atrasada é notificada uma única vez no Telegram, e um novo aviso só é enviado se ela atrasar de novo depois de deixar de estar atrasada;
- a API rejeita com
415qualquerPOST/PATCH/DELETEsemContent-Type: application/json(proteção contra CSRF — ver seção "Endpoints principais"), sem afetarGET; - ao tentar acessar uma tela protegida sem login, ou ao errar e-mail/senha, a mensagem aparece traduzida em pt-BR (não
Translation missing— ver seção "Mensagens em pt-BR").
Capybara + Chrome headless, e deliberadamente poucos exemplos: eles não replicam o que os request specs já cobrem, cobrem os lugares onde a falha é silenciosa — a página renderiza, o servidor responde 200, e só o navegador sabe que algo foi bloqueado ou deixou de rodar.
| Exemplo | O que quebraria sem ele |
|---|---|
| página pública sem violação de CSP | uma diretiva nova bloqueando um recurso legítimo, sem sintoma no servidor |
| página autenticada sem violação de CSP | idem, nas telas com importmap e widget do VLibras |
| sem violação depois de uma navegação do Turbo Drive | o bug que já aconteceu: nonce por requisição contra um CSP que o Turbo mantém em vigor da primeira resposta |
| alto contraste sobrevive à navegação do Turbo | application.js não executando — nonce do importmap recusado deixa a página bonita e inerte |
| confirmação do Turbo ao excluir | data-turbo-confirm deixando de funcionar, e a exclusão passando a acontecer sem perguntar |
Violação de CSP não levanta erro nem quebra a renderização: o navegador recusa o recurso e escreve no console. Por isso o driver liga goog:loggingPrefs e os exemplos leem o log — sem isso veriam uma página aparentemente perfeita, que é exatamente como as duas quebras anteriores passaram despercebidas.
O filtro é pelo texto Content Security Policy do próprio navegador, e não "console limpo": o layout carrega Bootstrap, fontes do Google e o widget do VLibras de CDNs externos, e uma falha de rede do runner vira Failed to load resource — ruído que não é o que estes exemplos verificam.
O que continua sem cobertura automatizada: tour guiado, dropdown de busca, busca por voz e o widget do VLibras em si. São verificados à mão (incluindo screenshots) quando mudam.
| Verbo | Rota | Quem pode acessar |
|---|---|---|
| GET | /api/v1/demandas |
qualquer usuário autenticado |
| GET | /api/v1/demandas/:id |
qualquer usuário autenticado |
| POST | /api/v1/demandas |
qualquer usuário autenticado |
| PATCH | /api/v1/demandas/:id |
líder ou admin |
| DELETE | /api/v1/demandas/:id |
líder ou admin |
| GET | /api/v1/users |
líder ou admin |
| GET | /api/v1/users/:id |
líder ou admin |
| POST | /api/v1/users |
líder ou admin (campo telegram_chat_id só é salvo se quem cadastra é admin) |
| DELETE | /api/v1/users/:id |
líder ou admin |
Toda ação de escrita (POST/PATCH/DELETE) exige o header Content-Type: application/json — uma requisição sem esse header recebe 415 Unsupported Media Type. Isso não é um capricho de formato: essa API autentica por sessão (cookie do Devise) e tem o token CSRF desativado (Api::V1::BaseController), então exigir application/json é o que impede um <form> HTML comum de outro site de forjar uma requisição usando a sessão já autenticada do usuário — um formulário nunca consegue definir esse Content-Type, só application/x-www-form-urlencoded, multipart/form-data ou text/plain.
As respostas de /api/v1/users trazem uma lista fechada de campos (Api::V1::UsersController::CAMPOS_PUBLICOS): id, name, email, role, must_change_password, created_at e updated_at — mais o telegram_chat_id, e só para admin, que é quem pode gravá-lo. É uma allowlist de propósito, e não uma lista do que esconder: assim uma coluna nova na tabela users nasce fora da API até alguém decidir publicá-la, em vez de nascer publicada até alguém lembrar de escondê-la.
A home (/, acessível clicando na marca "Task Keeper API" na navbar — não há um item "Início" separado no menu) é um painel com uma visão geral das demandas, pensado para responder duas perguntas diferentes: "o que precisa da minha atenção agora?" e "como está a equipe?" — nessa ordem de prioridade:
- KPIs no topo: total de demandas e quantas estão em cada status;
- Minhas demandas: as demandas do próprio usuário logado, ordenadas por urgência (atrasada primeiro, depois o que vence antes); cada uma tem um badge de prazo (
Atrasada há N dias,Vence hoje,Vence amanhã,Vence em N dias) — um canal separado do badge de status, porque "em que fase está" e "está no prazo?" são informações diferentes; - Atividade recente: últimas demandas criadas por toda a equipe;
- Prazos: quantas demandas (de toda a equipe) estão atrasadas, vencem hoje, ou vencem nos próximos
DashboardController::PRAZO_PROXIMO_DIASdias (3 por padrão); - Carga por responsável: quantas demandas abertas (não concluídas) cada pessoa tem, da maior carga para a menor — visível pros três papéis, já que qualquer usuário autenticado já enxerga todas as demandas na listagem.
Todos os dados vêm do banco (nada é fixo/mockado) e respeitam a mesma autorização já usada na listagem de demandas (Demanda.accessible_by(current_ability)).
Quando uma demanda de um executor fica atrasada (data no passado e ainda não concluída — o mesmo critério já usado no badge "Atrasada há N dias" do painel inicial), o app pode avisar o responsável por mensagem no Telegram. É opt-in por usuário e não depende de nenhum serviço externo pago.
Configuração:
- Crie um bot conversando com o @BotFather no Telegram (
/newbot) e copie o token gerado. - Configure a variável de ambiente
TELEGRAM_BOT_TOKENcom esse token (no Railway: aba Variables do serviço). - Cada usuário que quiser receber avisos descobre o próprio
chat_idcom o bot @userinfobot — passo a passo pelo celular ou computador: abra a barra de pesquisa do Telegram, digite@userinfobot, selecione o bot oficial nos resultados, toque em Começar (ou envie/start) e copie o número exibido no campo Id. - O admin cadastra esse
chat_idno campo Chat ID do Telegram ao criar ou editar o usuário em/users— o próprio formulário tem um ícone ⓘ ao lado do campo com esse mesmo passo a passo, em forma de tooltip. É o único campo do formulário exclusivo do admin: nem o líder (que também cadastra/edita usuários) vê ou edita esse campo.
Como funciona:
TelegramNotifier(app/services/telegram_notifier.rb) monta a mensagem e chama a API do Telegram (sendMessage) viaNet::HTTPpuro, 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. - 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_TOKENconfigurado, ou semtelegram_chat_idno usuário, a notificação é simplesmente pulada (não é um erro).
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.
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.
Eventos disponíveis: demanda_criada, demanda_concluida, demanda_excluida (disparados por callbacks no model Demanda — cobrem tanto a tela web quanto a API) e relatorio_gerado (disparado só quando o relatório semanal é efetivamente baixado ou enviado por Telegram, não a cada visita à pré-visualização).
Como funciona:
-
WebhookSubscription(app/models/webhook_subscription.rb) guarda a URL e os eventos escolhidos (array nativo do Postgres). Um webhook pode ser pausado (active: false) sem precisar excluir o cadastro. -
WebhookDispatcher(app/services/webhook_dispatcher.rb) encontra as assinaturas ativas que escutam o evento e enfileiraWebhookDeliveryJobpra cada uma — em background, pra não travar a request original no tempo de resposta de um serviço de terceiro.Em produção a fila é o Solid Queue, gravando nas tabelas
solid_queue_*do próprio PostgreSQL da aplicação (verconfig/environments/production.rbeconfig/queue.yml). Isso exige um processo separado rodandobin/jobs— é o serviçoworkerdodocker-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) eTelegramPasswordResetJob(link de redefinição de senha por Telegram) também dependem doworkerestar de pé. Num deploy sem ele, essas duas telas respondem normalmente e nada é entregue — os jobs se acumulam emsolid_queue_jobsaté 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:asynccontinua valendo, pra quebin/rails serversozinho 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.ymlinteiro — incluindo o caminho deDATABASE_URL, que é o que o Railway injeta — para resolver um problema de escala que não existe aqui. -
WebhookDelivery(app/services/webhook_delivery.rb) faz oPOSTde fato, com timeout curto (5s). O retorno dele é o que decide se o job tenta de novo, e a distinção é deliberada:Situação Resultado O job… 2xxtrueencerra assinatura pausada ou excluída, URL reprovada na checagem de SSRF, resposta 4xx, erro de TLSfalseencerra sem gastar tentativa — repetir não mudaria a resposta erro de rede (conexão recusada, timeout, DNS), 429,5xxlevanta FalhaTemporariareagenda, com backoff, até 5 tentativas Esgotadas as tentativas,
WebhookDeliveryJobregistra a desistência no log em nívelerror— a entrega perdida deixa rastro em vez de sumir. Um erro inesperado (bug nosso, não instabilidade do outro lado) também não consome tentativas: é logado comoerrore encerra, porque repetir cinco vezes não conserta e só atrasa o diagnóstico.Em produção as tentativas reagendadas ficam gravadas junto com o job, no Solid Queue — sobrevivem a um restart do web e são retomadas quando o worker volta. Em desenvolvimento, com o
:async, ainda se perdem. -
Proteção contra SSRF:
PublicHttpTarget(app/services/public_http_target.rb) resolve o host e recusa endereços de rede privada/local (127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16e as faixas IPv6 equivalentes), pra que um webhook não vire um jeito de fazer a aplicação bater num serviço interno da própria rede. A checagem roda duas vezes, de propósito:- no cadastro/edição (validação de
WebhookSubscription), só pra dar o erro no formulário enquanto o admin ainda está na tela; - de novo na hora da entrega (
WebhookDelivery), imediatamente antes de conectar — e é essa que protege de fato. Sozinha, a validação do cadastro não segura DNS rebinding: como a entrega acontece quando um evento dispara (possivelmente dias depois), bastaria cadastrar um host que resolve pra um IP público e trocar o registro DNS com calma. O IP verificado na entrega é passado direto proNet::HTTP(ipaddr:), então o endereço checado é exatamente o endereço conectado — sem intervalo entre a checagem e o uso. O host original continua valendo pro cabeçalhoHost, SNI e validação do certificado TLS. - o IP não é gravado no banco: resolver a cada entrega é o que mantém endpoints legítimos funcionando quando o provedor troca de IP.
- no cadastro/edição (validação de
Tela em /relatorios (menu Relatórios), visível pra líder e admin (can? :read, :relatorio — ver app/models/ability.rb), com um resumo dos últimos 7 dias corridos (hoje e os 6 dias anteriores, não a semana de calendário): demandas criadas na semana, demandas concluídas na semana, situação atual por status, atrasadas, e carga atual por responsável. Os dados são montados por Relatorios::Semanal (app/services/relatorios/semanal.rb) e reutilizados tanto pela tela de pré-visualização quanto pelo PDF.
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 +prawn-table(Relatorios::SemanalPdf, emapp/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 webhookrelatorio_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 (sendDocumentda 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; verTelegramNotifier#enviar_documento. Também dispararelatorio_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 doPOST, então a latência da tela dependia do tempo de resposta da API do Telegram — e, comRAILS_MAX_THREADSno 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ívelwarn. 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.
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.
Além da API, há uma tela em /demandas (menu "Demandas" no topo) para uso pelos usuários autenticados:
- os três papéis veem o botão Nova demanda e podem cadastrar;
- só líder e admin veem a coluna Ações, com os botões Editar e Excluir em cada linha;
- se um executor tentar acessar
/demandas/:id/editdiretamente, é redirecionado com aviso de permissão negada; - o botão Excluir pede confirmação (
data-turbo-confirm, via Turbo) antes de enviar o form de exclusão; - um formulário de busca (com autocomplete por título já cadastrado + filtro por status) e paginação (10 por página);
- as colunas Título, Data, Status, Responsável e Criada em são clicáveis e ordenam a listagem (clicar de novo inverte a direção); o filtro de busca preserva a ordenação escolhida;
- o cadastro tem um campo Data, preenchido por padrão com a data atual, mas que pode ser alterado para outra data no momento da criação; depois que a demanda já existe, só líder/admin pode alterar essa data (mesma regra de edição das demais informações da demanda).
Há também uma tela em /users (menu "Acessos" no topo, visível a líder e admin) para cadastrar novos usuários e alterar a permissão (papel executor/líder/admin) de usuários já existentes:
-
só líder e admin veem o menu "Acessos" e conseguem acessar
/users; um executor que tentar acessar diretamente é redirecionado com aviso de permissão negada; -
o cadastro de um novo usuário exige nome, e-mail, senha e a permissão (executor, líder ou admin) — não há autocadastro;
-
a edição permite alterar nome, e-mail e a permissão de um usuário existente — a troca de senha em si não é feita por aqui (ver seção "Primeiro acesso e redefinição de senha");
-
o campo Chat ID do Telegram só aparece pra quem está logado como admin — nem no formulário, nem na edição, um líder vê ou consegue alterar esse campo de outro usuário (reforçado também do lado do servidor, não só escondido na tela);
-
só um admin concede ou remove o papel de admin, e ninguém altera a própria permissão (
User#validar_atribuicao_de_papel). O líder continua alternando qualquer outro usuário entre executor e líder — o que ele não pode é criar um admin, promover alguém a admin ou rebaixar um admin existente. Sem essa regra ocannot :manage, WebhookSubscriptiondo líder (verapp/models/ability.rb) seria decorativo: como o líder gerencia usuários, bastaria umPATCHno próprio usuário comrole=adminpra contorná-lo num request. A trava de "não altera a própria permissão" vale inclusive pro admin, e de quebra impede o último admin de se rebaixar e deixar os webhooks sem ninguém que possa gerenciá-los.A regra é fail-closed: quem está fazendo a alteração chega no model pelo
User#ator, que não é coluna — os controllers preenchem —, e papel definido ou alterado sem ator é recusado. Antes era o contrário (ator ausente dispensava a validação), e a diferença não era teórica: a invariante valia só porque os dois controllers de hoje lembram de preencher o ator, e qualquer caminho novo que esquecesse — um job, um importador, uma rake task, um endpoint futuro — passava direto e em silêncio. Os poucos lugares legítimos que escrevem papel fora de uma requisição (db/seeds.rb, o console, a factory da suíte) declaram isso comUser#ator_dispensado. O nome é incômodo de propósito: usá-lo é afirmar "não há requisição aqui", não silenciar uma validação chata; -
líder e admin também podem excluir outros usuários (com confirmação via Turbo). Duas travas de segurança: não dá pra excluir a própria conta, nem excluir um usuário que já tenha demandas cadastradas (é preciso reatribuir ou excluir as demandas dele antes);
-
um formulário de busca (com autocomplete por nome/e-mail já cadastrados + filtro por permissão) e paginação (10 por página).
Como não há autocadastro, todo usuário novo entra pela primeira vez com a senha provisória que o líder/admin cadastrou em /users (campo Senha do formulário — ver seção "Tela web de Acessos"). Logo após esse primeiro login, o app pede — e obriga — que o usuário cadastre uma senha só dele antes de usar qualquer outra tela:
- a coluna
must_change_password(users, defaulttrue) marca esse estado;ApplicationController#exigir_troca_de_senha!redireciona qualquer tela pra/definir-senha(DefinirSenhaController) enquanto ela fortrue— só as próprias telas do Devise (ex.: logout) escapam dessa trava, senão quem está preso nesse estado não conseguiria nem sair; /definir-senhanão pede a senha atual (o usuário acabou de autenticar com ela), só a nova senha + confirmação; ao salvar,must_change_passwordvirafalsee o usuário segue navegando normalmente;- 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 emapp/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::SendPasswordResetViaTelegramgera o mesmo token de redefinição do Devise eTelegramNotifier#enviar_redefinicao_senhaentrega 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 umSELECT, e um deles umINSERTna 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 marcamust_change_passwordcomofalse.
-
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:
- tamanho mínimo de 12 caracteres (
config.password_length, emconfig/initializers/devise.rb), acima do default de 6 do Devise; - rate limit por IP e por e-mail no login e nos dois fluxos de "esqueci minha senha" (
AuthThrottling, emapp/controllers/concerns/auth_throttling.rb), usando orate_limitnativo do Rails 8 — sem rack-attack, mesma filosofia de manter poucas dependências. É throttle e não bloqueio de conta (:lockable) de propósito: bloqueio é ele mesmo um vetor de negação de serviço, já que quem souber o e-mail da vítima consegue trancar a conta dela; o throttle só atrasa e se resolve sozinho quando a janela passa. Além da força bruta, o limite por e-mail é o que impede o formulário de redefinição de senha de virar uma metralhadora de e-mails/mensagens de Telegram pra caixa de uma vítima.
O layout usa uma identidade visual própria (app/assets/stylesheets/application.css), com gradiente verde-petróleo, tipografia Kanit/Open Sans e componentes reutilizáveis (navbar, cards, badges, paginação). As telas de Demandas e Acessos ganharam um formulário de busca (com <datalist> de autocomplete) e paginação simples via Paginatable (app/controllers/concerns/paginatable.rb) — implementada só com Active Record (limit/offset), sem depender de gem externa como Kaminari.
O filtro e a ordenação de DemandasController#index e UsersController#index são construídos internamente com Ransack, mas a URL continua com o mesmo contrato simples de sempre (q, status/role, sort, direction) — não expomos a sintaxe nativa do Ransack (params[:q][:attr_predicate]) para fora. SORTABLE_COLUMNS, em DemandasController/UsersController, continua sendo a whitelist de colunas ordenáveis (traduzindo cada uma para o nome de atributo esperado pelo Ransack, ex.: "responsavel" => "user_name", a convenção do Ransack para "atributo name da associação user"), e status/role continuam validados contra o enum antes de chegar ao Ransack. Por segurança, o Ransack exige que cada model libere explicitamente o que pode ser buscado/ordenado — ver ransackable_attributes/ransackable_associations em Demanda e User.
Seleção múltipla e ordenação em todas as colunas: status (Demandas) e role/permissão (Acessos) agora aceitam mais de um valor ao mesmo tempo, via um <select multiple> — internamente vira status_in/role_in do Ransack (em vez de status_eq/role_eq). Nenhum valor selecionado equivale a "todos", igual antes. O campo de texto (q, título em Demandas / nome-ou-e-mail em Acessos) aceita mais de um termo separado por vírgula, combinados com OR via _cont_any — um único termo sem vírgula se comporta exatamente como antes (retrocompatível: os parâmetros antigos status=x/role=x, valor único, continuam funcionando — Array() normaliza os dois formatos). A tela de Acessos ganhou ordenação clicável em todas as colunas (Nome, E-mail, Permissão), igual já existia em Demandas — a lógica do cabeçalho clicável foi extraída pra ApplicationHelper#sort_header, reaproveitada pelas duas telas (demanda_sort_header/user_sort_header).
As páginas de listagem (Demandas, Acessos) não repetem o nome da seção como um heading gigante logo abaixo do menu — o item ativo na navbar já indica onde você está, e o título de cada página fica no <title> da aba do navegador (content_for :page_title); o %h1 continua no HTML por acessibilidade, só que visualmente oculto (visually-hidden).
Os alertas (flash) são fecháveis, com um "×" no canto (alert-dismissible do Bootstrap), e também se fecham sozinhos depois de 40 segundos (app/javascript/application.js, ouvindo turbo:load) — o que vier primeiro, clique ou tempo.
Uma barra fixa no topo de toda página (com ou sem login, incluindo a própria tela de login) oferece:
- Tamanho da fonte (
A−/A+/↺A): aumenta ou diminui o texto de toda a aplicação em passos de 10% (entre 80% e 150%), com um botão para voltar ao padrão. A escolha fica salva no navegador (localStorage) e vale para as próximas visitas; - Alto contraste (
◐): alterna para um esquema de cores fundo preto / texto branco / destaque amarelo — a mesma convenção usada no modo de alto contraste nativo do Windows. A escolha também fica salva; - Libras (
♿): o VLibras, widget oficial do governo federal (o mesmo padrão usado em sites gov.br, incluindo o da CGE-CE que inspirou a identidade visual deste app) para tradução do conteúdo em Língua Brasileira de Sinais; - o mesmo botão
♿leva também a/acessibilidade, uma página pública (não exige login) explicando cada um desses recursos.
A implementação de fonte/contraste é só JS (app/javascript/application.js, objeto window.TkAccessibility) manipulando o <html> (que sobrevive à troca de <body> nas navegações via Turbo Drive) — por isso não tem cobertura em RSpec, na mesma linha do que já vale para outras interações só-JS do projeto; a rota /acessibilidade em si (pública, responde 200 com ou sem login) está coberta em spec/requests/pages_spec.rb. A barra de busca com microfone que aparece em referências de sites gov.br não foi incluída aqui por não ter um caso de uso claro nesta aplicação (o app já tem busca por título/nome nas telas de Demandas e Acessos).
O locale padrão da aplicação é pt-BR (ver config/application.rb), mas nem o Rails nem o Devise têm tradução embutida para esse locale — sem config/locales/pt-BR.yml, mensagens como a de "faça login para continuar" apareciam como Translation missing. Esse arquivo traduz as mensagens do Devise (login, logout, credenciais inválidas, recuperação de senha), as mensagens padrão de validação do Rails (não pode ficar em branco, já está em uso etc.), incluindo os nomes dos campos (Título, E-mail, Permissão...), e também datetime.distance_in_words (usado por time_ago_in_words em "Atividade recente" no painel inicial — sem essa tradução, o mesmo Translation missing apareceria ali). A regressão (mensagem sem login e credenciais inválidas) tem teste dedicado em spec/requests/devise_i18n_spec.rb.
A política está em config/initializers/content_security_policy.rb, e o objetivo dela é um só: se algum dia entrar HTML de terceiro numa página, o navegador se recuse a executar script que não venha de uma origem declarada.
Por isso script-src não tem 'unsafe-inline' — e isso custou uma refatoração. Os botões da barra de acessibilidade e o link do tour usavam onclick inline, que só executa com essa diretiva ligada (nonce vale para tag <script>, não para atributo de evento). Passaram a declarar data-tk-action, resolvido por um único listener delegado no fim de app/javascript/application.js. Manter cinco atributos não valeria abrir mão de quase todo o valor do CSP.
style-src mantém 'unsafe-inline', e isso é concessão consciente: o Bootstrap escreve estilo inline em tempo de execução (tooltip, dropdown, collapse) e as barras de carga do dashboard calculam a largura no servidor. Injeção de CSS deforma a página; não executa código.
O nonce fica preso à sessão, não à requisição. Nonce novo a cada resposta quebra o Turbo Drive, que troca o <body> sem criar documento novo e mantém em vigor o CSP da primeira resposta — o script inline da página seguinte chegaria com um nonce que a política não conhece. O comentário no initializer detalha por que o gerador padrão do scaffold do Rails também não serve sozinho.
Os dois arquivos do Bootstrap vindos do jsDelivr carregam com integrity (SHA-384) e crossorigin. Sem SRI, um comprometimento do CDN entrega JavaScript arbitrário dentro de uma origem que o CSP autoriza. Não há SRI no CSS do Google Fonts (a resposta varia conforme o navegador, então não há hash estável) nem no plugin do VLibras (a URL não é versionada e o arquivo é atualizado no lugar — um hash fixo quebraria o widget silenciosamente na próxima atualização do gov.br).
spec/requests/content_security_policy_spec.rb cobre isso. Os exemplos verificam ausências de propósito: uma diretiva afrouxada não quebra nenhuma tela, então sem teste explícito a proteção sumiria sem ninguém notar.
Limitação conhecida, anterior a este CSP: o Turbo reexecuta o <script src> do VLibras a cada navegação e o plugin redeclara sua variável vw, gerando Uncaught SyntaxError no console. Verificado com o CSP desativado — o widget continua funcionando.
A tela de login usa Turbo Drive (gem turbo-rails), que exige que toda resposta 2xx a um POST de formulário seja um redirect — senão lança Error: Form responses must redirect to another location no console do navegador e não faz nada (a tela trava sem nenhum feedback pro usuário, nem a mensagem de erro aparece).
Por padrão, o responder do Devise devolve 200 OK quando um login falha (ele re-renderiza a tela de login com o erro, mas sem redirecionar) — o que é exatamente o caso que quebra o Turbo Drive. Desde a v5, o próprio Devise recomenda (no template do seu gerador) configurar:
config.responder.error_status = :unprocessable_entity # 422 em vez de 200
config.responder.redirect_status = :see_other # 303 em vez de 302com 422 o Turbo Drive passa a tratar a resposta como "renderizar no lugar" (igual já faz para erros de validação em formulários comuns), em vez de reclamar. Isso já está configurado em config/initializers/devise.rb; a regressão tem teste dedicado em spec/requests/devise_turbo_spec.rb.