From 88f19dd8f5b5582da2bb391bacd07e97b290030c1990e9859f53cc37b36540a4 Mon Sep 17 00:00:00 2001 From: victor Date: Mon, 24 Aug 2026 17:32:16 -0300 Subject: [PATCH] =?UTF-8?q?Implanta=C3=A7=C3=A3o=20da=20gera=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20mensagem=20de=20forma=20livre=20e=20mudan=C3=A7a=20na?= =?UTF-8?q?=20engine=20de=20mensagem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + README.md | 330 ++++++++++++++++++ app/controllers/admin/contatos_controller.rb | 77 ++++ .../admin/edicao_lancamentos_controller.rb | 21 ++ .../admin/eventos_notificacao_controller.rb | 140 ++++++++ .../admin/grupos_contato_controller.rb | 74 ++++ .../admin/mensagem_templates_controller.rb | 75 ++++ .../admin/notificacao_envios_controller.rb | 23 ++ .../admin/whatsapp_sessoes_controller.rb | 80 +++++ app/jobs/detectar_mudancas_operacao_job.rb | 64 ++++ app/jobs/disparar_eventos_agendados_job.rb | 48 +++ app/jobs/notificacao_job.rb | 26 ++ app/mailers/notificacao_mailer.rb | 18 + app/models/configuracao_notificacao.rb | 61 +++- app/models/consolidacao.rb | 7 + app/models/contato.rb | 68 ++++ app/models/evento_notificacao.rb | 119 +++++++ app/models/grupo_contato.rb | 38 ++ app/models/grupo_evento_assinatura.rb | 22 ++ app/models/mensagem_template.rb | 85 +++++ app/models/notificacao_envio.rb | 41 +++ app/models/operacao_snapshot.rb | 41 +++ app/policies/contato_policy.rb | 20 ++ app/policies/evento_notificacao_policy.rb | 23 ++ app/policies/grupo_contato_policy.rb | 3 + app/policies/notificacao_envio_policy.rb | 12 + app/policies/whatsapp_sessao_policy.rb | 13 + app/services/analytics/resumo_operacao.rb | 50 +++ app/services/notificacao/blocos.rb | 65 ++++ app/services/notificacao/cliente_whatsapp.rb | 98 ++++++ app/services/notificacao/despachante.rb | 155 ++++++++ app/services/notificacao/gatilhos.rb | 133 +++++++ app/services/notificacao/renderizador.rb | 159 +++++++++ app/services/notificacao/variaveis.rb | 78 +++++ app/services/notificacao/whatsapp.rb | 41 +++ app/services/notificacao_service.rb | 125 +++++-- app/views/admin/contatos/_form.html.erb | 52 +++ app/views/admin/contatos/edit.html.erb | 4 + app/views/admin/contatos/index.html.erb | 74 ++++ app/views/admin/contatos/new.html.erb | 4 + .../admin/eventos_notificacao/_form.html.erb | 102 ++++++ .../admin/eventos_notificacao/edit.html.erb | 4 + .../admin/eventos_notificacao/index.html.erb | 90 +++++ .../admin/eventos_notificacao/new.html.erb | 4 + app/views/admin/grupos_contato/_form.html.erb | 36 ++ app/views/admin/grupos_contato/edit.html.erb | 4 + app/views/admin/grupos_contato/index.html.erb | 65 ++++ app/views/admin/grupos_contato/new.html.erb | 4 + .../admin/mensagem_templates/edit.html.erb | 314 +++++++++++++++++ .../admin/notificacao_envios/index.html.erb | 95 +++++ .../admin/whatsapp_sessoes/show.html.erb | 114 ++++++ app/views/layouts/_navbar.html.erb | 14 + .../notificacao_mailer/mensagem.html.erb | 12 + config/routes.rb | 32 ++ config/schedule.rb | 19 + ...60824000001_create_notificacao_contatos.rb | 41 +++ ...260824000002_create_notificacao_eventos.rb | 70 ++++ ...0260824000003_create_notificacao_envios.rb | 31 ++ ...dd_baileys_to_configuracao_notificacoes.rb | 30 ++ ...0260824000005_create_mensagem_templates.rb | 29 ++ ...0260824000006_create_operacao_snapshots.rb | 27 ++ ...0260824000007_semear_eventos_de_gatilho.rb | 35 ++ docker-compose.yml | 26 ++ lib/tasks/notificacao.rake | 11 + spec/factories.rb | 28 ++ .../detectar_mudancas_operacao_job_spec.rb | 59 ++++ .../disparar_eventos_agendados_job_spec.rb | 72 ++++ spec/models/contato_spec.rb | 43 +++ spec/models/evento_notificacao_spec.rb | 69 ++++ spec/models/mensagem_template_spec.rb | 67 ++++ spec/services/notificacao/despachante_spec.rb | 134 +++++++ spec/services/notificacao/gatilhos_spec.rb | 69 ++++ .../services/notificacao/renderizador_spec.rb | 73 ++++ spec/services/notificacao/whatsapp_spec.rb | 35 ++ whatsapp/Dockerfile | 22 ++ whatsapp/package.json | 15 + whatsapp/server.js | 205 +++++++++++ 77 files changed, 4650 insertions(+), 25 deletions(-) create mode 100644 app/controllers/admin/contatos_controller.rb create mode 100644 app/controllers/admin/eventos_notificacao_controller.rb create mode 100644 app/controllers/admin/grupos_contato_controller.rb create mode 100644 app/controllers/admin/mensagem_templates_controller.rb create mode 100644 app/controllers/admin/notificacao_envios_controller.rb create mode 100644 app/controllers/admin/whatsapp_sessoes_controller.rb create mode 100644 app/jobs/detectar_mudancas_operacao_job.rb create mode 100644 app/jobs/disparar_eventos_agendados_job.rb create mode 100644 app/jobs/notificacao_job.rb create mode 100644 app/mailers/notificacao_mailer.rb create mode 100644 app/models/contato.rb create mode 100644 app/models/evento_notificacao.rb create mode 100644 app/models/grupo_contato.rb create mode 100644 app/models/grupo_evento_assinatura.rb create mode 100644 app/models/mensagem_template.rb create mode 100644 app/models/notificacao_envio.rb create mode 100644 app/models/operacao_snapshot.rb create mode 100644 app/policies/contato_policy.rb create mode 100644 app/policies/evento_notificacao_policy.rb create mode 100644 app/policies/grupo_contato_policy.rb create mode 100644 app/policies/notificacao_envio_policy.rb create mode 100644 app/policies/whatsapp_sessao_policy.rb create mode 100644 app/services/analytics/resumo_operacao.rb create mode 100644 app/services/notificacao/blocos.rb create mode 100644 app/services/notificacao/cliente_whatsapp.rb create mode 100644 app/services/notificacao/despachante.rb create mode 100644 app/services/notificacao/gatilhos.rb create mode 100644 app/services/notificacao/renderizador.rb create mode 100644 app/services/notificacao/variaveis.rb create mode 100644 app/services/notificacao/whatsapp.rb create mode 100644 app/views/admin/contatos/_form.html.erb create mode 100644 app/views/admin/contatos/edit.html.erb create mode 100644 app/views/admin/contatos/index.html.erb create mode 100644 app/views/admin/contatos/new.html.erb create mode 100644 app/views/admin/eventos_notificacao/_form.html.erb create mode 100644 app/views/admin/eventos_notificacao/edit.html.erb create mode 100644 app/views/admin/eventos_notificacao/index.html.erb create mode 100644 app/views/admin/eventos_notificacao/new.html.erb create mode 100644 app/views/admin/grupos_contato/_form.html.erb create mode 100644 app/views/admin/grupos_contato/edit.html.erb create mode 100644 app/views/admin/grupos_contato/index.html.erb create mode 100644 app/views/admin/grupos_contato/new.html.erb create mode 100644 app/views/admin/mensagem_templates/edit.html.erb create mode 100644 app/views/admin/notificacao_envios/index.html.erb create mode 100644 app/views/admin/whatsapp_sessoes/show.html.erb create mode 100644 app/views/notificacao_mailer/mensagem.html.erb create mode 100644 db/migrate/20260824000001_create_notificacao_contatos.rb create mode 100644 db/migrate/20260824000002_create_notificacao_eventos.rb create mode 100644 db/migrate/20260824000003_create_notificacao_envios.rb create mode 100644 db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb create mode 100644 db/migrate/20260824000005_create_mensagem_templates.rb create mode 100644 db/migrate/20260824000006_create_operacao_snapshots.rb create mode 100644 db/migrate/20260824000007_semear_eventos_de_gatilho.rb create mode 100644 lib/tasks/notificacao.rake create mode 100644 spec/jobs/detectar_mudancas_operacao_job_spec.rb create mode 100644 spec/jobs/disparar_eventos_agendados_job_spec.rb create mode 100644 spec/models/contato_spec.rb create mode 100644 spec/models/evento_notificacao_spec.rb create mode 100644 spec/models/mensagem_template_spec.rb create mode 100644 spec/services/notificacao/despachante_spec.rb create mode 100644 spec/services/notificacao/gatilhos_spec.rb create mode 100644 spec/services/notificacao/renderizador_spec.rb create mode 100644 spec/services/notificacao/whatsapp_spec.rb create mode 100644 whatsapp/Dockerfile create mode 100644 whatsapp/package.json create mode 100644 whatsapp/server.js diff --git a/.env.example b/.env.example index 709af88..c3b4c29 100644 --- a/.env.example +++ b/.env.example @@ -78,3 +78,11 @@ NOTIFICACAO_SECRET= # ── App ─────────────────────────────────────────────────────── APP_HOST=localhost:3000 APP_NAME=Reem Logística + +# ── WhatsApp (Baileys — sessão pareada por QR code) ──────────────────────── +# Token compartilhado entre o Rails e o container `whatsapp`. Qualquer string +# longa e aleatória: `openssl rand -hex 32`. Sem ele a ponte recusa TUDO. +WHATSAPP_TOKEN=troque_por_uma_string_aleatoria_longa +# URL da ponte dentro da rede do compose (não mexa, a menos que troque o nome +# do serviço no docker-compose.yml). +BAILEYS_URL=http://whatsapp:3001 diff --git a/README.md b/README.md index 075e16c..cc6b91b 100644 --- a/README.md +++ b/README.md @@ -2258,3 +2258,333 @@ docker compose exec app bin/rails runner ' ``` + +--- + +
+📣 Notificações: WhatsApp por QR (Baileys) no lugar do Twilio + contatos, grupos e eventos — ETAPA 1 (24/08/2026) + +> ⚠️ **STATUS: implementado, NADA executado.** Não há Ruby/Bundler, Postgres nem Docker rodando na +> máquina de desenvolvimento. Foi conferida a sintaxe de todos os `.rb`/`.erb` (com um checker que +> emula o handler ERB do Rails, porque `<%= form_with … do %>` não passa no ERB da stdlib) e do +> `server.js` (`node --check`). **Migration, `npm install`, build do container, pareamento do QR e a +> suíte continuam pendentes** — roteiro no fim. + +> Esta é a **etapa 1 de 3**. Ver "O que NÃO está aqui" no fim. + +### 🎯 O problema +O canal de WhatsApp era Twilio (pago). Além disso, "quem recebe" era implícito: o +`NotificacaoService` procurava o `User` motorista **pelo nome** da consolidação. Quem não é usuário +do sistema — diretoria, cliente, terceiro — não tinha como ser avisado, e não havia tela nenhuma +para controlar isso. O corpo da mensagem era string interpolada em Ruby. + +### 🆕 O que existe agora + +| Tela | O quê | +|---|---| +| **Notificações → Contatos** | Cadastro manual: nome, WhatsApp e/ou e-mail, grupo. Quem tem só número recebe só WhatsApp. | +| **Notificações → Grupos** | Diretoria, Operação, Motoristas… É o **grupo** que assina os eventos. | +| **Notificações → Eventos** | Cria eventos e marca quais grupos recebem, por qual canal. Gatilho `manual` tem botão "disparar agora". | +| **Notificações → WhatsApp** | Pareamento por **QR code**, status ao vivo, envio de teste, desconectar. | +| **Notificações → Envios** | Log de tudo que saiu: destinatário, canal, situação e o erro real. | + +### ⚙️ Pontos não-óbvios + +**Container Node novo (`whatsapp/`).** Não existe biblioteca Ruby que fale o protocolo do WhatsApp +Web — é Baileys. A ponte expõe `/status`, `/enviar`, `/logout` e `/health`, protegida por +`WHATSAPP_TOKEN`. A **porta não é publicada** no compose: só o container do Rails alcança. Publicar +exporia um endpoint que manda mensagem em nome da empresa. + +**A sessão precisa de volume.** `whatsapp_auth:/data` — sem ele, cada deploy exige escanear o QR +de novo. + +**Envio serializado e com intervalo.** Disparo em rajada é o que mais causa banimento no canal não +oficial. A fila do Node serializa e o Rails pausa entre mensagens (`whatsapp_intervalo_segundos`, +nasce em 5s). Como `sleep(5) × 30 contatos` penduraria o Puma por 2min30, o envio roda em +`NotificacaoJob`, **nunca dentro da requisição**. + +**`whatsapp_provedor` nasce em `twilio`.** O deploy não muda o comportamento até o ADM parear o QR +e trocar o provedor na tela. `Notificacao::Whatsapp` é o ponto único que escolhe — trocar +Twilio ↔ Baileys é um campo, não um `if` espalhado. + +**Nomes de rota ≠ nomes de controller, de propósito.** `grupos_contato` e `eventos_notificacao` +têm singular igual ao plural para o Inflector (que não fala português) e o Rails sufixaria o helper +de index com `_index` — pegadinha silenciosa. As rotas se chamam `grupos`, `eventos`, `envios`. +Por isso o `form_with` dos grupos passa `url:` explícita: a rota polimórfica de `GrupoContato` +procuraria `admin_grupo_contato_path`. + +**O aviso pessoal ao motorista não regrediu.** Ele continua recebendo o e-mail formatado do +`ConsolidacaoMailer` (não virou texto puro); o que mudou é que o WhatsApp passa pelo provedor +escolhido e **tudo fica logado**. Os grupos recebem uma cópia via `Despachante`, com +`envolvido: nil` para o motorista não receber duas vezes. + +**Log em tabela, não em arquivo.** O envio engole exceção de propósito (um SMTP fora do ar não pode +travar um fechamento). Com sessão QR — que cai sozinha e exige repareamento — "o motorista +recebeu?" vira pergunta de rotina, e a resposta precisava sair do `log/production.log`. + +### ⚠️ O risco, dito na tela +Conectar por QR usa a porta do WhatsApp Web por engenharia reversa: está **fora dos Termos do +WhatsApp** e a Meta **pode banir o número** sem aviso. A tela de pareamento diz isso em texto e +recomenda **chip dedicado**, não o número principal da operação. + +### 📂 Arquivos +``` +db/migrate/20260824000001_create_notificacao_contatos.rb (NOVO) +db/migrate/20260824000002_create_notificacao_eventos.rb (NOVO — semeia os 2 eventos atuais) +db/migrate/20260824000003_create_notificacao_envios.rb (NOVO) +db/migrate/20260824000004_add_baileys_to_configuracao_...rb (NOVO) +whatsapp/{server.js,package.json,Dockerfile} (NOVO — ponte Baileys) +docker-compose.yml (serviço whatsapp + volume) +app/models/{grupo_contato,contato,evento_notificacao}.rb (NOVO) +app/models/{grupo_evento_assinatura,notificacao_envio}.rb (NOVO) +app/models/configuracao_notificacao.rb (provedor + credenciais Baileys) +app/services/notificacao/{cliente_whatsapp,whatsapp,despachante}.rb (NOVO) +app/services/notificacao_service.rb (grupos + provedor + log) +app/jobs/notificacao_job.rb (NOVO) +app/mailers/notificacao_mailer.rb + view (NOVO — e-mail genérico) +app/controllers/admin/{grupos_contato,contatos,eventos_notificacao}_controller.rb (NOVO) +app/controllers/admin/{whatsapp_sessoes,notificacao_envios}_controller.rb (NOVO) +app/policies/{contato,grupo_contato,evento_notificacao,notificacao_envio,whatsapp_sessao}_policy.rb (NOVO) +app/views/admin/{grupos_contato,contatos,eventos_notificacao,whatsapp_sessoes,notificacao_envios}/ (NOVO) +app/views/layouts/_navbar.html.erb (seção Notificações) +config/routes.rb + .env.example +spec/{models,services}/… (NOVO) +``` + +> **4 migrations e 1 container novo.** Nenhuma gem nova no Gemfile. + +### ⏳ Pendente — roteiro +```bash +# 1. Gerar o token da ponte e colocar no .env do servidor: +openssl rand -hex 32 # -> WHATSAPP_TOKEN=... +# e BAILEYS_URL=http://whatsapp:3001 + +# 2. Subir (a 1ª vez baixa o Baileys; leva alguns minutos): +docker compose up -d --build + +# 3. Migrar: +docker compose exec app bin/rails db:migrate + +# 4. Suíte (não pôde ser executada aqui — sem Ruby/Bundler local): +docker compose exec app bundle exec rspec \ + spec/models/contato_spec.rb spec/models/evento_notificacao_spec.rb \ + spec/services/notificacao/ + +# 5. Parear: Notificações → WhatsApp → ler o QR com o CHIP DEDICADO. +# Depois: Configurações → Notificações → provedor = Baileys, e enviar um teste. + +# 6. Cadastrar um grupo, um contato e marcar o grupo nos 2 eventos de sistema. +# Conferir o resultado em Notificações → Envios. +``` + +### 🚧 O que NÃO está aqui (etapas 2 e 3) +- **Editor de blocos** (arrastar cabeçalho / tabela de valores / aviso / botão / rodapé, com preview + e HTML montado no e-mail). Hoje o texto dos 2 eventos de sistema ainda é o do código, e o disparo + manual usa um campo de texto. +- **Gatilhos novos**: `valor_alterado`, `operacao_alterada` e `agendado` já existem como opção no + cadastro e o `Despachante` os atende — mas **ainda não há código chamando** esses gatilhos, nem o + job de varredura do agendado. Um evento com esses gatilhos hoje só dispara pelo botão manual. + +
+ +--- + +
+🧱 Editor de blocos das mensagens — ETAPA 2 (24/08/2026) + +> ⚠️ **STATUS: implementado, NADA executado.** Sem Ruby/Bundler/Postgres/Docker na máquina de +> desenvolvimento. Conferida a sintaxe de todos os `.rb`, de todos os `.erb` (checker que emula o +> handler do Rails) e **do JavaScript do editor** (`node --check` sobre o ` + diff --git a/app/views/admin/notificacao_envios/index.html.erb b/app/views/admin/notificacao_envios/index.html.erb new file mode 100644 index 0000000..710a37e --- /dev/null +++ b/app/views/admin/notificacao_envios/index.html.erb @@ -0,0 +1,95 @@ +<% ctx = { status: params[:status], canal: params[:canal] }.compact_blank %> +
+
+

Envios

+

+ O que saiu, para quem e se chegou. Uma falha de envio nunca trava o fechamento — + então é aqui que ela aparece. +

+
+ +
+ <% [['', 'Todos'], ['enviado', 'Enviados'], ['falhou', 'Falhados'], ['pendente', 'Pendentes']].each do |valor, rotulo| %> + <% ativo = params[:status].to_s == valor %> + <%= link_to rotulo, admin_envios_path(ctx.merge(status: valor.presence).compact), data: { turbo: false }, + class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> + <% end %> + | + <% [['', 'Todos os canais'], ['whatsapp', 'WhatsApp'], ['email', 'E-mail']].each do |valor, rotulo| %> + <% ativo = params[:canal].to_s == valor %> + <%= link_to rotulo, admin_envios_path(ctx.merge(canal: valor.presence).compact), data: { turbo: false }, + class: "px-3 py-2 rounded-xl text-sm border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> + <% end %> + <% if @total_falhas.positive? %> + <%= @total_falhas %> falha(s) neste filtro + <% end %> +
+ +
+ <% if @envios.empty? %> +

Nenhum envio registrado ainda.

+ <% else %> +
+ + + + + + + + + + + + + <% @envios.each do |envio| %> + + + + + + + + + <% end %> + +
QuandoEventoDestinatárioCanalSituaçãoMensagem
<%= envio.created_at.strftime('%d/%m/%Y %H:%M') %><%= envio.evento_notificacao&.nome || '—' %> + <%= envio.contato&.nome || envio.user&.nome || '—' %> + <%= envio.destino %> + <%= envio.canal == 'whatsapp' ? 'WhatsApp' : 'E-mail' %> + <% cor = { 'enviado' => 'text-green-400', 'falhou' => 'text-red-400' }.fetch(envio.status, 'text-yellow-400') %> + <%= envio.status.capitalize %> + <% if envio.falhou? && envio.erro.present? %> + <%= envio.erro.truncate(160) %> + <% end %> + + <%= envio.corpo.to_s.truncate(90) %> +
+
+ + <% if @pagy.pages > 1 %> + <% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %> + + <% end %> + <% end %> +
+
diff --git a/app/views/admin/whatsapp_sessoes/show.html.erb b/app/views/admin/whatsapp_sessoes/show.html.erb new file mode 100644 index 0000000..bd56113 --- /dev/null +++ b/app/views/admin/whatsapp_sessoes/show.html.erb @@ -0,0 +1,114 @@ +
+
+

WhatsApp — conexão

+

+ Pareia o número da empresa com este servidor lendo um QR code, como no WhatsApp Web. +

+
+ + <%# O aviso fica na TELA, não só no código: quem parear precisa saber o que + está aceitando. %> +
+

⚠️ Este canal não é oficial.

+

+ A conexão por QR usa a porta do WhatsApp Web por engenharia reversa. É gratuita e ilimitada, + mas está fora dos Termos do WhatsApp — a Meta pode banir o número sem aviso. + Use um chip dedicado, nunca o número principal da operação. +

+

+ Disparo em rajada é o que mais causa banimento. O intervalo entre mensagens fica em + <%= link_to 'Configurações → Notificações', admin_configuracao_notificacao_path, + data: { turbo: false }, class: 'underline hover:text-white' %> + (hoje: <%= @config.intervalo_envio %>s). +

+
+ +
+ <% if @estado['conectado'] %> +
+ +
+

Conectado

+

+ Número <%= @estado['numero'] %> + <% if @config.whatsapp_conectado_em %> · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %><% end %> +

+
+
+ +
+ <%= form_with url: testar_admin_whatsapp_sessao_path, method: :post, data: { turbo: false }, + class: 'flex flex-wrap items-end gap-3' do |f| %> +
+ <%= f.label :numero, 'Enviar teste para', class: 'block text-gray-400 text-sm mb-1' %> + <%= f.text_field :numero, placeholder: '(11) 92005-1157', + class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %> +
+ <%= f.submit 'Enviar teste', + class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %> + <% end %> + + <%= button_to 'Desconectar', desconectar_admin_whatsapp_sessao_path, method: :delete, + form: { data: { turbo_confirm: 'Isso encerra a sessão e para TODOS os envios de WhatsApp até você ler o QR de novo. Confirmar?' } }, + class: 'text-red-400 hover:text-red-300 text-sm' %> +
+ + <% elsif @estado['qr'].present? %> +
+

Leia o QR code

+

+ No celular do chip dedicado: WhatsApp → Aparelhos conectados → + Conectar um aparelho → aponte para o código abaixo. +

+ <%# O QR expira em ~20s e a ponte gera outro — o polling abaixo troca a + imagem sozinho, senão o operador ficaria lendo um código morto. %> + QR code do WhatsApp +

O código se renova sozinho a cada poucos segundos.

+
+ + <% else %> +
+ +
+

Desconectado

+

+ <%= @estado['erro'].presence || @estado['ultimo_erro'].presence || + 'A ponte ainda não gerou um QR code. Aguarde alguns segundos e recarregue.' %> +

+ <% unless @cliente.configurado? %> +

+ Falta configurar a ponte: defina WHATSAPP_TOKEN no .env + (o mesmo valor do container whatsapp) e suba os containers. +

+ <% end %> +
+
+ <% end %> +
+
+ + diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 197d730..5e6e8f7 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -63,6 +63,20 @@ <%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %> <% end %> + + <%# Notificações: quem recebe (grupos/contatos) é admin+gerente; ligar o + WhatsApp e criar eventos manda mensagem em nome da empresa — só ADM. + Mesmo recorte das policies. %> +
+

Notificações

+ <%= nav_link_to 'Contatos', admin_contatos_path, icon: :usuarios %> + <%= nav_link_to 'Grupos', admin_grupos_path, icon: :tag %> + <%= nav_link_to 'Envios', admin_envios_path, icon: :historico %> + <% if current_user.admin? %> + <%= nav_link_to 'Eventos', admin_eventos_path, icon: :diario %> + <%= nav_link_to 'WhatsApp', admin_whatsapp_sessao_path, icon: :telefone %> + <% end %> +
<% end %> diff --git a/app/views/notificacao_mailer/mensagem.html.erb b/app/views/notificacao_mailer/mensagem.html.erb new file mode 100644 index 0000000..5c539ba --- /dev/null +++ b/app/views/notificacao_mailer/mensagem.html.erb @@ -0,0 +1,12 @@ +<%# Dois caminhos, e a diferença é de segurança: + - @html: o corpo veio do Notificacao::Renderizador, que escapa todo texto do + editor e toda variável. Sai como está, senão as tags viriam visíveis. + - senão: texto puro digitado — simple_format escapa e quebra em parágrafos. + Nunca inverter isso: `raw` em texto não renderizado seria injeção de HTML. %> +<% if @html %> + <%= raw @corpo %> +<% else %> +
+ <%= simple_format(@corpo) %> +
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index d271f38..0d925e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,6 +111,38 @@ Rails.application.routes.draw do resource :planilha_simpli_route, only: [:show], controller: 'planilhas_simpli_route' do get :baixar end + + # ── Notificações: quem recebe, o quê e por qual canal ────────────────── + # Grupos e contatos são a lista de destinatários cadastrada à mão; eventos + # ligam um gatilho aos grupos; a sessão do WhatsApp é o pareamento por QR. + # + # ⚠️ Os nomes das rotas (`grupos`, `eventos`, `envios`) NÃO são os nomes dos + # controllers de propósito: `grupos_contato` e `eventos_notificacao` têm + # singular igual ao plural para o Inflector (que não fala português), e aí + # o Rails sufixa o helper de index com `_index` — pegadinha silenciosa. + resources :grupos, except: [:show], controller: 'grupos_contato' + resources :contatos, except: [:show] + resources :eventos, except: [:show], controller: 'eventos_notificacao' do + member do + post :disparar # gatilho `manual` — manda mensagem de verdade + + # Editor de blocos. O template é identificado pelo CANAL, não por id: + # existe no máximo um por evento+canal (índice único), então uma URL com + # id seria um identificador a mais para o mesmo par. + get 'template/:canal', to: 'mensagem_templates#edit', as: :template + patch 'template/:canal', to: 'mensagem_templates#update' + post 'template/:canal/preview', to: 'mensagem_templates#preview', as: :preview_template + end + end + resources :envios, only: [:index], controller: 'notificacao_envios' + + # Pareamento do WhatsApp por QR code (ponte Baileys). `show` faz polling do + # status via JSON enquanto o QR não é lido. + resource :whatsapp_sessao, only: [:show], controller: 'whatsapp_sessoes' do + get :status # JSON { conectado, numero, qr } + post :testar # envia uma mensagem de teste + delete :desconectar + end end # API interna — Dashboard métricas diff --git a/config/schedule.rb b/config/schedule.rb index 67a603a..e143576 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -19,3 +19,22 @@ end every "0 1 * * *" do rake "simpli_route:series" end + +# De hora em hora — dispara os eventos de notificação com gatilho `agendado` +# que venceram (resumo diário/semanal da operação). +# +# POR QUE varredura horária e não uma linha por evento: hora e frequência são +# escolhidas na TELA e mudam a qualquer momento; reler o crontab a cada edição +# acoplaria a UI ao cron do sistema. `EventoNotificacao#vencido?` garante um +# disparo por dia (grava ultimo_disparo_em). Ver DispararEventosAgendadosJob. +every '0 * * * *' do + rake 'notificacao:agendados' +end + +# A cada 2h, das 07h às 21h — compara os números de cada operação com o retrato +# anterior e avisa o que mudou (NF que entrou/saiu, entrega que trocou de +# status). A maioria dessas mudanças acontece no SimpliRoute e chega pelo sync +# do espelho, então só uma varredura as detecta. Ver DetectarMudancasOperacaoJob. +every '0 7-21/2 * * *' do + rake 'notificacao:mudancas_operacao' +end diff --git a/db/migrate/20260824000001_create_notificacao_contatos.rb b/db/migrate/20260824000001_create_notificacao_contatos.rb new file mode 100644 index 0000000..1ce37a5 --- /dev/null +++ b/db/migrate/20260824000001_create_notificacao_contatos.rb @@ -0,0 +1,41 @@ +# Lista de destinatários das notificações, cadastrada à mão pelo ADM. +# +# Antes, "quem recebe" era implícito: o NotificacaoService procurava o User +# motorista pelo NOME da consolidação. Quem não é usuário do sistema (diretoria, +# cliente, terceiro) não tinha como ser avisado, e não havia tela nenhuma para +# controlar isso. +# +# O contato pertence a UM grupo, e é o GRUPO que assina os eventos (ver +# 20260824000002) — assim cadastrar alguém novo é escolher o grupo, não repetir +# a configuração pessoa por pessoa. +class CreateNotificacaoContatos < ActiveRecord::Migration[7.1] + def change + create_table :grupos_contato do |t| + t.string :nome, null: false + t.string :descricao + t.boolean :ativo, null: false, default: true + t.timestamps + end + add_index :grupos_contato, 'LOWER(nome)', unique: true, name: 'index_grupos_contato_nome_unico' + + create_table :contatos do |t| + t.references :grupo_contato, foreign_key: true, index: true + + t.string :nome, null: false + # E.164 normalizado (+5511920051157). Vazio = contato só de e-mail. + t.string :telefone + t.string :email + t.boolean :ativo, null: false, default: true + t.string :observacao + + # Quando o contato É um usuário do sistema (motorista), guardar o vínculo + # permite o disparo "avisar o envolvido" achar a pessoa certa sem depender + # de casar nome por string, que é o que se faz hoje. + t.references :user, foreign_key: true, index: true + + t.timestamps + end + add_index :contatos, :telefone + add_index :contatos, :email + end +end diff --git a/db/migrate/20260824000002_create_notificacao_eventos.rb b/db/migrate/20260824000002_create_notificacao_eventos.rb new file mode 100644 index 0000000..86d3e86 --- /dev/null +++ b/db/migrate/20260824000002_create_notificacao_eventos.rb @@ -0,0 +1,70 @@ +# Eventos de notificação + assinatura por grupo. +# +# O ADM cadastra quantos eventos quiser, mas o GATILHO sai de uma lista fixa: +# gatilho é código. `manual` cobre o aviso pontual (botão "disparar agora") e +# `agendado` cobre o resumo periódico; os demais são pontos que o sistema já +# conhece (consolidação finalizada, pagamento efetuado, valor alterado, dados da +# operação mudaram). +# +# `sistema: true` marca os eventos que nascem com o app: podem ser editados e +# desligados, mas não apagados — apagar deixaria o código disparando no vazio. +class CreateNotificacaoEventos < ActiveRecord::Migration[7.1] + GATILHOS = %w[manual consolidacao_finalizada pagamento_efetuado + valor_alterado operacao_alterada agendado].freeze + + def up + create_table :eventos_notificacao do |t| + t.string :nome, null: false + t.string :chave, null: false # slug estável usado pelo código + t.string :gatilho, null: false + t.string :descricao + t.boolean :ativo, null: false, default: true + t.boolean :sistema, null: false, default: false + + # Avisar também a pessoa diretamente envolvida no fato (o motorista do + # pagamento), além dos grupos assinantes. É o comportamento de hoje. + t.boolean :notificar_envolvido, null: false, default: true + + # Só para gatilho `agendado`. + t.string :frequencia # diaria | semanal + t.integer :hora, default: 8 # 0..23 + t.integer :dia_semana # 0..6 (domingo=0), só na semanal + t.datetime :ultimo_disparo_em + + t.timestamps + end + add_index :eventos_notificacao, :chave, unique: true + + create_table :grupo_evento_assinaturas do |t| + t.references :grupo_contato, null: false, foreign_key: true + t.references :evento_notificacao, null: false, foreign_key: true + t.string :canal, null: false, default: 'ambos' # ambos | whatsapp | email + t.boolean :ativo, null: false, default: true + t.timestamps + end + add_index :grupo_evento_assinaturas, %i[grupo_contato_id evento_notificacao_id], + unique: true, name: 'index_assinatura_grupo_evento_unico' + + # Os dois eventos que o código JÁ dispara hoje, para nada parar de funcionar + # no deploy. SQL cru de propósito: migration não deve depender de model. + agora = 'NOW()' + [ + ['Pagamento fechado', 'consolidacao_finalizada', 'consolidacao_finalizada', + 'Disparado quando uma consolidação é finalizada.'], + ['Pagamento efetuado', 'pagamento_efetuado', 'pagamento_efetuado', + 'Disparado quando o pagamento de um motorista é marcado como pago.'] + ].each do |nome, chave, gatilho, descricao| + execute(<<~SQL.squish) + INSERT INTO eventos_notificacao + (nome, chave, gatilho, descricao, ativo, sistema, notificar_envolvido, hora, created_at, updated_at) + VALUES (#{quote(nome)}, #{quote(chave)}, #{quote(gatilho)}, #{quote(descricao)}, + true, true, true, 8, #{agora}, #{agora}) + SQL + end + end + + def down + drop_table :grupo_evento_assinaturas + drop_table :eventos_notificacao + end +end diff --git a/db/migrate/20260824000003_create_notificacao_envios.rb b/db/migrate/20260824000003_create_notificacao_envios.rb new file mode 100644 index 0000000..77f27b4 --- /dev/null +++ b/db/migrate/20260824000003_create_notificacao_envios.rb @@ -0,0 +1,31 @@ +# Log de cada mensagem disparada — quem recebeu, por qual canal, com que texto e +# se deu certo. +# +# Hoje uma falha de envio só vira linha no log do Rails (NotificacaoService +# engole a exceção de propósito, para um SMTP fora do ar não travar o +# fechamento). Isso é certo, mas deixa o operador sem resposta para "o motorista +# recebeu?". Com o WhatsApp por sessão QR — que cai sozinho e precisa +# repareamento — essa pergunta passa a ser rotina. +class CreateNotificacaoEnvios < ActiveRecord::Migration[7.1] + def change + create_table :notificacao_envios do |t| + t.references :evento_notificacao, foreign_key: true, index: true + t.references :contato, foreign_key: true, index: true + t.references :user, foreign_key: true, index: true + + t.string :canal, null: false # whatsapp | email + t.string :destino, null: false # telefone E.164 ou e-mail + t.string :assunto + t.text :corpo + t.string :status, null: false, default: 'pendente' # pendente|enviado|falhou + t.text :erro + t.integer :tentativas, null: false, default: 0 + t.datetime :enviado_em + + t.timestamps + end + + add_index :notificacao_envios, %i[status created_at] + add_index :notificacao_envios, :created_at + end +end diff --git a/db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb b/db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb new file mode 100644 index 0000000..ebf1d9f --- /dev/null +++ b/db/migrate/20260824000004_add_baileys_to_configuracao_notificacoes.rb @@ -0,0 +1,30 @@ +# WhatsApp por sessão pareada com QR code (Baileys), no lugar do Twilio. +# +# `whatsapp_provedor` decide quem envia. Nasce com o valor que a instalação já +# usa ('twilio' se havia credencial configurada), então o deploy não muda o +# comportamento até o ADM parear o QR e trocar o provedor na tela. +# +# ⚠️ O envio por QR é a porta do WhatsApp Web (engenharia reversa), fora dos +# Termos do WhatsApp: a Meta PODE banir o número. Por isso o intervalo entre +# mensagens é configurável e nasce em 5s — disparo em rajada para dezenas de +# contatos é a forma mais rápida de perder o número. +class AddBaileysToConfiguracaoNotificacoes < ActiveRecord::Migration[7.1] + def up + change_table :configuracao_notificacoes, bulk: true do |t| + t.string :whatsapp_provedor, null: false, default: 'twilio' # twilio | baileys + t.string :baileys_url + t.text :baileys_token_cifrado + t.string :whatsapp_numero_conectado + t.datetime :whatsapp_conectado_em + t.integer :whatsapp_intervalo_segundos, null: false, default: 5 + end + end + + def down + change_table :configuracao_notificacoes, bulk: true do |t| + t.remove :whatsapp_provedor, :baileys_url, :baileys_token_cifrado, + :whatsapp_numero_conectado, :whatsapp_conectado_em, + :whatsapp_intervalo_segundos + end + end +end diff --git a/db/migrate/20260824000005_create_mensagem_templates.rb b/db/migrate/20260824000005_create_mensagem_templates.rb new file mode 100644 index 0000000..38a5f6e --- /dev/null +++ b/db/migrate/20260824000005_create_mensagem_templates.rb @@ -0,0 +1,29 @@ +# Corpo das mensagens montado pelo ADM, um template por EVENTO e por CANAL. +# +# Antes, o texto era string interpolada dentro do NotificacaoService: mudar uma +# palavra exigia deploy. Agora o ADM empilha blocos (cabeçalho, texto, tabela de +# valores, aviso, botão, rodapé) na tela. +# +# `blocos` é jsonb e não tabela filha de propósito: a ordem faz parte do dado +# (é uma lista, não um conjunto), o conteúdo de cada tipo de bloco é diferente, e +# salvar o template inteiro numa transação evita estado meio-salvo. A validação +# da forma acontece no model (MensagemTemplate#normalizar_blocos), não no banco. +class CreateMensagemTemplates < ActiveRecord::Migration[7.1] + def change + create_table :mensagem_templates do |t| + t.references :evento_notificacao, null: false, foreign_key: true + + t.string :canal, null: false # whatsapp | email + t.string :assunto # só e-mail + t.jsonb :blocos, null: false, default: [] + t.boolean :ativo, null: false, default: true + + t.timestamps + end + + # Um template por evento+canal: dois templates ativos para o mesmo par + # deixariam o envio escolhendo em silêncio. + add_index :mensagem_templates, %i[evento_notificacao_id canal], + unique: true, name: 'index_template_evento_canal_unico' + end +end diff --git a/db/migrate/20260824000006_create_operacao_snapshots.rb b/db/migrate/20260824000006_create_operacao_snapshots.rb new file mode 100644 index 0000000..c1ed802 --- /dev/null +++ b/db/migrate/20260824000006_create_operacao_snapshots.rb @@ -0,0 +1,27 @@ +# Retrato dos números de cada operação, para detectar o que MUDOU entre uma +# varredura e a seguinte. +# +# A maior parte das mudanças da operação não passa pelo nosso código: elas +# acontecem no SimpliRoute e chegam pelo sync do espelho (que é read-only aqui). +# Sem guardar o retrato anterior não há como dizer "3 NFs entraram, 1 saiu" — +# só dá para mostrar o número de agora, que é o que os dashboards já fazem. +# +# Uma linha por tabela de operação. É snapshot, não histórico: a linha é +# ATUALIZADA a cada varredura. Guardar a série inteira seria outra feature (e a +# tabela cresceria por operação × varredura sem ninguém consultar). +class CreateOperacaoSnapshots < ActiveRecord::Migration[7.1] + def change + create_table :operacao_snapshots do |t| + t.string :tabela, null: false + t.integer :total_notas, null: false, default: 0 + t.integer :entregues, null: false, default: 0 + t.integer :nao_entregues, null: false, default: 0 + t.integer :pendentes, null: false, default: 0 + t.datetime :capturado_em, null: false + + t.timestamps + end + + add_index :operacao_snapshots, :tabela, unique: true + end +end diff --git a/db/migrate/20260824000007_semear_eventos_de_gatilho.rb b/db/migrate/20260824000007_semear_eventos_de_gatilho.rb new file mode 100644 index 0000000..5beb390 --- /dev/null +++ b/db/migrate/20260824000007_semear_eventos_de_gatilho.rb @@ -0,0 +1,35 @@ +# Cria os eventos de sistema dos gatilhos ligados na etapa 3. +# +# Sem isso, `Despachante.disparar(chave: 'valor_alterado')` não acharia evento +# nenhum e o gatilho ficaria mudo — e o ADM teria de adivinhar que precisa criar +# um evento cujo nome gere exatamente aquela `chave`. +# +# Nascem ATIVOS e isso é seguro: sem grupo assinante não há destinatário, então +# nada é enviado até o ADM marcar um grupo na tela. +class SemearEventosDeGatilho < ActiveRecord::Migration[7.1] + EVENTOS = [ + ['Valor alterado no fechamento', 'valor_alterado', 'valor_alterado', + 'Disparado quando o valor de um motorista muda depois da consolidação finalizada.'], + ['Dados da operação mudaram', 'operacao_alterada', 'operacao_alterada', + 'NF que entrou ou saiu, entrega que trocou de status, lançamento corrigido no SimpliRoute.'] + ].freeze + + def up + EVENTOS.each do |nome, chave, gatilho, descricao| + # Idempotente: rodar de novo (ou num banco que já tenha o evento criado à + # mão) não pode estourar índice único. + next if select_value("SELECT 1 FROM eventos_notificacao WHERE chave = #{quote(chave)}") + + execute(<<~SQL.squish) + INSERT INTO eventos_notificacao + (nome, chave, gatilho, descricao, ativo, sistema, notificar_envolvido, hora, created_at, updated_at) + VALUES (#{quote(nome)}, #{quote(chave)}, #{quote(gatilho)}, #{quote(descricao)}, + true, true, true, 8, NOW(), NOW()) + SQL + end + end + + def down + EVENTOS.each { |_, chave, _, _| execute("DELETE FROM eventos_notificacao WHERE chave = #{quote(chave)}") } + end +end diff --git a/docker-compose.yml b/docker-compose.yml index 4bb6c02..1b20001 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,32 @@ services: command: bash -c "rm -f tmp/pids/server.pid && (bundle check || bundle install) && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0" # O banco PostgreSQL já existe externamente. # Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL. + depends_on: + - whatsapp + + # Ponte com o WhatsApp (Baileys, sessão pareada por QR). Container separado + # porque não existe biblioteca Ruby que fale o protocolo do WhatsApp Web — e + # porque isolar a sessão evita que uma queda dela derrube o Puma. + # + # ⚠️ A porta NÃO é publicada de propósito: quem alcança é só o container do + # Rails, pela rede interna do compose. Publicar exporia um endpoint que + # manda mensagem em nome da empresa. + whatsapp: + build: + context: ./whatsapp + dockerfile: Dockerfile + restart: unless-stopped + environment: + WHATSAPP_TOKEN: ${WHATSAPP_TOKEN} + WHATSAPP_DATA_DIR: /data + PORT: 3001 + volumes: + # A sessão pareada vive aqui. Sem este volume, cada deploy exige escanear + # o QR de novo. + - "whatsapp_auth:/data" + expose: + - "3001" volumes: bundle_cache: + whatsapp_auth: diff --git a/lib/tasks/notificacao.rake b/lib/tasks/notificacao.rake new file mode 100644 index 0000000..3cbec55 --- /dev/null +++ b/lib/tasks/notificacao.rake @@ -0,0 +1,11 @@ +namespace :notificacao do + desc 'Dispara os eventos agendados que venceram (roda de hora em hora pelo cron)' + task agendados: :environment do + DispararEventosAgendadosJob.perform_now + end + + desc 'Compara os números das operações com o retrato anterior e avisa o que mudou' + task mudancas_operacao: :environment do + DetectarMudancasOperacaoJob.perform_now + end +end diff --git a/spec/factories.rb b/spec/factories.rb index c91a81d..2e8d06d 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -53,6 +53,34 @@ FactoryBot.define do valor_aplicado { 15.0 } end + factory :grupo_contato do + sequence(:nome) { |n| "Grupo #{n}" } + ativo { true } + end + + factory :contato do + sequence(:nome) { |n| "Contato #{n}" } + sequence(:telefone) { |n| format('11 9%04d-%04d', n % 10000, (n * 7) % 10000) } + ativo { true } + association :grupo_contato + end + + factory :evento_notificacao do + sequence(:nome) { |n| "Evento #{n}" } + sequence(:chave) { |n| "evento_#{n}" } + gatilho { 'manual' } + ativo { true } + notificar_envolvido { true } + hora { 8 } + end + + factory :grupo_evento_assinatura do + association :grupo_contato + association :evento_notificacao + canal { 'ambos' } + ativo { true } + end + factory :historico_estimado do data_hora { Time.current } valor_total_estimado { 150.0 } diff --git a/spec/jobs/detectar_mudancas_operacao_job_spec.rb b/spec/jobs/detectar_mudancas_operacao_job_spec.rb new file mode 100644 index 0000000..0b790cb --- /dev/null +++ b/spec/jobs/detectar_mudancas_operacao_job_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe DetectarMudancasOperacaoJob do + let(:tabela) { 'gade_entregas_teste' } + + before do + create(:evento_notificacao, gatilho: 'operacao_alterada', chave: 'operacao_alterada') + allow(Operacao).to receive(:nomes_validos).and_return([tabela]) + allow(Operacao).to receive(:label).with(tabela).and_return('TESTE') + allow(Notificacao::Gatilhos).to receive(:operacao_alterada) + end + + def com_numeros(total:, entregues:, nao_entregues:, pendentes:) + allow(Analytics::OperacaoMetricas).to receive(:new).and_return( + instance_double(Analytics::OperacaoMetricas, total: total, sucesso: entregues, + recusas: nao_entregues, pendentes: pendentes) + ) + end + + # Sem retrato anterior toda operação pareceria "nova", e o primeiro deploy + # dispararia uma mensagem por operação cadastrada. + it 'primeira execução só grava o retrato, sem avisar' do + com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2) + + expect { described_class.perform_now }.to change(OperacaoSnapshot, :count).by(1) + expect(Notificacao::Gatilhos).not_to have_received(:operacao_alterada) + end + + it 'não avisa quando nada mudou' do + com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2) + described_class.perform_now + + described_class.perform_now + + expect(Notificacao::Gatilhos).not_to have_received(:operacao_alterada) + end + + it 'descreve o que mudou entre os dois retratos' do + com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2) + described_class.perform_now + + com_numeros(total: 103, entregues: 92, nao_entregues: 8, pendentes: 3) + described_class.perform_now + + expect(Notificacao::Gatilhos).to have_received(:operacao_alterada) do |args| + expect(args[:operacao]).to eq('TESTE') + expect(args[:o_que_mudou]).to include('3 notas na operação a mais') + expect(args[:o_que_mudou]).to include('2 entregues a mais') + end + expect(OperacaoSnapshot.find_by(tabela: tabela).total_notas).to eq(103) + end + + it 'não varre nada quando nenhum evento usa esse gatilho' do + EventoNotificacao.where(gatilho: 'operacao_alterada').destroy_all + expect(Analytics::OperacaoMetricas).not_to receive(:new) + + described_class.perform_now + end +end diff --git a/spec/jobs/disparar_eventos_agendados_job_spec.rb b/spec/jobs/disparar_eventos_agendados_job_spec.rb new file mode 100644 index 0000000..9ded4fe --- /dev/null +++ b/spec/jobs/disparar_eventos_agendados_job_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe DispararEventosAgendadosJob do + let!(:evento) do + create(:evento_notificacao, gatilho: 'agendado', frequencia: 'diaria', hora: 8, ativo: true) + end + let(:agora) { Time.zone.local(2026, 8, 24, 9, 0) } + + let(:resumo) do + instance_double(Analytics::ResumoOperacao, vazio?: false, + numeros: { 'entregues' => '10', 'recusas' => '1', 'pendentes' => '0', + 'retentativas' => '0', 'fora_operacao' => '0' }) + end + + before do + allow(Analytics::ResumoOperacao).to receive(:new).and_return(resumo) + allow(Notificacao::Despachante).to receive(:disparar) + end + + it 'dispara o evento vencido e marca o disparo' do + described_class.perform_now(agora) + + expect(Notificacao::Despachante).to have_received(:disparar) + expect(evento.reload.ultimo_disparo_em).to be_present + end + + # `vencido?` depende de ultimo_disparo_em; sem gravar, a varredura horária + # mandaria a mesma mensagem a cada hora até o dia virar. + it 'não repete no mesmo dia' do + described_class.perform_now(agora) + described_class.perform_now(agora + 1.hour) + + expect(Notificacao::Despachante).to have_received(:disparar).once + end + + it 'não dispara antes da hora configurada' do + described_class.perform_now(Time.zone.local(2026, 8, 24, 7, 0)) + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + it 'ignora evento desativado' do + evento.update!(ativo: false) + described_class.perform_now(agora) + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + # Resumo zerado só treinaria o leitor a ignorar a mensagem — mas precisa + # marcar como disparado, senão tenta de hora em hora. + it 'período sem movimento: não envia, mas marca o disparo' do + allow(resumo).to receive(:vazio?).and_return(true) + + described_class.perform_now(agora) + + expect(Notificacao::Despachante).not_to have_received(:disparar) + expect(evento.reload.ultimo_disparo_em).to be_present + end + + describe 'janela do resumo (recortes FECHADOS, para o número não mudar depois de enviado)' do + it 'diária cobre o dia anterior' do + described_class.perform_now(agora) + expect(Analytics::ResumoOperacao).to have_received(:new) + .with(inicio: Date.new(2026, 8, 23), fim: Date.new(2026, 8, 23)) + end + + it 'semanal cobre os 7 dias anteriores' do + evento.update!(frequencia: 'semanal', dia_semana: agora.to_date.wday) + described_class.perform_now(agora) + expect(Analytics::ResumoOperacao).to have_received(:new) + .with(inicio: Date.new(2026, 8, 17), fim: Date.new(2026, 8, 23)) + end + end +end diff --git a/spec/models/contato_spec.rb b/spec/models/contato_spec.rb new file mode 100644 index 0000000..7139c18 --- /dev/null +++ b/spec/models/contato_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe Contato do + it 'normaliza o telefone digitado para E.164 na gravação' do + # O ADM digita com máscara; mandar esse texto cru para o provedor devolve + # erro de destino inválido que o rescue do envio esconderia. + %w[(11)92005-1157 11920051157 011\ 92005\ 1157 +5511920051157].each do |digitado| + contato = build(:contato, telefone: digitado) + expect(contato).to be_valid + expect(contato.telefone).to eq('+5511920051157') + end + end + + it 'exige ao menos um canal' do + contato = build(:contato, telefone: nil, email: nil) + expect(contato).not_to be_valid + expect(contato.errors.full_messages.join).to include('WhatsApp ou um e-mail') + end + + it 'aceita contato só de e-mail' do + expect(build(:contato, telefone: nil, email: 'diretoria@reem.com')).to be_valid + end + + it 'rejeita telefone que não dá para normalizar, devolvendo o valor digitado' do + contato = build(:contato, telefone: '123') + expect(contato).not_to be_valid + expect(contato.errors[:telefone].join).to include('número brasileiro') + # O ADM precisa ver o que digitou para corrigir. + expect(contato.telefone).to eq('123') + end + + describe '.com_canal' do + let!(:so_zap) { create(:contato, telefone: '11 92005-1157', email: nil) } + let!(:so_email) { create(:contato, telefone: nil, email: 'a@b.com') } + let!(:ambos) { create(:contato, telefone: '11 93005-1157', email: 'c@d.com') } + + it 'filtra por quem tem o canal' do + expect(Contato.com_canal('whatsapp')).to contain_exactly(so_zap, ambos) + expect(Contato.com_canal('email')).to contain_exactly(so_email, ambos) + expect(Contato.com_canal('ambos')).to contain_exactly(so_zap, so_email, ambos) + end + end +end diff --git a/spec/models/evento_notificacao_spec.rb b/spec/models/evento_notificacao_spec.rb new file mode 100644 index 0000000..6694719 --- /dev/null +++ b/spec/models/evento_notificacao_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe EventoNotificacao do + it 'gera a chave a partir do nome quando não informada' do + evento = described_class.create!(nome: 'Aviso da Diretoria', gatilho: 'manual') + expect(evento.chave).to eq('aviso_da_diretoria') + end + + it 'exige dia da semana na frequência semanal' do + evento = build(:evento_notificacao, gatilho: 'agendado', frequencia: 'semanal', dia_semana: nil) + expect(evento).not_to be_valid + expect(evento.errors[:dia_semana]).to be_present + end + + it 'evento de sistema não é apagável' do + expect(build(:evento_notificacao, sistema: true).apagavel?).to be(false) + expect(build(:evento_notificacao, sistema: false).apagavel?).to be(true) + end + + describe '#destinatarios' do + let(:evento) { create(:evento_notificacao) } + let(:diretoria) { create(:grupo_contato) } + let(:operacao) { create(:grupo_contato) } + + it 'junta os contatos de todos os grupos assinantes, sem repetir' do + a = create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157') + b = create(:contato, grupo_contato: operacao, telefone: '11 93005-1157') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + create(:grupo_evento_assinatura, grupo_contato: operacao, evento_notificacao: evento) + + expect(evento.reload.destinatarios('whatsapp')).to contain_exactly(a, b) + end + + it 'respeita o canal da assinatura' do + so_email = create(:contato, grupo_contato: diretoria, telefone: nil, email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'email') + + expect(evento.reload.destinatarios('email')).to contain_exactly(so_email) + expect(evento.reload.destinatarios('whatsapp')).to be_empty + end + + it 'ignora assinatura desativada e contato desativado' do + create(:contato, grupo_contato: diretoria, ativo: false) + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, ativo: false) + + expect(evento.reload.destinatarios('whatsapp')).to be_empty + end + end + + describe '#vencido?' do + let(:evento) do + create(:evento_notificacao, gatilho: 'agendado', frequencia: 'diaria', hora: 8) + end + + it 'vence depois da hora e só uma vez por dia' do + manha = Time.zone.local(2026, 8, 24, 9, 0) + expect(evento.vencido?(manha)).to be(true) + + evento.update!(ultimo_disparo_em: Time.zone.local(2026, 8, 24, 8, 5)) + expect(evento.vencido?(manha)).to be(false) + + expect(evento.vencido?(Time.zone.local(2026, 8, 25, 9, 0))).to be(true) + end + + it 'não vence antes da hora' do + expect(evento.vencido?(Time.zone.local(2026, 8, 24, 7, 0))).to be(false) + end + end +end diff --git a/spec/models/mensagem_template_spec.rb b/spec/models/mensagem_template_spec.rb new file mode 100644 index 0000000..f881d05 --- /dev/null +++ b/spec/models/mensagem_template_spec.rb @@ -0,0 +1,67 @@ +require 'rails_helper' + +RSpec.describe MensagemTemplate do + let(:evento) { create(:evento_notificacao, gatilho: 'pagamento_efetuado') } + + def montar(blocos, **extra) + described_class.new({ evento_notificacao: evento, canal: 'whatsapp', blocos: blocos }.merge(extra)) + end + + # `blocos` vem de um hidden preenchido por JavaScript — ou seja, de fora. + describe 'limpeza dos blocos na gravação' do + it 'descarta tipo fora do catálogo' do + t = montar([{ 'tipo' => 'iframe', 'texto' => 'x' }, { 'tipo' => 'texto', 'texto' => 'ok' }]) + t.validate + expect(t.blocos.map { |b| b['tipo'] }).to eq(['texto']) + end + + it 'descarta campo que o tipo não tem' do + t = montar([{ 'tipo' => 'divisor', 'onload' => 'alert(1)' }]) + t.validate + expect(t.blocos.first.keys).to eq(['tipo']) + end + + it 'descarta linha de tabela sem rótulo nem valor' do + t = montar([{ 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => '', 'valor' => '' }, + { 'rotulo' => 'Valor', 'valor' => '10' }] }]) + t.validate + expect(t.blocos.first['linhas'].size).to eq(1) + end + + it 'limita a quantidade de blocos' do + t = montar(Array.new(60) { { 'tipo' => 'divisor' } }) + t.validate + expect(t.blocos.size).to eq(described_class::MAX_BLOCOS) + end + + it 'aceita blocos com chave símbolo (vindos de código, não do form)' do + t = montar([{ tipo: 'texto', texto: 'oi' }]) + t.validate + expect(t.blocos).to eq([{ 'tipo' => 'texto', 'texto' => 'oi' }]) + end + end + + it 'exige assunto no e-mail com conteúdo' do + t = montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], canal: 'email', assunto: '') + expect(t).not_to be_valid + expect(t.errors[:assunto]).to be_present + end + + # Template ativo e vazio mandaria mensagem em branco — pior que o texto padrão. + it 'template vazio não é utilizável mesmo estando ativo' do + expect(montar([], ativo: true).utilizavel?).to be(false) + expect(montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], ativo: true).utilizavel?).to be(true) + expect(montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], ativo: false).utilizavel?).to be(false) + end + + it 'não deixa dois templates do mesmo canal no mesmo evento' do + described_class.create!(evento_notificacao: evento, canal: 'whatsapp', blocos: []) + duplicado = described_class.new(evento_notificacao: evento, canal: 'whatsapp', blocos: []) + expect(duplicado).not_to be_valid + end + + it 'preview usa os valores de amostra do gatilho' do + t = montar([{ 'tipo' => 'texto', 'texto' => 'Valor: {{valor}}' }]) + expect(t.preview).to include(Notificacao::Variaveis.amostra('pagamento_efetuado')['valor']) + end +end diff --git a/spec/services/notificacao/despachante_spec.rb b/spec/services/notificacao/despachante_spec.rb new file mode 100644 index 0000000..5bdf862 --- /dev/null +++ b/spec/services/notificacao/despachante_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe Notificacao::Despachante do + let(:evento) { create(:evento_notificacao) } + let(:diretoria) { create(:grupo_contato) } + let(:config) { ConfiguracaoNotificacao.instancia } + + before do + config.update_columns(whatsapp_ativo: true, whatsapp_provedor: 'baileys', + email_notificacoes_ativo: true, whatsapp_intervalo_segundos: 0) + # Nunca tocar a ponte de verdade no teste. + allow(Notificacao::Whatsapp).to receive(:enviar) + .and_return(Notificacao::ClienteWhatsapp::Resposta.new(ok: true)) + allow(ConfiguracaoNotificacao).to receive(:instancia).and_return(config) + end + + def despachar(envolvido: nil, dados: {}) + described_class.new(evento.reload, dados: dados, assunto: 'Assunto', + corpo: 'Corpo', envolvido: envolvido).executar + end + + it 'envia para os contatos dos grupos assinantes e registra cada envio' do + contato = create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + + expect { despachar }.to change(NotificacaoEnvio, :count).by(2) # whatsapp + email + + expect(Notificacao::Whatsapp).to have_received(:enviar) + .with(hash_including(para: contato.telefone)) + expect(NotificacaoEnvio.where(canal: 'whatsapp').first).to be_enviado + end + + # A mesma pessoa pode ser contato de um grupo E a envolvida no fato — sem + # dedup ela receberia a mensagem duas vezes. + it 'não manda duas vezes para o mesmo número' do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: nil) + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'whatsapp') + motorista = create(:motorista, telefone: '(11) 92005-1157') + + despachar(envolvido: motorista) + + expect(NotificacaoEnvio.where(canal: 'whatsapp').count).to eq(1) + end + + it 'inclui o envolvido que não é contato de nenhum grupo' do + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + motorista = create(:motorista, telefone: '(11) 98888-7777') + + despachar(envolvido: motorista) + + expect(NotificacaoEnvio.where(canal: 'whatsapp').pluck(:destino)).to eq(['+5511988887777']) + end + + it 'não avisa o envolvido quando o evento diz para não avisar' do + evento.update!(notificar_envolvido: false) + motorista = create(:motorista, telefone: '(11) 98888-7777') + + despachar(envolvido: motorista) + + expect(NotificacaoEnvio.where(canal: 'whatsapp')).to be_empty + end + + it 'registra a falha em vez de levantar — envio não pode travar um fechamento' do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: nil) + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'whatsapp') + allow(Notificacao::Whatsapp).to receive(:enviar) + .and_return(Notificacao::ClienteWhatsapp::Resposta.new(ok: false, erro: 'whatsapp desconectado')) + + expect { despachar }.not_to raise_error + + envio = NotificacaoEnvio.last + expect(envio).to be_falhou + expect(envio.erro).to include('desconectado') + end + + # O editor de blocos vence o texto padrão — e o fallback existe para que ligar + # o editor não apague as notificações dos eventos que já funcionavam. + describe 'template de blocos' do + before do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + end + + it 'sem template, usa o corpo passado pelo chamador' do + despachar + expect(NotificacaoEnvio.find_by(canal: 'whatsapp').corpo).to eq('Corpo') + end + + it 'com template, o corpo sai renderizado por canal' do + MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true, + blocos: [{ 'tipo' => 'texto', 'texto' => 'Oi {{contato}}, {{valor}}' }]) + MensagemTemplate.create!(evento_notificacao: evento, canal: 'email', ativo: true, + assunto: 'Fechamento de {{contato}}', + blocos: [{ 'tipo' => 'texto', 'texto' => 'Corpo do e-mail' }]) + + despachar(dados: { 'valor' => 'R$ 10,00' }) + + zap = NotificacaoEnvio.find_by(canal: 'whatsapp') + expect(zap.corpo).to include('R$ 10,00') + + email = NotificacaoEnvio.find_by(canal: 'email') + expect(email.corpo).to include(' 'texto', 'texto' => 'Olá {{contato}}' }]) + + despachar + + corpos = NotificacaoEnvio.where(canal: 'whatsapp').pluck(:corpo) + expect(corpos).to include(a_string_including('Ana')) + expect(corpos.uniq.size).to eq(corpos.size) + end + + it 'template ativo mas vazio não substitui o texto padrão' do + MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true, blocos: []) + despachar + expect(NotificacaoEnvio.find_by(canal: 'whatsapp').corpo).to eq('Corpo') + end + end + + it 'não envia nada quando o canal está desligado na configuração' do + create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com') + create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento) + config.update_columns(whatsapp_ativo: false, email_notificacoes_ativo: false) + + expect { despachar }.not_to change(NotificacaoEnvio, :count) + end +end diff --git a/spec/services/notificacao/gatilhos_spec.rb b/spec/services/notificacao/gatilhos_spec.rb new file mode 100644 index 0000000..1580548 --- /dev/null +++ b/spec/services/notificacao/gatilhos_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe Notificacao::Gatilhos do + before { allow(Notificacao::Despachante).to receive(:disparar) } + + describe '.valor_alterado' do + let(:consolidacao) { create(:consolidacao, status: :finalizada) } + let(:cm) { create(:consolidacao_motorista, consolidacao: consolidacao, valor_total: 5060.0) } + + # Em rascunho o valor muda a cada clique do wizard — avisar ali seria spam. + it 'não dispara enquanto a consolidação é rascunho' do + rascunho = create(:consolidacao, status: :rascunho) + cm_rasc = create(:consolidacao_motorista, consolidacao: rascunho, valor_total: 100.0) + + described_class.valor_alterado(rascunho, cm_rasc, anterior: 50.0) + + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + it 'não dispara quando o valor não mudou' do + described_class.valor_alterado(consolidacao, cm, anterior: 5060.0) + expect(Notificacao::Despachante).not_to have_received(:disparar) + end + + it 'descreve a mudança em {{o_que_mudou}}' do + described_class.valor_alterado(consolidacao, cm, anterior: 4900.0) + + expect(Notificacao::Despachante).to have_received(:disparar) do |args| + expect(args[:chave]).to eq('valor_alterado') + expect(args[:dados]['o_que_mudou']).to eq('Valor passou de R$ 4.900,00 para R$ 5.060,00') + expect(args[:dados]['valor']).to eq('R$ 5.060,00') + end + end + + it 'não levanta quando algo dá errado — não pode travar um fechamento' do + allow(Notificacao::Despachante).to receive(:disparar).and_raise(StandardError, 'boom') + expect { described_class.valor_alterado(consolidacao, cm, anterior: 1.0) }.not_to raise_error + end + end + + describe '.operacao_alterada' do + it 'monta as variáveis do gatilho' do + described_class.operacao_alterada(operacao: 'UBS NORTE AGO 2026', + o_que_mudou: '3 notas na operação a mais', nf: '85382') + + expect(Notificacao::Despachante).to have_received(:disparar) do |args| + expect(args[:chave]).to eq('operacao_alterada') + expect(args[:dados]['operacao']).to eq('UBS NORTE AGO 2026') + expect(args[:dados]['nf']).to eq('85382') + end + end + end + + # Toda variável que o editor anuncia precisa de par aqui: anunciada e não + # preenchida sai vazia na mensagem, o que é pior do que não existir. + describe 'cobertura das variáveis anunciadas no editor' do + it 'valor_alterado preenche tudo que Variaveis oferece' do + consolidacao = create(:consolidacao, status: :finalizada) + cm = create(:consolidacao_motorista, consolidacao: consolidacao, valor_total: 10.0) + + described_class.valor_alterado(consolidacao, cm, anterior: 1.0) + + expect(Notificacao::Despachante).to have_received(:disparar) do |args| + esperadas = Notificacao::Variaveis.nomes('valor_alterado') - ['contato'] # vem por destinatário + expect(args[:dados].keys).to include(*esperadas) + end + end + end +end diff --git a/spec/services/notificacao/renderizador_spec.rb b/spec/services/notificacao/renderizador_spec.rb new file mode 100644 index 0000000..062854f --- /dev/null +++ b/spec/services/notificacao/renderizador_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe Notificacao::Renderizador do + let(:dados) { { 'contato' => 'Carlos', 'valor' => 'R$ 5.060,00', 'entregas' => '280' } } + + def render(blocos) = described_class.new(blocos, dados) + + describe 'as duas saídas do mesmo template' do + let(:blocos) do + [ + { 'tipo' => 'cabecalho', 'titulo' => 'Pagamento fechado', 'subtitulo' => 'UBS Norte' }, + { 'tipo' => 'texto', 'texto' => 'Olá {{contato}}!' }, + { 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => 'Entregas', 'valor' => '{{entregas}}' }, + { 'rotulo' => 'Valor', 'valor' => '{{valor}}' }] }, + { 'tipo' => 'botao', 'rotulo' => 'Ver extrato', 'url' => 'https://app.reem.com.br/motorista' } + ] + end + + it 'monta o texto do WhatsApp com negrito e bullets' do + texto = render(blocos).texto + expect(texto).to include('*Pagamento fechado*') + expect(texto).to include('Olá Carlos!') + expect(texto).to include('• Entregas: 280') + expect(texto).to include('Ver extrato: https://app.reem.com.br/motorista') + end + + it 'monta o HTML do e-mail com estilo inline' do + html = render(blocos).html + expect(html).to include(' 'texto', 'texto' => 'Oi {{inexistente}}!' }]).texto + expect(texto).to eq('Oi !') + end + + # O corpo é digitado numa tela e o preview usa este mesmo renderizador — um + # escape faltando atingiria primeiro o próprio admin. + it 'escapa HTML do texto digitado e do valor da variável' do + r = described_class.new( + [{ 'tipo' => 'texto', 'texto' => ' {{contato}}' }], + { 'contato' => '' } + ) + html = r.html + expect(html).not_to include('