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.
+
+
+ <% 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 %>
+
+ <%= 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. %>
+
+
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. %>
+
<% 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('