Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
330
README.md
330
README.md
@@ -2258,3 +2258,333 @@ docker compose exec app bin/rails runner '
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>📣 Notificações: WhatsApp por QR (Baileys) no lugar do Twilio + contatos, grupos e eventos — ETAPA 1 (24/08/2026)</strong></summary>
|
||||
|
||||
> ⚠️ **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.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>🧱 Editor de blocos das mensagens — ETAPA 2 (24/08/2026)</strong></summary>
|
||||
|
||||
> ⚠️ **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 `<script>` extraído).
|
||||
> **Migration, `npm install` e a suíte continuam pendentes.**
|
||||
|
||||
### 🎯 O que mudou
|
||||
Na etapa 1, o corpo das mensagens ainda era string interpolada em Ruby — mudar uma palavra exigia
|
||||
deploy. Agora o ADM monta a mensagem **arrastando blocos**, em
|
||||
`Notificações → Eventos → WhatsApp / E-mail`.
|
||||
|
||||
**Blocos:** cabeçalho, texto, tabela de valores, aviso de mudança, botão/link, divisor, rodapé.
|
||||
Arraste da paleta (ou clique), reordene pela alça, remova no ✕.
|
||||
|
||||
**Variáveis:** clique num campo e depois num chip — `{{contato}}`, `{{valor}}`, `{{entregas}}`,
|
||||
`{{o_que_mudou}}`… A lista muda conforme o **gatilho** do evento (`Notificacao::Variaveis`).
|
||||
|
||||
**Um template por evento E por canal:** o mesmo evento tem uma mensagem de WhatsApp e outra de
|
||||
e-mail, montadas separadamente. O ✓ na lista de eventos diz quais já estão montadas.
|
||||
|
||||
### ⚙️ Pontos não-óbvios
|
||||
|
||||
**O preview roda no servidor.** O botão chama `POST …/template/:canal/preview`, que instancia o
|
||||
mesmo `Notificacao::Renderizador` do envio. Uma segunda implementação em JavaScript ficaria mais
|
||||
rápida e **inevitavelmente divergiria do que é enviado** — e o preview existe justamente para
|
||||
prometer o contrário. O preview do e-mail é exibido num `<iframe sandbox>` para os estilos do
|
||||
e-mail não vazarem para o admin.
|
||||
|
||||
**Duas saídas do MESMO template.** `#texto` produz o WhatsApp (com `*negrito*` e `•` nas tabelas);
|
||||
`#html` produz o e-mail com **estilo inline**, porque cliente de e-mail não lê CSS externo. Não usei
|
||||
`simple_format` no e-mail: ele gera HTML sem os estilos que o Outlook/Gmail precisam.
|
||||
|
||||
**Escape em tudo, sempre.** Todo texto do editor e todo valor de variável passa por
|
||||
`ERB::Util.html_escape` no caminho HTML. O corpo é digitado numa tela e o preview usa o mesmo
|
||||
renderizador — um escape faltando atingiria **primeiro o próprio admin**. Botão só aceita `http(s)`:
|
||||
um `javascript:` no href seria clique armado dentro do e-mail. Tem spec para os dois.
|
||||
|
||||
**Blocos são sanitizados na gravação.** O estado do editor viaja num campo hidden preenchido por
|
||||
JavaScript — ou seja, vem de fora. `MensagemTemplate#normalizar_blocos` descarta tipo fora do
|
||||
catálogo, campo que aquele tipo não tem e o que não for Hash, e limita a 40 blocos / 20 linhas por
|
||||
tabela.
|
||||
|
||||
**A renderização acontece por destinatário.** `{{contato}}` é o primeiro nome de quem recebe — um
|
||||
render compartilhado mandaria o nome da primeira pessoa para todo mundo. Renderizar é manipulação
|
||||
de string; o custo por destinatário é irrelevante perto do envio.
|
||||
|
||||
**Fallback preservado.** Sem template montado (ou com template ativo porém **vazio**), o evento
|
||||
continua usando o texto padrão do código. Sem isso, ligar o editor apagaria as notificações que já
|
||||
funcionavam.
|
||||
|
||||
**Amostra ≠ real.** `Variaveis.amostra` só alimenta o preview; `Variaveis.comuns_reais` é o que
|
||||
entra num envio de verdade. Trocar os dois colocaria uma data fixa dentro da mensagem que o contato
|
||||
recebe — foi um bug que existiu por alguns minutos no disparo manual e está fixado aqui.
|
||||
|
||||
**`jsonb`, não tabela filha.** A ordem faz parte do dado (é lista, não conjunto), cada tipo de bloco
|
||||
tem campos diferentes, e salvar o template inteiro numa transação evita estado meio-salvo.
|
||||
|
||||
### 📂 Arquivos
|
||||
```
|
||||
db/migrate/20260824000005_create_mensagem_templates.rb (NOVO)
|
||||
app/models/mensagem_template.rb (NOVO — sanitização dos blocos)
|
||||
app/models/evento_notificacao.rb (#template, #template_utilizavel)
|
||||
app/services/notificacao/blocos.rb (NOVO — catálogo, fonte única)
|
||||
app/services/notificacao/variaveis.rb (NOVO — por gatilho + amostra)
|
||||
app/services/notificacao/renderizador.rb (NOVO — texto + html)
|
||||
app/services/notificacao/despachante.rb (render por canal e por destinatário)
|
||||
app/services/notificacao_service.rb (passa as variáveis do fechamento)
|
||||
app/jobs/notificacao_job.rb (carrega os dados)
|
||||
app/mailers/notificacao_mailer.rb + view (corpo em HTML x texto)
|
||||
app/controllers/admin/mensagem_templates_controller.rb (NOVO — edit/update/preview)
|
||||
app/views/admin/mensagem_templates/edit.html.erb (NOVO — editor + SortableJS)
|
||||
app/views/admin/eventos_notificacao/index.html.erb (links por canal + ✓)
|
||||
config/routes.rb
|
||||
spec/models/mensagem_template_spec.rb (NOVO)
|
||||
spec/services/notificacao/renderizador_spec.rb (NOVO)
|
||||
spec/services/notificacao/despachante_spec.rb (+ template)
|
||||
```
|
||||
|
||||
> **1 migration.** Nenhuma gem nova; o SortableJS vem de CDN, como flatpickr/Chart.js/Leaflet.
|
||||
|
||||
### ⏳ Pendente
|
||||
```bash
|
||||
docker compose exec app bin/rails db:migrate
|
||||
docker compose exec app bundle exec rspec spec/services/notificacao/ spec/models/mensagem_template_spec.rb
|
||||
|
||||
# Depois: Notificações → Eventos → "WhatsApp" num evento → arrastar blocos →
|
||||
# "Atualizar preview" → Salvar. Conferir o corpo real em Notificações → Envios.
|
||||
```
|
||||
|
||||
### 🚧 O que falta (etapa 3)
|
||||
`valor_alterado`, `operacao_alterada` e `agendado` existem como opção de gatilho, o editor já
|
||||
oferece as variáveis certas para cada um e o `Despachante` os atende — mas **nada no código chama
|
||||
esses gatilhos ainda**, nem existe o job que varre os agendados vencidos. Um evento com esses
|
||||
gatilhos hoje só dispara pelo botão manual.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>🔔 Gatilhos: valor alterado, operação mudou e resumo agendado — ETAPA 3 (24/08/2026)</strong></summary>
|
||||
|
||||
> ⚠️ **STATUS: implementado, NADA executado.** Sem Ruby/Bundler/Postgres/Docker na máquina de
|
||||
> desenvolvimento. Sintaxe conferida em todos os `.rb`, `.rake` e `.erb`. **2 migrations, o
|
||||
> `whenever --update-crontab` e a suíte continuam pendentes.**
|
||||
|
||||
### 🎯 O que faltava
|
||||
Nas etapas 1 e 2, `valor_alterado`, `operacao_alterada` e `agendado` existiam como opção de gatilho
|
||||
e o editor já oferecia as variáveis certas de cada um — mas **nada no código os chamava**. Um evento
|
||||
com esses gatilhos só disparava pelo botão manual.
|
||||
|
||||
### 🆕 Os três gatilhos, ligados
|
||||
|
||||
**`valor_alterado`** → `Consolidacao#recalcular_motorista!`. É o **funil único**: todo caminho que
|
||||
mexe em pilar, desconto ou lançamento termina ali. Só dispara com a consolidação **finalizada** — em
|
||||
rascunho o valor muda a cada clique do wizard, e avisar ali seria spam, não informação. Também só
|
||||
dispara se o valor **realmente mudou**. `{{o_que_mudou}}` sai como
|
||||
"Valor passou de R$ 4.900,00 para R$ 5.060,00". O próprio motorista entra como *envolvido*, sob o
|
||||
`notificar_envolvido` do evento — dá para desligar sem perder o aviso à diretoria.
|
||||
|
||||
**`operacao_alterada`** → dois caminhos, de propósito:
|
||||
- **Imediato**: `Admin::EdicaoLancamentosController#atualizar`. Sabe exatamente qual NF e o que
|
||||
mudou (`status: pending → completed`).
|
||||
- **Varredura**: `DetectarMudancasOperacaoJob` compara os números de cada operação com o retrato
|
||||
anterior (`operacao_snapshots`) e avisa "3 notas na operação a mais, 1 em aberto a menos".
|
||||
|
||||
Por que os dois: a **maioria** das mudanças da operação não passa pelo nosso código — acontece no
|
||||
SimpliRoute e chega pelo sync do espelho, que é read-only aqui. Só o hook interno cobriria a
|
||||
minoria dos casos.
|
||||
|
||||
**`agendado`** → `DispararEventosAgendadosJob`, de hora em hora pelo cron. Os números vêm de
|
||||
`Analytics::ResumoOperacao`, que **reusa** `OperacaoMetricas` e `NotasForaOperacao` — o resumo que
|
||||
chega no WhatsApp não pode dizer coisa diferente do dashboard.
|
||||
|
||||
### ⚙️ Pontos não-óbvios
|
||||
|
||||
**Varredura horária, não uma linha de cron 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?` + `ultimo_disparo_em` garantem **um disparo por dia**.
|
||||
|
||||
**Janelas FECHADAS.** Diária = o dia anterior; semanal = os 7 dias anteriores. Um resumo sobre "hoje"
|
||||
mudaria de número depois de enviado.
|
||||
|
||||
**Período sem movimento não vira mensagem** — mas marca `ultimo_disparo_em` assim mesmo, senão a
|
||||
varredura tentaria de hora em hora até o dia virar. Resumo zerado num feriado só treina o leitor a
|
||||
ignorar a mensagem.
|
||||
|
||||
**A primeira varredura de cada operação só grava o retrato.** Sem retrato anterior, toda operação
|
||||
existente pareceria "nova" e o primeiro deploy dispararia uma mensagem por operação cadastrada.
|
||||
|
||||
**Os eventos nascem ativos e isso é seguro.** A migration cria "Valor alterado no fechamento" e
|
||||
"Dados da operação mudaram" como eventos de sistema — sem grupo assinante não há destinatário, então
|
||||
nada sai até o ADM marcar um grupo. Sem esse seed, `disparar(chave: 'valor_alterado')` não acharia
|
||||
evento nenhum e o gatilho ficaria mudo.
|
||||
|
||||
**Nada nos gatilhos levanta.** Uma notificação não pode derrubar um fechamento nem uma edição de
|
||||
lançamento — `Notificacao::Gatilhos` engole e loga, como o resto do módulo.
|
||||
|
||||
**Custo conhecido da varredura:** `OperacaoMetricas` carrega as visitas em memória para deduplicar
|
||||
por NF, e o job varre todas as tabelas 8×/dia. É o preço de reusar a contagem do dashboard em vez de
|
||||
escrever um SQL paralelo que divergiria dele. Se incomodar, o caminho é restringir às operações do
|
||||
mês corrente e do anterior — as antigas não mudam mais. Está anotado no código.
|
||||
|
||||
### 📂 Arquivos
|
||||
```
|
||||
db/migrate/20260824000006_create_operacao_snapshots.rb (NOVO)
|
||||
db/migrate/20260824000007_semear_eventos_de_gatilho.rb (NOVO — idempotente)
|
||||
app/models/operacao_snapshot.rb (NOVO — retrato + diferença legível)
|
||||
app/models/consolidacao.rb (hook em recalcular_motorista!)
|
||||
app/services/notificacao/gatilhos.rb (NOVO — os 3 gatilhos)
|
||||
app/services/analytics/resumo_operacao.rb (NOVO — reusa as métricas do dashboard)
|
||||
app/jobs/disparar_eventos_agendados_job.rb (NOVO)
|
||||
app/jobs/detectar_mudancas_operacao_job.rb (NOVO)
|
||||
app/controllers/admin/edicao_lancamentos_controller.rb (dispara operacao_alterada)
|
||||
app/services/notificacao/despachante.rb (dono do ultimo_disparo_em saiu daqui)
|
||||
lib/tasks/notificacao.rake + config/schedule.rb (cron)
|
||||
spec/services/notificacao/gatilhos_spec.rb (NOVO)
|
||||
spec/jobs/*_spec.rb (NOVO)
|
||||
```
|
||||
|
||||
### ⏳ Pendente
|
||||
```bash
|
||||
docker compose exec app bin/rails db:migrate
|
||||
# O cron do container já roda `whenever --update-crontab` no boot; num container
|
||||
# em pé, forçar:
|
||||
docker compose exec app bundle exec whenever --update-crontab
|
||||
docker compose restart app
|
||||
|
||||
docker compose exec app bundle exec rspec spec/jobs spec/services/notificacao spec/models
|
||||
|
||||
# Teste manual: criar um evento "Resumo diário" (gatilho Agendado, hora = agora),
|
||||
# marcar um grupo, e rodar à mão:
|
||||
docker compose exec app bundle exec rake notificacao:agendados
|
||||
docker compose exec app bundle exec rake notificacao:mudancas_operacao
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user