Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
@@ -78,3 +78,11 @@ NOTIFICACAO_SECRET=
|
|||||||
# ── App ───────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────
|
||||||
APP_HOST=localhost:3000
|
APP_HOST=localhost:3000
|
||||||
APP_NAME=Reem Logística
|
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
|
||||||
|
|||||||
330
README.md
330
README.md
@@ -2258,3 +2258,333 @@ docker compose exec app bin/rails runner '
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|||||||
77
app/controllers/admin/contatos_controller.rb
Normal file
77
app/controllers/admin/contatos_controller.rb
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# app/controllers/admin/contatos_controller.rb
|
||||||
|
#
|
||||||
|
# Cadastro manual de quem recebe as mensagens: nome, WhatsApp e/ou e-mail, e o
|
||||||
|
# grupo. O telefone é normalizado para E.164 no model — o ADM digita como quiser.
|
||||||
|
class Admin::ContatosController < ApplicationController
|
||||||
|
before_action :set_contato, only: %i[edit update destroy]
|
||||||
|
before_action :carregar_grupos, only: %i[new create edit update]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize Contato
|
||||||
|
@grupos = GrupoContato.ordenados
|
||||||
|
escopo = policy_scope(Contato).includes(:grupo_contato)
|
||||||
|
escopo = escopo.where(grupo_contato_id: params[:grupo_id]) if params[:grupo_id].present?
|
||||||
|
escopo = escopo.where('LOWER(nome) LIKE :q OR telefone LIKE :q OR LOWER(email) LIKE :q',
|
||||||
|
q: "%#{params[:q].to_s.strip.downcase}%") if params[:q].present?
|
||||||
|
@contatos = escopo.ordenados
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@contato = Contato.new(grupo_contato_id: params[:grupo_id])
|
||||||
|
authorize @contato
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@contato = Contato.new(contato_params)
|
||||||
|
authorize @contato
|
||||||
|
|
||||||
|
if @contato.save
|
||||||
|
registrar("Cadastrou contato #{@contato.nome}")
|
||||||
|
redirect_to admin_contatos_path, notice: 'Contato cadastrado.'
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @contato
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @contato
|
||||||
|
|
||||||
|
if @contato.update(contato_params)
|
||||||
|
registrar("Editou contato #{@contato.nome}")
|
||||||
|
redirect_to admin_contatos_path, notice: 'Contato atualizado.'
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @contato
|
||||||
|
nome = @contato.nome
|
||||||
|
@contato.destroy
|
||||||
|
registrar("Excluiu contato #{nome}")
|
||||||
|
redirect_to admin_contatos_path, notice: 'Contato removido.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_contato
|
||||||
|
@contato = Contato.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def carregar_grupos
|
||||||
|
@grupos = GrupoContato.ativos.ordenados
|
||||||
|
end
|
||||||
|
|
||||||
|
def contato_params
|
||||||
|
params.require(:contato).permit(:nome, :telefone, :email, :grupo_contato_id, :ativo, :observacao)
|
||||||
|
end
|
||||||
|
|
||||||
|
def registrar(detalhes)
|
||||||
|
AuditoriaLog.registrar(user: current_user, acao: 'contato', entidade: 'Contato',
|
||||||
|
dados_novos: { detalhes: detalhes }, request: request)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -118,6 +118,15 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
|||||||
request: request
|
request: request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Mudança na operação feita POR DENTRO do sistema. A maioria das mudanças
|
||||||
|
# vem de fora e é pega pelo DetectarMudancasOperacaoJob; esta chega na hora
|
||||||
|
# e sabe exatamente qual NF e o que mudou.
|
||||||
|
Notificacao::Gatilhos.operacao_alterada(
|
||||||
|
operacao: anterior['title'].presence || 'SimpliRoute',
|
||||||
|
o_que_mudou: descrever_mudanca(anterior, atualizada, attrs.keys),
|
||||||
|
nf: anterior['reference']
|
||||||
|
)
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.json do
|
format.json do
|
||||||
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
|
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
|
||||||
@@ -157,6 +166,18 @@ class Admin::EdicaoLancamentosController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# "status: pending → completed; observação: … → ÓBITO" — o texto que vai para
|
||||||
|
# a variável {{o_que_mudou}} do editor de blocos.
|
||||||
|
def descrever_mudanca(anterior, atualizada, campos)
|
||||||
|
Array(campos).filter_map do |campo|
|
||||||
|
de = anterior[campo].to_s.strip
|
||||||
|
para = atualizada[campo].to_s.strip
|
||||||
|
next if de == para
|
||||||
|
|
||||||
|
"#{campo}: #{de.presence || '(vazio)'} → #{para.presence || '(vazio)'}"
|
||||||
|
end.join('; ').presence || 'lançamento atualizado'
|
||||||
|
end
|
||||||
|
|
||||||
def client
|
def client
|
||||||
@client ||= SimpliRoute::Client.new
|
@client ||= SimpliRoute::Client.new
|
||||||
end
|
end
|
||||||
|
|||||||
140
app/controllers/admin/eventos_notificacao_controller.rb
Normal file
140
app/controllers/admin/eventos_notificacao_controller.rb
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# app/controllers/admin/eventos_notificacao_controller.rb
|
||||||
|
#
|
||||||
|
# Cadastro dos eventos e de quais grupos assinam cada um.
|
||||||
|
#
|
||||||
|
# O ADM cria quantos eventos quiser, mas o GATILHO sai de EventoNotificacao::
|
||||||
|
# GATILHOS — gatilho é código. `manual` cobre o aviso pontual (o texto é
|
||||||
|
# digitado na hora do disparo) e `agendado` cobre o resumo periódico.
|
||||||
|
class Admin::EventosNotificacaoController < ApplicationController
|
||||||
|
before_action :set_evento, only: %i[edit update destroy disparar]
|
||||||
|
before_action :carregar_grupos, only: %i[new create edit update]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize EventoNotificacao
|
||||||
|
@eventos = policy_scope(EventoNotificacao)
|
||||||
|
.ordenados.includes(assinaturas: :grupo_contato)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@evento = EventoNotificacao.new(gatilho: 'manual', ativo: true, hora: 8)
|
||||||
|
authorize @evento
|
||||||
|
montar_assinaturas
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@evento = EventoNotificacao.new(evento_params)
|
||||||
|
authorize @evento
|
||||||
|
|
||||||
|
if @evento.save
|
||||||
|
salvar_assinaturas
|
||||||
|
registrar("Criou evento de notificação #{@evento.nome}")
|
||||||
|
redirect_to admin_eventos_path, notice: 'Evento criado.'
|
||||||
|
else
|
||||||
|
montar_assinaturas
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @evento
|
||||||
|
montar_assinaturas
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @evento
|
||||||
|
|
||||||
|
# `chave` é o que o código usa para achar o evento no disparo. Deixar o ADM
|
||||||
|
# renomear a chave de um evento de sistema quebraria o gatilho em silêncio.
|
||||||
|
permitidos = @evento.sistema? ? evento_params.except(:chave, :gatilho) : evento_params
|
||||||
|
|
||||||
|
if @evento.update(permitidos)
|
||||||
|
salvar_assinaturas
|
||||||
|
registrar("Editou evento de notificação #{@evento.nome}")
|
||||||
|
redirect_to admin_eventos_path, notice: 'Evento atualizado.'
|
||||||
|
else
|
||||||
|
montar_assinaturas
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @evento
|
||||||
|
nome = @evento.nome
|
||||||
|
@evento.destroy
|
||||||
|
registrar("Excluiu evento de notificação #{nome}")
|
||||||
|
redirect_to admin_eventos_path, notice: 'Evento removido.'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Disparo manual: manda mensagem DE VERDADE para todos os grupos assinantes.
|
||||||
|
# O texto é digitado na hora — o editor de blocos ainda não existe.
|
||||||
|
def disparar
|
||||||
|
authorize @evento, :disparar?
|
||||||
|
|
||||||
|
corpo = params[:corpo].to_s.strip
|
||||||
|
# Com mensagem montada no editor o texto é opcional: o template vence e o
|
||||||
|
# corpo digitado só serve de reserva para o canal que ainda não tem um.
|
||||||
|
com_template = MensagemTemplate::CANAIS.any? { |c| @evento.template_utilizavel(c) }
|
||||||
|
if corpo.blank? && !com_template
|
||||||
|
return redirect_to(admin_eventos_path, alert: 'Escreva a mensagem ou monte o template antes de disparar.')
|
||||||
|
end
|
||||||
|
|
||||||
|
Notificacao::Despachante.disparar(chave: @evento.chave,
|
||||||
|
dados: Notificacao::Variaveis.comuns_reais,
|
||||||
|
assunto: params[:assunto].presence || @evento.nome,
|
||||||
|
corpo: corpo)
|
||||||
|
registrar("Disparou manualmente o evento #{@evento.nome}")
|
||||||
|
redirect_to admin_envios_path,
|
||||||
|
notice: 'Disparo agendado. Acompanhe o resultado aqui.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_evento
|
||||||
|
@evento = EventoNotificacao.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def carregar_grupos
|
||||||
|
@grupos = GrupoContato.ativos.ordenados
|
||||||
|
end
|
||||||
|
|
||||||
|
# Uma linha por grupo ativo, marcada quando já existe assinatura. É o que faz
|
||||||
|
# a tela mostrar TODOS os grupos com checkbox, e não só os já assinados.
|
||||||
|
def montar_assinaturas
|
||||||
|
existentes = @evento.assinaturas.index_by(&:grupo_contato_id)
|
||||||
|
@linhas_assinatura = @grupos.map do |grupo|
|
||||||
|
{ grupo: grupo, assinatura: existentes[grupo.id] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def salvar_assinaturas
|
||||||
|
# ActionController::Parameters, indexado pela id do grupo como STRING (é
|
||||||
|
# assim que o form serializa: assinaturas[12][ativo]). Nada de `to_h` aqui:
|
||||||
|
# em params não permitidos ele levanta.
|
||||||
|
marcadas = params.fetch(:assinaturas, {})
|
||||||
|
|
||||||
|
GrupoContato.ativos.find_each do |grupo|
|
||||||
|
dados = marcadas[grupo.id.to_s]
|
||||||
|
atual = @evento.assinaturas.find_by(grupo_contato_id: grupo.id)
|
||||||
|
|
||||||
|
if dados.present? && dados[:ativo] == '1'
|
||||||
|
canal = GrupoEventoAssinatura::CANAIS.include?(dados[:canal]) ? dados[:canal] : 'ambos'
|
||||||
|
atual ? atual.update(canal: canal, ativo: true)
|
||||||
|
: @evento.assinaturas.create(grupo_contato: grupo, canal: canal, ativo: true)
|
||||||
|
elsif atual
|
||||||
|
atual.destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def evento_params
|
||||||
|
params.require(:evento_notificacao)
|
||||||
|
.permit(:nome, :chave, :gatilho, :descricao, :ativo, :notificar_envolvido,
|
||||||
|
:frequencia, :hora, :dia_semana)
|
||||||
|
end
|
||||||
|
|
||||||
|
def registrar(detalhes)
|
||||||
|
AuditoriaLog.registrar(user: current_user, acao: 'evento_notificacao',
|
||||||
|
entidade: 'EventoNotificacao',
|
||||||
|
dados_novos: { detalhes: detalhes }, request: request)
|
||||||
|
end
|
||||||
|
end
|
||||||
74
app/controllers/admin/grupos_contato_controller.rb
Normal file
74
app/controllers/admin/grupos_contato_controller.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# app/controllers/admin/grupos_contato_controller.rb
|
||||||
|
#
|
||||||
|
# Grupos de destinatários. É o grupo que assina os eventos, então cadastrar
|
||||||
|
# alguém novo é escolher o grupo — não repetir a configuração pessoa por pessoa.
|
||||||
|
class Admin::GruposContatoController < ApplicationController
|
||||||
|
before_action :set_grupo, only: %i[edit update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize GrupoContato
|
||||||
|
@grupos = policy_scope(GrupoContato).ordenados
|
||||||
|
.includes(:contatos, assinaturas: :evento_notificacao)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@grupo = GrupoContato.new
|
||||||
|
authorize @grupo
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@grupo = GrupoContato.new(grupo_params)
|
||||||
|
authorize @grupo
|
||||||
|
|
||||||
|
if @grupo.save
|
||||||
|
registrar("Criou grupo de contatos #{@grupo.nome}")
|
||||||
|
redirect_to admin_grupos_path, notice: 'Grupo criado.'
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @grupo
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @grupo
|
||||||
|
|
||||||
|
if @grupo.update(grupo_params)
|
||||||
|
registrar("Editou grupo de contatos #{@grupo.nome}")
|
||||||
|
redirect_to admin_grupos_path, notice: 'Grupo atualizado.'
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
authorize @grupo
|
||||||
|
|
||||||
|
# `dependent: :nullify`: os contatos NÃO são apagados junto — ficam sem
|
||||||
|
# grupo e param de receber, o que é reversível. Apagar pessoas porque o
|
||||||
|
# grupo saiu seria destrutivo demais para um clique.
|
||||||
|
nome = @grupo.nome
|
||||||
|
orfaos = @grupo.contatos.count
|
||||||
|
@grupo.destroy
|
||||||
|
registrar("Excluiu grupo de contatos #{nome} (#{orfaos} contato(s) ficaram sem grupo)")
|
||||||
|
redirect_to admin_grupos_path,
|
||||||
|
notice: "Grupo removido. #{orfaos} contato(s) ficaram sem grupo e não recebem mais."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_grupo
|
||||||
|
@grupo = GrupoContato.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def grupo_params
|
||||||
|
params.require(:grupo_contato).permit(:nome, :descricao, :ativo)
|
||||||
|
end
|
||||||
|
|
||||||
|
def registrar(detalhes)
|
||||||
|
AuditoriaLog.registrar(user: current_user, acao: 'grupo_contato', entidade: 'GrupoContato',
|
||||||
|
dados_novos: { detalhes: detalhes }, request: request)
|
||||||
|
end
|
||||||
|
end
|
||||||
75
app/controllers/admin/mensagem_templates_controller.rb
Normal file
75
app/controllers/admin/mensagem_templates_controller.rb
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# app/controllers/admin/mensagem_templates_controller.rb
|
||||||
|
#
|
||||||
|
# Editor de blocos: monta o corpo de UM evento em UM canal.
|
||||||
|
#
|
||||||
|
# O preview roda no SERVIDOR, chamando o mesmo Notificacao::Renderizador do
|
||||||
|
# envio. Uma segunda implementação em JavaScript ficaria mais rápida e
|
||||||
|
# inevitavelmente divergiria do que é enviado de verdade — e o preview existe
|
||||||
|
# justamente para prometer o contrário.
|
||||||
|
class Admin::MensagemTemplatesController < ApplicationController
|
||||||
|
before_action :carregar
|
||||||
|
|
||||||
|
def edit
|
||||||
|
authorize @evento, :update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
authorize @evento, :update?
|
||||||
|
|
||||||
|
@template.assign_attributes(atributos)
|
||||||
|
if @template.save
|
||||||
|
AuditoriaLog.registrar(user: current_user, acao: 'mensagem_template',
|
||||||
|
entidade: 'MensagemTemplate',
|
||||||
|
dados_novos: { detalhes: "Editou a mensagem de #{@evento.nome} (#{@canal})" },
|
||||||
|
request: request)
|
||||||
|
redirect_to template_admin_evento_path(@evento, canal: @canal), notice: 'Mensagem salva.'
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Renderiza o que está NA TELA (ainda não salvo) com os valores de amostra.
|
||||||
|
def preview
|
||||||
|
authorize @evento, :update?
|
||||||
|
|
||||||
|
provisorio = MensagemTemplate.new(evento_notificacao: @evento, canal: @canal,
|
||||||
|
assunto: params[:assunto], blocos: blocos_do_form, ativo: true)
|
||||||
|
provisorio.validate # dispara a limpeza dos blocos antes de renderizar
|
||||||
|
|
||||||
|
dados = Notificacao::Variaveis.amostra(@evento.gatilho)
|
||||||
|
render json: { html: corpo_do_preview(provisorio, dados),
|
||||||
|
assunto: provisorio.renderizador(dados).interpolar(provisorio.assunto) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def carregar
|
||||||
|
@evento = EventoNotificacao.find(params[:id])
|
||||||
|
@canal = MensagemTemplate::CANAIS.include?(params[:canal]) ? params[:canal] : 'whatsapp'
|
||||||
|
@template = @evento.template(@canal)
|
||||||
|
@variaveis = @evento.variaveis
|
||||||
|
end
|
||||||
|
|
||||||
|
def atributos
|
||||||
|
{ assunto: params[:assunto].to_s.strip,
|
||||||
|
blocos: blocos_do_form,
|
||||||
|
ativo: params[:ativo] == '1' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# O editor serializa o estado num campo hidden. JSON inválido vira lista
|
||||||
|
# vazia: salvar lixo seria pior do que salvar nada, e o model ainda descarta
|
||||||
|
# bloco fora do catálogo.
|
||||||
|
def blocos_do_form
|
||||||
|
JSON.parse(params[:blocos].to_s)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
# E-mail: HTML já montado e escapado pelo renderizador — vai para a tela como
|
||||||
|
# está, dentro de um iframe sandbox na view. WhatsApp: texto puro, que a view
|
||||||
|
# escapa.
|
||||||
|
def corpo_do_preview(template, dados)
|
||||||
|
r = template.renderizador(dados)
|
||||||
|
template.email? ? r.html : r.texto
|
||||||
|
end
|
||||||
|
end
|
||||||
23
app/controllers/admin/notificacao_envios_controller.rb
Normal file
23
app/controllers/admin/notificacao_envios_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# app/controllers/admin/notificacao_envios_controller.rb
|
||||||
|
#
|
||||||
|
# Log de envios — responde "o contato recebeu?". Sem esta tela, a resposta só
|
||||||
|
# existia no log do Rails, porque o disparo engole a exceção de propósito (um
|
||||||
|
# SMTP fora do ar não pode travar o fechamento de um pagamento).
|
||||||
|
class Admin::NotificacaoEnviosController < ApplicationController
|
||||||
|
POR_PAGINA = 50
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize NotificacaoEnvio
|
||||||
|
|
||||||
|
escopo = policy_scope(NotificacaoEnvio).includes(:evento_notificacao, :contato, :user)
|
||||||
|
escopo = escopo.where(status: params[:status]) if NotificacaoEnvio::STATUS.include?(params[:status])
|
||||||
|
escopo = escopo.where(canal: params[:canal]) if NotificacaoEnvio::CANAIS.include?(params[:canal])
|
||||||
|
|
||||||
|
@total_falhas = escopo.falhados.count
|
||||||
|
begin
|
||||||
|
@pagy, @envios = pagy(escopo.recentes, limit: POR_PAGINA)
|
||||||
|
rescue Pagy::OverflowError
|
||||||
|
@pagy, @envios = pagy(escopo.recentes, limit: POR_PAGINA, page: 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
80
app/controllers/admin/whatsapp_sessoes_controller.rb
Normal file
80
app/controllers/admin/whatsapp_sessoes_controller.rb
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# app/controllers/admin/whatsapp_sessoes_controller.rb
|
||||||
|
#
|
||||||
|
# Pareamento do WhatsApp por QR code (ponte Baileys, container `whatsapp`).
|
||||||
|
#
|
||||||
|
# ⚠️ Canal NÃO OFICIAL: parear liga o número da empresa a este servidor por uma
|
||||||
|
# sessão do WhatsApp Web obtida por engenharia reversa. Está fora dos Termos
|
||||||
|
# do WhatsApp e a Meta pode banir o número — use um chip dedicado. A tela
|
||||||
|
# avisa isso em texto, não só no código.
|
||||||
|
class Admin::WhatsappSessoesController < ApplicationController
|
||||||
|
def show
|
||||||
|
authorize :whatsapp_sessao, :show?
|
||||||
|
@config = ConfiguracaoNotificacao.instancia
|
||||||
|
@cliente = Notificacao::ClienteWhatsapp.padrao
|
||||||
|
@estado = consultar
|
||||||
|
end
|
||||||
|
|
||||||
|
# Polling da tela enquanto o QR não é lido — o QR expira em ~20s e a ponte
|
||||||
|
# gera outro, então a página precisa buscar o novo sozinha.
|
||||||
|
def status
|
||||||
|
authorize :whatsapp_sessao, :show?
|
||||||
|
render json: consultar
|
||||||
|
end
|
||||||
|
|
||||||
|
def testar
|
||||||
|
authorize :whatsapp_sessao, :testar?
|
||||||
|
|
||||||
|
destino = ConfiguracaoNotificacao.normalizar_telefone(params[:numero])
|
||||||
|
if destino.blank?
|
||||||
|
return redirect_to(admin_whatsapp_sessao_path, alert: 'Informe um número válido (DDD + número).')
|
||||||
|
end
|
||||||
|
|
||||||
|
resposta = Notificacao::ClienteWhatsapp.padrao.enviar(
|
||||||
|
para: destino,
|
||||||
|
texto: "Teste do Reem Notas ✅\nSe você recebeu isto, o WhatsApp está conectado."
|
||||||
|
)
|
||||||
|
|
||||||
|
if resposta.ok?
|
||||||
|
redirect_to admin_whatsapp_sessao_path, notice: "Mensagem de teste enviada para #{destino}."
|
||||||
|
else
|
||||||
|
redirect_to admin_whatsapp_sessao_path, alert: "Falhou: #{resposta.erro}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def desconectar
|
||||||
|
authorize :whatsapp_sessao, :desconectar?
|
||||||
|
|
||||||
|
resposta = Notificacao::ClienteWhatsapp.padrao.desconectar
|
||||||
|
AuditoriaLog.registrar(user: current_user, acao: 'whatsapp_desconectar',
|
||||||
|
entidade: 'ConfiguracaoNotificacao',
|
||||||
|
dados_novos: { detalhes: 'Desconectou a sessão do WhatsApp' },
|
||||||
|
request: request)
|
||||||
|
|
||||||
|
if resposta.ok?
|
||||||
|
ConfiguracaoNotificacao.instancia.update_columns(whatsapp_numero_conectado: nil,
|
||||||
|
whatsapp_conectado_em: nil)
|
||||||
|
redirect_to admin_whatsapp_sessao_path, notice: 'Sessão encerrada. Leia o QR para conectar de novo.'
|
||||||
|
else
|
||||||
|
redirect_to admin_whatsapp_sessao_path, alert: "Não foi possível desconectar: #{resposta.erro}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Consulta a ponte e memoriza o número conectado no banco — assim a tela de
|
||||||
|
# configuração mostra quem está pareado mesmo com a ponte fora do ar.
|
||||||
|
def consultar
|
||||||
|
resposta = Notificacao::ClienteWhatsapp.padrao.status
|
||||||
|
return { 'conectado' => false, 'erro' => resposta.erro } unless resposta.ok?
|
||||||
|
|
||||||
|
dados = resposta.dados || {}
|
||||||
|
if dados['conectado'] && dados['numero'].present?
|
||||||
|
config = ConfiguracaoNotificacao.instancia
|
||||||
|
if config.whatsapp_numero_conectado != dados['numero']
|
||||||
|
config.update_columns(whatsapp_numero_conectado: dados['numero'],
|
||||||
|
whatsapp_conectado_em: Time.current)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dados
|
||||||
|
end
|
||||||
|
end
|
||||||
64
app/jobs/detectar_mudancas_operacao_job.rb
Normal file
64
app/jobs/detectar_mudancas_operacao_job.rb
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# app/jobs/detectar_mudancas_operacao_job.rb
|
||||||
|
#
|
||||||
|
# Compara os números de cada operação com o retrato anterior (OperacaoSnapshot)
|
||||||
|
# e dispara `operacao_alterada` no que mudou.
|
||||||
|
#
|
||||||
|
# POR QUE VARREDURA e não gatilho no código: a maior parte das mudanças da
|
||||||
|
# operação NÃO passa por aqui — ela acontece no SimpliRoute e chega pelo sync do
|
||||||
|
# espelho, que é read-only do nosso lado. Sem comparar retratos, "o que mudou"
|
||||||
|
# seria só o que o próprio sistema editou, que é a minoria dos casos.
|
||||||
|
#
|
||||||
|
# A PRIMEIRA execução de cada operação só grava o retrato e não avisa nada: sem
|
||||||
|
# retrato anterior, toda operação existente pareceria "nova" e o primeiro deploy
|
||||||
|
# dispararia uma mensagem por operação cadastrada.
|
||||||
|
class DetectarMudancasOperacaoJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless EventoNotificacao.ativos.exists?(gatilho: 'operacao_alterada')
|
||||||
|
|
||||||
|
Operacao.nomes_validos.each { |tabela| verificar(tabela) }
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[MudancasOperacao] #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def verificar(tabela)
|
||||||
|
atuais = contar(tabela)
|
||||||
|
snapshot = OperacaoSnapshot.para(tabela)
|
||||||
|
|
||||||
|
if snapshot.new_record?
|
||||||
|
snapshot.registrar!(atuais)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
diferenca = snapshot.diferenca(atuais)
|
||||||
|
return if diferenca.empty?
|
||||||
|
|
||||||
|
Notificacao::Gatilhos.operacao_alterada(
|
||||||
|
operacao: Operacao.label(tabela),
|
||||||
|
o_que_mudou: OperacaoSnapshot.descrever(diferenca)
|
||||||
|
)
|
||||||
|
snapshot.registrar!(atuais)
|
||||||
|
rescue StandardError => e
|
||||||
|
# Uma operação problemática não pode impedir a varredura das demais.
|
||||||
|
Rails.logger.error("[MudancasOperacao] #{tabela}: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sem filtro de data: a comparação é do estado ATUAL da operação inteira, que
|
||||||
|
# é o que muda quando uma NF entra, sai ou troca de status.
|
||||||
|
#
|
||||||
|
# ⚠️ CUSTO: OperacaoMetricas carrega as visitas da operação em memória para
|
||||||
|
# deduplicar por NF. Uma operação mensal tem alguns milhares de linhas, e o
|
||||||
|
# job varre TODAS as tabelas a cada execução (8×/dia pelo schedule). É o
|
||||||
|
# preço de reusar a mesma contagem do dashboard em vez de escrever um SQL
|
||||||
|
# paralelo que divergiria dele. Se passar a incomodar, o caminho é restringir
|
||||||
|
# a varredura às operações do mês corrente e do anterior — as antigas não
|
||||||
|
# mudam mais.
|
||||||
|
def contar(tabela)
|
||||||
|
m = Analytics::OperacaoMetricas.new(tabelas: [tabela])
|
||||||
|
{ total_notas: m.total, entregues: m.sucesso,
|
||||||
|
nao_entregues: m.recusas, pendentes: m.pendentes }
|
||||||
|
end
|
||||||
|
end
|
||||||
48
app/jobs/disparar_eventos_agendados_job.rb
Normal file
48
app/jobs/disparar_eventos_agendados_job.rb
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# app/jobs/disparar_eventos_agendados_job.rb
|
||||||
|
#
|
||||||
|
# Varre os eventos de gatilho `agendado` e dispara os que venceram. Roda de hora
|
||||||
|
# em hora pelo cron do container (config/schedule.rb).
|
||||||
|
#
|
||||||
|
# POR QUE DE HORA EM HORA e não "no horário exato": o evento guarda hora e
|
||||||
|
# frequência escolhidas na tela, e essas podem mudar a qualquer momento — reler
|
||||||
|
# o crontab a cada edição seria acoplar a UI ao cron do sistema. Uma varredura
|
||||||
|
# horária resolve com uma linha fixa no schedule, e `EventoNotificacao#vencido?`
|
||||||
|
# garante que cada evento dispara UMA vez por dia (ele grava ultimo_disparo_em).
|
||||||
|
#
|
||||||
|
# PERÍODO do resumo — recortes FECHADOS, para o número não mudar depois de
|
||||||
|
# enviado:
|
||||||
|
# diária → o dia anterior
|
||||||
|
# semanal → os 7 dias anteriores
|
||||||
|
class DispararEventosAgendadosJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(agora = Time.current)
|
||||||
|
EventoNotificacao.ativos.where(gatilho: 'agendado').find_each do |evento|
|
||||||
|
next unless evento.vencido?(agora)
|
||||||
|
|
||||||
|
inicio, fim = janela(evento, agora)
|
||||||
|
resumo = Analytics::ResumoOperacao.new(inicio: inicio, fim: fim)
|
||||||
|
|
||||||
|
# Resumo zerado (feriado, fim de semana) só treinaria o leitor a ignorar a
|
||||||
|
# mensagem. Marca como disparado assim mesmo, senão tentaria de hora em
|
||||||
|
# hora até o dia virar.
|
||||||
|
if resumo.vazio?
|
||||||
|
evento.update_columns(ultimo_disparo_em: agora)
|
||||||
|
Rails.logger.info("[Agendados] #{evento.chave}: período sem movimento, nada enviado")
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Notificacao::Gatilhos.resumo_agendado(evento, inicio: inicio, fim: fim)
|
||||||
|
evento.update_columns(ultimo_disparo_em: agora)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Agendados] #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def janela(evento, agora)
|
||||||
|
hoje = agora.to_date
|
||||||
|
evento.frequencia == 'semanal' ? [hoje - 7, hoje - 1] : [hoje - 1, hoje - 1]
|
||||||
|
end
|
||||||
|
end
|
||||||
26
app/jobs/notificacao_job.rb
Normal file
26
app/jobs/notificacao_job.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# app/jobs/notificacao_job.rb
|
||||||
|
#
|
||||||
|
# Entrega das notificações fora da requisição web.
|
||||||
|
#
|
||||||
|
# POR QUE JOB: o envio de WhatsApp pausa entre uma mensagem e outra (o canal por
|
||||||
|
# QR bane número que dispara em rajada). Um `sleep` de 5s vezes 30 contatos
|
||||||
|
# dentro do controller pendura o worker Puma por dois minutos e meio.
|
||||||
|
#
|
||||||
|
# ⚠️ O adapter é o :async do Rails (fila em memória, igual ao `deliver_later` que
|
||||||
|
# o app já usa): job pendente se perde se o container reiniciar. Para
|
||||||
|
# notificação isso é aceitável — o log em notificacao_envios mostra o que
|
||||||
|
# ficou 'pendente' e a tela permite reenviar.
|
||||||
|
class NotificacaoJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(evento_id, dados = {}, assunto = nil, corpo = nil, envolvido_id = nil)
|
||||||
|
evento = EventoNotificacao.find_by(id: evento_id)
|
||||||
|
return if evento.nil? || !evento.ativo?
|
||||||
|
|
||||||
|
envolvido = envolvido_id && User.find_by(id: envolvido_id)
|
||||||
|
Notificacao::Despachante.new(evento, dados: dados, assunto: assunto,
|
||||||
|
corpo: corpo, envolvido: envolvido).executar
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[NotificacaoJob] evento #{evento_id}: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
18
app/mailers/notificacao_mailer.rb
Normal file
18
app/mailers/notificacao_mailer.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# app/mailers/notificacao_mailer.rb
|
||||||
|
#
|
||||||
|
# E-mail genérico das notificações configuráveis: assunto e corpo vêm prontos do
|
||||||
|
# editor, não de uma view por evento (como no ConsolidacaoMailer). É o que
|
||||||
|
# permite o ADM criar eventos novos sem precisar de código.
|
||||||
|
class NotificacaoMailer < ApplicationMailer
|
||||||
|
# `html: true` = o corpo já vem montado pelo Notificacao::Renderizador, que
|
||||||
|
# escapa todo texto do editor e toda variável. `html: false` = texto puro
|
||||||
|
# digitado, formatado com simple_format (que escapa).
|
||||||
|
#
|
||||||
|
# ⚠️ Nunca chamar com html: true para texto que não passou pelo renderizador —
|
||||||
|
# seria injeção de HTML no e-mail.
|
||||||
|
def mensagem(destino, assunto, corpo, html: false)
|
||||||
|
@corpo = corpo.to_s
|
||||||
|
@html = html
|
||||||
|
mail(to: destino, subject: assunto.presence || 'Aviso')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,9 +11,15 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
|||||||
|
|
||||||
self.table_name = 'configuracao_notificacoes'
|
self.table_name = 'configuracao_notificacoes'
|
||||||
|
|
||||||
atributo_cifrado :smtp_password, :twilio_auth_token
|
atributo_cifrado :smtp_password, :twilio_auth_token, :baileys_token
|
||||||
|
|
||||||
AUTENTICACOES = %w[plain login cram_md5].freeze
|
AUTENTICACOES = %w[plain login cram_md5].freeze
|
||||||
|
|
||||||
|
# Quem entrega o WhatsApp. `baileys` é a ponte por QR code (container
|
||||||
|
# `whatsapp`), gratuita e ilimitada, porém NÃO OFICIAL — a Meta pode banir o
|
||||||
|
# número. `twilio` é o canal oficial pago que existia antes.
|
||||||
|
PROVEDORES_WHATSAPP = %w[baileys twilio].freeze
|
||||||
|
BAILEYS_URL_PADRAO = 'http://whatsapp:3001'.freeze
|
||||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||||
REMETENTE_PADRAO = 'noreply@gade.com.br'
|
REMETENTE_PADRAO = 'noreply@gade.com.br'
|
||||||
|
|
||||||
@@ -28,9 +34,16 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
|||||||
validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true
|
validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true
|
||||||
validate :senha_smtp_presente, if: :smtp_ativo?
|
validate :senha_smtp_presente, if: :smtp_ativo?
|
||||||
|
|
||||||
validates :twilio_account_sid, presence: true, if: :whatsapp_ativo?
|
validates :whatsapp_provedor, inclusion: { in: PROVEDORES_WHATSAPP }
|
||||||
validates :twilio_from, presence: true, if: :whatsapp_ativo?
|
validates :whatsapp_intervalo_segundos,
|
||||||
validate :token_twilio_presente, if: :whatsapp_ativo?
|
numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||||
|
less_than_or_equal_to: 300 }
|
||||||
|
|
||||||
|
# As credenciais do Twilio só são exigidas quando ele É o provedor escolhido.
|
||||||
|
validates :twilio_account_sid, presence: true, if: :twilio_exigido?
|
||||||
|
validates :twilio_from, presence: true, if: :twilio_exigido?
|
||||||
|
validate :token_twilio_presente, if: :twilio_exigido?
|
||||||
|
validate :baileys_configuravel, if: :baileys_exigido?
|
||||||
|
|
||||||
# ── Singleton ───────────────────────────────────────────────
|
# ── Singleton ───────────────────────────────────────────────
|
||||||
def self.instancia
|
def self.instancia
|
||||||
@@ -102,6 +115,32 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
|||||||
email_notificacoes_ativo?
|
email_notificacoes_ativo?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── WhatsApp: escolha de provedor ───────────────────────────
|
||||||
|
def baileys? = whatsapp_provedor.to_s == 'baileys'
|
||||||
|
def twilio? = whatsapp_provedor.to_s == 'twilio'
|
||||||
|
|
||||||
|
def twilio_exigido? = whatsapp_ativo? && twilio?
|
||||||
|
def baileys_exigido? = whatsapp_ativo? && baileys?
|
||||||
|
|
||||||
|
# URL e token da ponte: banco > .env, mesma hierarquia do resto da tela.
|
||||||
|
def baileys_url_efetiva
|
||||||
|
baileys_url.presence || ENV['BAILEYS_URL'].presence || BAILEYS_URL_PADRAO
|
||||||
|
end
|
||||||
|
|
||||||
|
def baileys_token_efetivo
|
||||||
|
baileys_token.presence || ENV['WHATSAPP_TOKEN'].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def baileys_pronto?
|
||||||
|
whatsapp_ativo? && baileys? && baileys_token_efetivo.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Segundos entre uma mensagem e a seguinte. Disparo em rajada para dezenas de
|
||||||
|
# contatos é a forma mais rápida de o número ser banido no canal não oficial.
|
||||||
|
def intervalo_envio
|
||||||
|
whatsapp_intervalo_segundos.to_i.clamp(0, 300)
|
||||||
|
end
|
||||||
|
|
||||||
# ── WhatsApp / Twilio ───────────────────────────────────────
|
# ── WhatsApp / Twilio ───────────────────────────────────────
|
||||||
def whatsapp_pronto?
|
def whatsapp_pronto?
|
||||||
whatsapp_ativo? && twilio_account_sid.present? &&
|
whatsapp_ativo? && twilio_account_sid.present? &&
|
||||||
@@ -109,7 +148,9 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_habilitado?
|
def whatsapp_habilitado?
|
||||||
whatsapp_ativo? && credenciais_whatsapp.present?
|
return false unless whatsapp_ativo?
|
||||||
|
|
||||||
|
baileys? ? baileys_pronto? : credenciais_whatsapp.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta
|
# Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta
|
||||||
@@ -162,12 +203,22 @@ class ConfiguracaoNotificacao < ApplicationRecord
|
|||||||
self.remetente_nome = remetente_nome.to_s.strip.presence
|
self.remetente_nome = remetente_nome.to_s.strip.presence
|
||||||
self.email_admin = email_admin.to_s.strip.downcase.presence
|
self.email_admin = email_admin.to_s.strip.downcase.presence
|
||||||
self.twilio_account_sid = twilio_account_sid.to_s.strip.presence
|
self.twilio_account_sid = twilio_account_sid.to_s.strip.presence
|
||||||
|
self.baileys_url = baileys_url.to_s.strip.chomp('/').presence
|
||||||
|
self.whatsapp_provedor = whatsapp_provedor.presence || 'baileys'
|
||||||
# Mantém o que o admin digitou se não der para normalizar — a validação de
|
# Mantém o que o admin digitou se não der para normalizar — a validação de
|
||||||
# presença acusa, e ele vê o próprio valor de volta no campo para corrigir.
|
# presença acusa, e ele vê o próprio valor de volta no campo para corrigir.
|
||||||
self.twilio_from = self.class.normalizar_telefone(twilio_from) || twilio_from.presence
|
self.twilio_from = self.class.normalizar_telefone(twilio_from) || twilio_from.presence
|
||||||
self.whatsapp_admin = self.class.normalizar_telefone(whatsapp_admin) || whatsapp_admin.presence
|
self.whatsapp_admin = self.class.normalizar_telefone(whatsapp_admin) || whatsapp_admin.presence
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def baileys_configuravel
|
||||||
|
return if baileys_token_efetivo.present?
|
||||||
|
|
||||||
|
errors.add(:baileys_token,
|
||||||
|
'é obrigatório: defina WHATSAPP_TOKEN no .env (o mesmo do container `whatsapp`) ' \
|
||||||
|
'ou preencha aqui.')
|
||||||
|
end
|
||||||
|
|
||||||
def senha_smtp_presente
|
def senha_smtp_presente
|
||||||
return if smtp_password.present?
|
return if smtp_password.present?
|
||||||
|
|
||||||
|
|||||||
@@ -377,12 +377,19 @@ class Consolidacao < ApplicationRecord
|
|||||||
cm = consolidacao_motoristas.find_by(motorista_nome: nome)
|
cm = consolidacao_motoristas.find_by(motorista_nome: nome)
|
||||||
return unless cm
|
return unless cm
|
||||||
|
|
||||||
|
anterior = cm.valor_total
|
||||||
total = consolidacao_entregas.where(motorista_nome: nome).sum do |e|
|
total = consolidacao_entregas.where(motorista_nome: nome).sum do |e|
|
||||||
e.desconto? ? -e.valor_aplicado : e.valor_aplicado
|
e.desconto? ? -e.valor_aplicado : e.valor_aplicado
|
||||||
end
|
end
|
||||||
cm.update_column(:valor_total, total)
|
cm.update_column(:valor_total, total)
|
||||||
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
||||||
sincronizar_veiculos!(nome)
|
sincronizar_veiculos!(nome)
|
||||||
|
|
||||||
|
# Funil ÚNICO de mudança de valor do motorista — todo caminho que altera
|
||||||
|
# pilar, desconto ou lançamento termina aqui. O gatilho só vale depois do
|
||||||
|
# fechamento: em rascunho o valor muda a cada clique do wizard e avisar ali
|
||||||
|
# seria spam, não informação. Nunca levanta (ver Notificacao::Gatilhos).
|
||||||
|
Notificacao::Gatilhos.valor_alterado(self, cm, anterior: anterior)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
|
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
|
||||||
|
|||||||
68
app/models/contato.rb
Normal file
68
app/models/contato.rb
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# app/models/contato.rb
|
||||||
|
#
|
||||||
|
# Destinatário das notificações, cadastrado à mão pelo ADM: nome, número e/ou
|
||||||
|
# e-mail, e o grupo a que pertence.
|
||||||
|
#
|
||||||
|
# Telefone é gravado SEMPRE em E.164 (+5511920051157). O ADM digita como quiser
|
||||||
|
# — "(11) 92005-1157", "011 92005 1157" — e a normalização acontece aqui, uma
|
||||||
|
# vez, na gravação. Mandar o texto cru para o provedor devolve erro de destino
|
||||||
|
# inválido que o rescue do envio esconderia.
|
||||||
|
class Contato < ApplicationRecord
|
||||||
|
belongs_to :grupo_contato, optional: true
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
has_many :notificacao_envios, dependent: :nullify
|
||||||
|
|
||||||
|
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||||
|
|
||||||
|
validates :nome, presence: true
|
||||||
|
validates :email, format: { with: EMAIL_RE }, allow_blank: true
|
||||||
|
validate :telefone_ou_email
|
||||||
|
validate :telefone_valido
|
||||||
|
|
||||||
|
before_validation :normalizar
|
||||||
|
|
||||||
|
scope :ativos, -> { where(ativo: true) }
|
||||||
|
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||||
|
scope :com_whatsapp, -> { where.not(telefone: [nil, '']) }
|
||||||
|
scope :com_email, -> { where.not(email: [nil, '']) }
|
||||||
|
|
||||||
|
# Filtra pelo canal do disparo. 'ambos' = quem tiver qualquer um dos dois.
|
||||||
|
scope :com_canal, ->(canal) {
|
||||||
|
case canal.to_s
|
||||||
|
when 'whatsapp' then com_whatsapp
|
||||||
|
when 'email' then com_email
|
||||||
|
else where("(telefone IS NOT NULL AND telefone <> '') OR (email IS NOT NULL AND email <> '')")
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
def whatsapp? = telefone.present?
|
||||||
|
def email? = email.present?
|
||||||
|
|
||||||
|
def primeiro_nome
|
||||||
|
nome.to_s.split.first.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalizar
|
||||||
|
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||||
|
self.email = email.to_s.strip.downcase.presence
|
||||||
|
# Mantém o que foi digitado se não der para normalizar — a validação acusa e
|
||||||
|
# o ADM vê o próprio valor de volta no campo para corrigir.
|
||||||
|
bruto = telefone.to_s.strip.presence
|
||||||
|
self.telefone = bruto && (ConfiguracaoNotificacao.normalizar_telefone(bruto) || bruto)
|
||||||
|
end
|
||||||
|
|
||||||
|
def telefone_ou_email
|
||||||
|
return if telefone.present? || email.present?
|
||||||
|
|
||||||
|
errors.add(:base, 'Informe ao menos um WhatsApp ou um e-mail.')
|
||||||
|
end
|
||||||
|
|
||||||
|
def telefone_valido
|
||||||
|
return if telefone.blank?
|
||||||
|
return if ConfiguracaoNotificacao.normalizar_telefone(telefone).present?
|
||||||
|
|
||||||
|
errors.add(:telefone, 'não parece um número brasileiro válido (DDD + número)')
|
||||||
|
end
|
||||||
|
end
|
||||||
119
app/models/evento_notificacao.rb
Normal file
119
app/models/evento_notificacao.rb
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# app/models/evento_notificacao.rb
|
||||||
|
#
|
||||||
|
# Um evento que dispara mensagem. O ADM cadastra quantos quiser e liga cada um a
|
||||||
|
# grupos de contato — mas o GATILHO sai de GATILHOS, porque gatilho é código.
|
||||||
|
#
|
||||||
|
# manual → botão "disparar agora" (aviso pontual)
|
||||||
|
# consolidacao_finalizada → NotificacaoService.notificar_finalizacao
|
||||||
|
# pagamento_efetuado → NotificacaoService.notificar_pagamento
|
||||||
|
# valor_alterado → edição de lançamento / desconto após o fechamento
|
||||||
|
# operacao_alterada → NF que entrou/saiu, entrega que mudou de status
|
||||||
|
# agendado → resumo periódico (frequencia + hora)
|
||||||
|
#
|
||||||
|
# `sistema: true` marca os que nascem com o app: editáveis e desligáveis, mas
|
||||||
|
# não apagáveis — apagar deixaria o código disparando no vazio.
|
||||||
|
class EventoNotificacao < ApplicationRecord
|
||||||
|
self.table_name = 'eventos_notificacao'
|
||||||
|
|
||||||
|
GATILHOS = %w[manual consolidacao_finalizada pagamento_efetuado
|
||||||
|
valor_alterado operacao_alterada agendado].freeze
|
||||||
|
|
||||||
|
GATILHO_LABEL = {
|
||||||
|
'manual' => 'Disparo manual (botão)',
|
||||||
|
'consolidacao_finalizada' => 'Consolidação finalizada',
|
||||||
|
'pagamento_efetuado' => 'Pagamento efetuado',
|
||||||
|
'valor_alterado' => 'Valor alterado no fechamento',
|
||||||
|
'operacao_alterada' => 'Dados da operação mudaram',
|
||||||
|
'agendado' => 'Agendado (resumo periódico)'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
FREQUENCIAS = %w[diaria semanal].freeze
|
||||||
|
|
||||||
|
# Gatilhos que se referem a UMA pessoa específica (o motorista do pagamento),
|
||||||
|
# e não só a grupos. Só neles `notificar_envolvido` faz sentido.
|
||||||
|
GATILHOS_COM_ENVOLVIDO = %w[consolidacao_finalizada pagamento_efetuado valor_alterado].freeze
|
||||||
|
|
||||||
|
has_many :assinaturas, class_name: 'GrupoEventoAssinatura', dependent: :destroy
|
||||||
|
has_many :grupos_contato, through: :assinaturas, source: :grupo_contato
|
||||||
|
has_many :notificacao_envios, dependent: :nullify
|
||||||
|
has_many :mensagem_templates, dependent: :destroy
|
||||||
|
|
||||||
|
accepts_nested_attributes_for :assinaturas, allow_destroy: true
|
||||||
|
|
||||||
|
validates :nome, presence: true
|
||||||
|
validates :chave, presence: true, uniqueness: { case_sensitive: false }
|
||||||
|
validates :gatilho, inclusion: { in: GATILHOS }
|
||||||
|
validates :frequencia, inclusion: { in: FREQUENCIAS }, if: :agendado?
|
||||||
|
validates :hora, numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||||
|
less_than_or_equal_to: 23 }, allow_nil: true
|
||||||
|
validates :dia_semana, numericality: { only_integer: true, greater_than_or_equal_to: 0,
|
||||||
|
less_than_or_equal_to: 6 }, allow_nil: true
|
||||||
|
validate :semanal_tem_dia
|
||||||
|
|
||||||
|
before_validation :normalizar
|
||||||
|
|
||||||
|
scope :ativos, -> { where(ativo: true) }
|
||||||
|
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||||
|
scope :do_gatilho, ->(g) { ativos.where(gatilho: g) }
|
||||||
|
|
||||||
|
def agendado? = gatilho == 'agendado'
|
||||||
|
def manual? = gatilho == 'manual'
|
||||||
|
def envolvido_aplicavel? = GATILHOS_COM_ENVOLVIDO.include?(gatilho)
|
||||||
|
def gatilho_label = GATILHO_LABEL[gatilho] || gatilho
|
||||||
|
|
||||||
|
# Só pode apagar o que o ADM criou.
|
||||||
|
def apagavel? = !sistema?
|
||||||
|
|
||||||
|
# Template do canal, criando um vazio em memória quando ainda não existe —
|
||||||
|
# assim a tela do editor abre igual para evento novo e evento já configurado.
|
||||||
|
def template(canal)
|
||||||
|
mensagem_templates.find { |t| t.canal == canal.to_s } ||
|
||||||
|
mensagem_templates.build(canal: canal.to_s, blocos: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
# O corpo configurado no editor vence o texto padrão do código. Um template
|
||||||
|
# ativo porém VAZIO não conta (mandaria mensagem em branco).
|
||||||
|
def template_utilizavel(canal)
|
||||||
|
t = mensagem_templates.detect { |x| x.canal == canal.to_s }
|
||||||
|
t&.utilizavel? ? t : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def variaveis = Notificacao::Variaveis.para(gatilho)
|
||||||
|
|
||||||
|
# Contatos que devem receber este evento, sem repetição — a mesma pessoa pode
|
||||||
|
# estar em dois grupos assinantes.
|
||||||
|
def destinatarios(canal)
|
||||||
|
ids = assinaturas.where(ativo: true)
|
||||||
|
.select { |a| a.cobre_canal?(canal) }
|
||||||
|
.map(&:grupo_contato_id)
|
||||||
|
return Contato.none if ids.empty?
|
||||||
|
|
||||||
|
Contato.ativos.where(grupo_contato_id: ids).com_canal(canal).distinct
|
||||||
|
end
|
||||||
|
|
||||||
|
# Já passou da hora de disparar hoje/esta semana?
|
||||||
|
def vencido?(agora = Time.current)
|
||||||
|
return false unless agendado? && ativo?
|
||||||
|
return false unless agora.hour >= hora.to_i
|
||||||
|
return false if frequencia == 'semanal' && agora.wday != dia_semana.to_i
|
||||||
|
return true if ultimo_disparo_em.blank?
|
||||||
|
|
||||||
|
ultimo_disparo_em < agora.beginning_of_day
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalizar
|
||||||
|
self.nome = nome.to_s.strip.squeeze(' ').presence
|
||||||
|
self.chave = chave.presence || nome.to_s.parameterize(separator: '_').presence
|
||||||
|
self.chave = chave.to_s.strip.downcase.presence
|
||||||
|
self.dia_semana = nil unless agendado? && frequencia == 'semanal'
|
||||||
|
self.frequencia = nil unless agendado?
|
||||||
|
end
|
||||||
|
|
||||||
|
def semanal_tem_dia
|
||||||
|
return unless agendado? && frequencia == 'semanal' && dia_semana.blank?
|
||||||
|
|
||||||
|
errors.add(:dia_semana, 'é obrigatório na frequência semanal')
|
||||||
|
end
|
||||||
|
end
|
||||||
38
app/models/grupo_contato.rb
Normal file
38
app/models/grupo_contato.rb
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# app/models/grupo_contato.rb
|
||||||
|
#
|
||||||
|
# Grupo de destinatários (Diretoria, Operação, Motoristas, Cliente...). É o
|
||||||
|
# GRUPO que assina os eventos: cadastrar alguém novo é escolher o grupo, não
|
||||||
|
# repetir a configuração pessoa por pessoa.
|
||||||
|
class GrupoContato < ApplicationRecord
|
||||||
|
self.table_name = 'grupos_contato'
|
||||||
|
|
||||||
|
has_many :contatos, dependent: :nullify
|
||||||
|
has_many :assinaturas, class_name: 'GrupoEventoAssinatura', dependent: :destroy
|
||||||
|
has_many :eventos, through: :assinaturas, source: :evento_notificacao
|
||||||
|
|
||||||
|
validates :nome, presence: true
|
||||||
|
validate :nome_unico
|
||||||
|
|
||||||
|
scope :ativos, -> { where(ativo: true) }
|
||||||
|
scope :ordenados, -> { order(Arel.sql('LOWER(nome)')) }
|
||||||
|
|
||||||
|
before_validation { self.nome = nome.to_s.strip.squeeze(' ').presence }
|
||||||
|
|
||||||
|
# Contatos que devem receber ESTE evento por ESTE canal.
|
||||||
|
def destinatarios(canal)
|
||||||
|
return GrupoContato.none unless ativo?
|
||||||
|
|
||||||
|
contatos.ativos.com_canal(canal)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Índice único é sobre LOWER(nome); validar aqui dá a mensagem amigável em vez
|
||||||
|
# de estourar RecordNotUnique na tela.
|
||||||
|
def nome_unico
|
||||||
|
return if nome.blank?
|
||||||
|
|
||||||
|
existe = GrupoContato.where.not(id: id).where('LOWER(nome) = ?', nome.downcase).exists?
|
||||||
|
errors.add(:nome, 'já existe') if existe
|
||||||
|
end
|
||||||
|
end
|
||||||
22
app/models/grupo_evento_assinatura.rb
Normal file
22
app/models/grupo_evento_assinatura.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# app/models/grupo_evento_assinatura.rb
|
||||||
|
#
|
||||||
|
# "O grupo X recebe o evento Y por WhatsApp/e-mail/ambos."
|
||||||
|
class GrupoEventoAssinatura < ApplicationRecord
|
||||||
|
CANAIS = %w[ambos whatsapp email].freeze
|
||||||
|
CANAL_LABEL = { 'ambos' => 'WhatsApp e e-mail', 'whatsapp' => 'Só WhatsApp', 'email' => 'Só e-mail' }.freeze
|
||||||
|
|
||||||
|
belongs_to :grupo_contato
|
||||||
|
belongs_to :evento_notificacao
|
||||||
|
|
||||||
|
validates :canal, inclusion: { in: CANAIS }
|
||||||
|
validates :grupo_contato_id, uniqueness: { scope: :evento_notificacao_id,
|
||||||
|
message: 'já assina este evento' }
|
||||||
|
|
||||||
|
scope :ativas, -> { where(ativo: true) }
|
||||||
|
|
||||||
|
def cobre_canal?(canal)
|
||||||
|
self.canal == 'ambos' || self.canal == canal.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def canal_label = CANAL_LABEL[canal] || canal
|
||||||
|
end
|
||||||
85
app/models/mensagem_template.rb
Normal file
85
app/models/mensagem_template.rb
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# app/models/mensagem_template.rb
|
||||||
|
#
|
||||||
|
# Corpo de um evento em UM canal, montado com blocos no editor.
|
||||||
|
#
|
||||||
|
# `blocos` vem de um campo hidden preenchido por JavaScript — ou seja, de fora.
|
||||||
|
# Por isso #normalizar_blocos DESCARTA o que não está no catálogo: tipo
|
||||||
|
# desconhecido, campo que aquele tipo não tem, e qualquer coisa que não seja
|
||||||
|
# Hash. Sem isso, um JSON adulterado no form viraria conteúdo renderizado sem
|
||||||
|
# passar por validação nenhuma.
|
||||||
|
class MensagemTemplate < ApplicationRecord
|
||||||
|
CANAIS = %w[whatsapp email].freeze
|
||||||
|
# Teto de segurança: um template com milhares de blocos só serviria para
|
||||||
|
# travar o render do e-mail.
|
||||||
|
MAX_BLOCOS = 40
|
||||||
|
MAX_LINHAS_TABELA = 20
|
||||||
|
|
||||||
|
belongs_to :evento_notificacao
|
||||||
|
|
||||||
|
validates :canal, inclusion: { in: CANAIS }
|
||||||
|
validates :canal, uniqueness: { scope: :evento_notificacao_id }
|
||||||
|
validate :assunto_no_email
|
||||||
|
|
||||||
|
before_validation :normalizar_blocos
|
||||||
|
|
||||||
|
scope :ativos, -> { where(ativo: true) }
|
||||||
|
scope :do_canal, ->(canal) { where(canal: canal.to_s) }
|
||||||
|
|
||||||
|
def email? = canal == 'email'
|
||||||
|
def whatsapp? = canal == 'whatsapp'
|
||||||
|
|
||||||
|
# Um template só entra em uso quando tem conteúdo — template ativo e vazio
|
||||||
|
# mandaria mensagem em branco, o que é pior do que cair no texto padrão.
|
||||||
|
def utilizavel?
|
||||||
|
ativo? && blocos.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def renderizador(dados = {})
|
||||||
|
Notificacao::Renderizador.new(blocos, dados)
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview(dados = nil)
|
||||||
|
dados ||= Notificacao::Variaveis.amostra(evento_notificacao&.gatilho)
|
||||||
|
r = renderizador(dados)
|
||||||
|
email? ? r.html : r.texto
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalizar_blocos
|
||||||
|
lista = blocos.is_a?(Array) ? blocos : []
|
||||||
|
self.blocos = lista.filter_map { |bruto| limpar(bruto) }.first(MAX_BLOCOS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def limpar(bruto)
|
||||||
|
return nil unless bruto.is_a?(Hash)
|
||||||
|
|
||||||
|
tipo = bruto['tipo'] || bruto[:tipo]
|
||||||
|
return nil unless Notificacao::Blocos.valido?(tipo)
|
||||||
|
|
||||||
|
limpo = { 'tipo' => tipo.to_s }
|
||||||
|
Notificacao::Blocos.campos(tipo).each do |campo, especie|
|
||||||
|
valor = bruto[campo] || bruto[campo.to_sym]
|
||||||
|
limpo[campo] = especie == :lista ? limpar_linhas(valor) : valor.to_s
|
||||||
|
end
|
||||||
|
limpo
|
||||||
|
end
|
||||||
|
|
||||||
|
def limpar_linhas(valor)
|
||||||
|
Array(valor).filter_map do |linha|
|
||||||
|
next unless linha.is_a?(Hash)
|
||||||
|
|
||||||
|
rotulo = (linha['rotulo'] || linha[:rotulo]).to_s
|
||||||
|
conteudo = (linha['valor'] || linha[:valor]).to_s
|
||||||
|
next if rotulo.blank? && conteudo.blank?
|
||||||
|
|
||||||
|
{ 'rotulo' => rotulo, 'valor' => conteudo }
|
||||||
|
end.first(MAX_LINHAS_TABELA)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assunto_no_email
|
||||||
|
return unless email? && ativo? && blocos.present? && assunto.to_s.strip.blank?
|
||||||
|
|
||||||
|
errors.add(:assunto, 'é obrigatório no e-mail')
|
||||||
|
end
|
||||||
|
end
|
||||||
41
app/models/notificacao_envio.rb
Normal file
41
app/models/notificacao_envio.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# app/models/notificacao_envio.rb
|
||||||
|
#
|
||||||
|
# Registro de UMA mensagem disparada. Existe para responder "o motorista
|
||||||
|
# recebeu?" — pergunta que hoje só tem resposta no log do Rails, porque o
|
||||||
|
# NotificacaoService engole a exceção de propósito (um SMTP fora do ar não pode
|
||||||
|
# travar o fechamento de um pagamento).
|
||||||
|
#
|
||||||
|
# Com o WhatsApp por sessão QR, que cai sozinha e exige repareamento, essa
|
||||||
|
# pergunta vira rotina — daí o log ser tabela, não linha de arquivo.
|
||||||
|
class NotificacaoEnvio < ApplicationRecord
|
||||||
|
STATUS = %w[pendente enviado falhou].freeze
|
||||||
|
CANAIS = %w[whatsapp email].freeze
|
||||||
|
|
||||||
|
belongs_to :evento_notificacao, optional: true
|
||||||
|
belongs_to :contato, optional: true
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
|
||||||
|
validates :canal, inclusion: { in: CANAIS }
|
||||||
|
validates :status, inclusion: { in: STATUS }
|
||||||
|
validates :destino, presence: true
|
||||||
|
|
||||||
|
scope :recentes, -> { order(created_at: :desc) }
|
||||||
|
scope :falhados, -> { where(status: 'falhou') }
|
||||||
|
scope :enviados, -> { where(status: 'enviado') }
|
||||||
|
scope :no_periodo, ->(inicio, fim) {
|
||||||
|
where(created_at: inicio.to_date.beginning_of_day..fim.to_date.end_of_day)
|
||||||
|
}
|
||||||
|
|
||||||
|
def marcar_enviado!
|
||||||
|
update!(status: 'enviado', enviado_em: Time.current, erro: nil,
|
||||||
|
tentativas: tentativas + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def marcar_falha!(erro)
|
||||||
|
update!(status: 'falhou', erro: erro.to_s.truncate(2000),
|
||||||
|
tentativas: tentativas + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enviado? = status == 'enviado'
|
||||||
|
def falhou? = status == 'falhou'
|
||||||
|
end
|
||||||
41
app/models/operacao_snapshot.rb
Normal file
41
app/models/operacao_snapshot.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# app/models/operacao_snapshot.rb
|
||||||
|
#
|
||||||
|
# Últimos números conhecidos de uma operação. Comparar com os de agora é o que
|
||||||
|
# permite dizer O QUE mudou — e não só como está.
|
||||||
|
class OperacaoSnapshot < ApplicationRecord
|
||||||
|
CONTADORES = %i[total_notas entregues nao_entregues pendentes].freeze
|
||||||
|
|
||||||
|
ROTULOS = {
|
||||||
|
total_notas: 'notas na operação',
|
||||||
|
entregues: 'entregues',
|
||||||
|
nao_entregues: 'não entregues',
|
||||||
|
pendentes: 'em aberto'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
validates :tabela, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
def self.para(tabela) = find_or_initialize_by(tabela: tabela.to_s)
|
||||||
|
|
||||||
|
def numeros = CONTADORES.index_with { |c| send(c).to_i }
|
||||||
|
|
||||||
|
# Diferença legível entre o retrato guardado e os números de agora:
|
||||||
|
# { total_notas: +3, pendentes: -1 } => "3 notas na operação a mais, 1 em aberto a menos"
|
||||||
|
def diferenca(atuais)
|
||||||
|
CONTADORES.filter_map do |contador|
|
||||||
|
delta = atuais[contador].to_i - send(contador).to_i
|
||||||
|
next if delta.zero?
|
||||||
|
|
||||||
|
[contador, delta]
|
||||||
|
end.to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.descrever(diferenca)
|
||||||
|
diferenca.map do |contador, delta|
|
||||||
|
"#{delta.abs} #{ROTULOS.fetch(contador, contador)} #{delta.positive? ? 'a mais' : 'a menos'}"
|
||||||
|
end.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def registrar!(atuais)
|
||||||
|
update!(atuais.merge(capturado_em: Time.current))
|
||||||
|
end
|
||||||
|
end
|
||||||
20
app/policies/contato_policy.rb
Normal file
20
app/policies/contato_policy.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# app/policies/contato_policy.rb
|
||||||
|
#
|
||||||
|
# Contatos e grupos são a lista de quem recebe mensagem em nome da empresa —
|
||||||
|
# admin e gerente administram, operador não.
|
||||||
|
#
|
||||||
|
# ⚠️ Cada action nova do controller precisa de um `<action>?` aqui, senão o
|
||||||
|
# `authorize` estoura.
|
||||||
|
class ContatoPolicy < ApplicationPolicy
|
||||||
|
def index? = admin_ou_gerente?
|
||||||
|
def show? = admin_ou_gerente?
|
||||||
|
def create? = admin_ou_gerente?
|
||||||
|
def new? = create?
|
||||||
|
def update? = admin_ou_gerente?
|
||||||
|
def edit? = update?
|
||||||
|
def destroy? = admin?
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve = scope.all
|
||||||
|
end
|
||||||
|
end
|
||||||
23
app/policies/evento_notificacao_policy.rb
Normal file
23
app/policies/evento_notificacao_policy.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# app/policies/evento_notificacao_policy.rb
|
||||||
|
#
|
||||||
|
# Eventos definem o que a empresa dispara e para quem — inclusive o botão de
|
||||||
|
# disparo manual, que manda mensagem de verdade. Só ADM.
|
||||||
|
class EventoNotificacaoPolicy < ApplicationPolicy
|
||||||
|
def index? = admin?
|
||||||
|
def show? = admin?
|
||||||
|
def create? = admin?
|
||||||
|
def new? = create?
|
||||||
|
def update? = admin?
|
||||||
|
def edit? = update?
|
||||||
|
def disparar? = admin?
|
||||||
|
|
||||||
|
# Evento de sistema não se apaga: o código dispara por `chave` e ficaria
|
||||||
|
# chamando no vazio. A tela oferece desativar.
|
||||||
|
def destroy?
|
||||||
|
admin? && record.respond_to?(:apagavel?) && record.apagavel?
|
||||||
|
end
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve = scope.all
|
||||||
|
end
|
||||||
|
end
|
||||||
3
app/policies/grupo_contato_policy.rb
Normal file
3
app/policies/grupo_contato_policy.rb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# app/policies/grupo_contato_policy.rb — ver ContatoPolicy.
|
||||||
|
class GrupoContatoPolicy < ContatoPolicy
|
||||||
|
end
|
||||||
12
app/policies/notificacao_envio_policy.rb
Normal file
12
app/policies/notificacao_envio_policy.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# app/policies/notificacao_envio_policy.rb
|
||||||
|
#
|
||||||
|
# Log de envios: o corpo das mensagens passa por aqui, então leitura é de
|
||||||
|
# admin/gerente. Nada é editável — o log é registro, não cadastro.
|
||||||
|
class NotificacaoEnvioPolicy < ApplicationPolicy
|
||||||
|
def index? = admin_ou_gerente?
|
||||||
|
def show? = admin_ou_gerente?
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve = scope.all
|
||||||
|
end
|
||||||
|
end
|
||||||
13
app/policies/whatsapp_sessao_policy.rb
Normal file
13
app/policies/whatsapp_sessao_policy.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# app/policies/whatsapp_sessao_policy.rb
|
||||||
|
#
|
||||||
|
# Parear o WhatsApp por QR é ligar o número da empresa a este servidor, e
|
||||||
|
# desconectar derruba o canal para todo mundo. Só ADM.
|
||||||
|
class WhatsappSessaoPolicy < ApplicationPolicy
|
||||||
|
def show? = admin?
|
||||||
|
def desconectar? = admin?
|
||||||
|
def testar? = admin?
|
||||||
|
|
||||||
|
class Scope < Scope
|
||||||
|
def resolve = scope.all
|
||||||
|
end
|
||||||
|
end
|
||||||
50
app/services/analytics/resumo_operacao.rb
Normal file
50
app/services/analytics/resumo_operacao.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# app/services/analytics/resumo_operacao.rb
|
||||||
|
#
|
||||||
|
# Os números da operação num período, na forma que a mensagem agendada usa —
|
||||||
|
# as MESMAS chaves que Notificacao::Variaveis anuncia no editor para o gatilho
|
||||||
|
# `agendado`.
|
||||||
|
#
|
||||||
|
# Reusa OperacaoMetricas (visão Global) e NotasForaOperacao em vez de refazer as
|
||||||
|
# queries: se a regra de contagem mudar num lugar, o resumo enviado por WhatsApp
|
||||||
|
# não pode continuar dizendo outra coisa que o dashboard.
|
||||||
|
module Analytics
|
||||||
|
class ResumoOperacao
|
||||||
|
def initialize(inicio:, fim:)
|
||||||
|
@inicio = inicio.to_date
|
||||||
|
@fim = fim.to_date
|
||||||
|
end
|
||||||
|
|
||||||
|
# Valores como TEXTO: eles vão direto para dentro de uma mensagem.
|
||||||
|
def numeros
|
||||||
|
{
|
||||||
|
'operacao' => rotulo_operacao,
|
||||||
|
'entregues' => metricas.sucesso.to_s,
|
||||||
|
'recusas' => metricas.recusas.to_s,
|
||||||
|
'pendentes' => metricas.pendentes.to_s,
|
||||||
|
'retentativas' => metricas.retentativas.to_s,
|
||||||
|
'fora_operacao' => fora.total.to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Nada mudou no período? Evita mandar resumo zerado num feriado.
|
||||||
|
def vazio?
|
||||||
|
metricas.total.zero? && fora.total.zero?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def tabelas = @tabelas ||= Operacao.nomes_validos
|
||||||
|
|
||||||
|
def rotulo_operacao
|
||||||
|
tabelas.size == 1 ? Operacao.label(tabelas.first) : "Todas as operações (#{tabelas.size})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def metricas
|
||||||
|
@metricas ||= OperacaoMetricas.new(tabelas: tabelas, inicio: @inicio, fim: @fim)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fora
|
||||||
|
@fora ||= NotasForaOperacao.new(inicio: @inicio, fim: @fim)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
65
app/services/notificacao/blocos.rb
Normal file
65
app/services/notificacao/blocos.rb
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# app/services/notificacao/blocos.rb
|
||||||
|
#
|
||||||
|
# Catálogo dos blocos que o ADM pode empilhar no editor. É a ÚNICA fonte da
|
||||||
|
# verdade sobre quais tipos existem e quais campos cada um tem — o editor monta
|
||||||
|
# a paleta a partir daqui, o model valida a partir daqui e o renderizador
|
||||||
|
# desenha a partir daqui.
|
||||||
|
#
|
||||||
|
# Bloco desconhecido é DESCARTADO na gravação (ver MensagemTemplate): sem isso,
|
||||||
|
# um JSON adulterado no form viraria conteúdo renderizado sem passar por
|
||||||
|
# validação nenhuma.
|
||||||
|
module Notificacao
|
||||||
|
module Blocos
|
||||||
|
# tipo => { rotulo:, campos: { chave => tipo_do_campo } }
|
||||||
|
# tipo_do_campo: :texto (uma linha) | :multilinha | :lista (tabela)
|
||||||
|
CATALOGO = {
|
||||||
|
'cabecalho' => {
|
||||||
|
rotulo: 'Cabeçalho',
|
||||||
|
dica: 'Título da mensagem, em destaque.',
|
||||||
|
campos: { 'titulo' => :texto, 'subtitulo' => :texto }
|
||||||
|
},
|
||||||
|
'texto' => {
|
||||||
|
rotulo: 'Texto',
|
||||||
|
dica: 'Parágrafo livre. Use as variáveis para personalizar.',
|
||||||
|
campos: { 'texto' => :multilinha }
|
||||||
|
},
|
||||||
|
'tabela' => {
|
||||||
|
rotulo: 'Tabela de valores',
|
||||||
|
dica: 'Pares rótulo/valor — entregas, valor, período…',
|
||||||
|
campos: { 'linhas' => :lista }
|
||||||
|
},
|
||||||
|
'aviso' => {
|
||||||
|
rotulo: 'Aviso de mudança',
|
||||||
|
dica: 'Destaque em amarelo. Bom para "o que foi alterado".',
|
||||||
|
campos: { 'texto' => :multilinha }
|
||||||
|
},
|
||||||
|
'botao' => {
|
||||||
|
rotulo: 'Botão / link',
|
||||||
|
dica: 'No e-mail vira botão; no WhatsApp, o link em texto.',
|
||||||
|
campos: { 'rotulo' => :texto, 'url' => :texto }
|
||||||
|
},
|
||||||
|
'divisor' => {
|
||||||
|
rotulo: 'Divisor',
|
||||||
|
dica: 'Linha separando seções.',
|
||||||
|
campos: {}
|
||||||
|
},
|
||||||
|
'rodape' => {
|
||||||
|
rotulo: 'Rodapé',
|
||||||
|
dica: 'Texto pequeno no fim (assinatura, aviso legal).',
|
||||||
|
campos: { 'texto' => :multilinha }
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
TIPOS = CATALOGO.keys.freeze
|
||||||
|
|
||||||
|
def self.valido?(tipo) = TIPOS.include?(tipo.to_s)
|
||||||
|
def self.campos(tipo) = CATALOGO.dig(tipo.to_s, :campos) || {}
|
||||||
|
def self.rotulo(tipo) = CATALOGO.dig(tipo.to_s, :rotulo) || tipo.to_s
|
||||||
|
|
||||||
|
# Bloco novo com os campos vazios — usado pelo editor ao arrastar da paleta.
|
||||||
|
def self.vazio(tipo)
|
||||||
|
campos(tipo).to_h { |chave, especie| [chave, especie == :lista ? [] : ''] }
|
||||||
|
.merge('tipo' => tipo.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
98
app/services/notificacao/cliente_whatsapp.rb
Normal file
98
app/services/notificacao/cliente_whatsapp.rb
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# app/services/notificacao/cliente_whatsapp.rb
|
||||||
|
#
|
||||||
|
# Cliente da ponte Baileys (container `whatsapp`). Substitui o Twilio como canal
|
||||||
|
# padrão de WhatsApp.
|
||||||
|
#
|
||||||
|
# ⚠️ Canal NÃO OFICIAL: a ponte fala o protocolo do WhatsApp Web por engenharia
|
||||||
|
# reversa. É gratuito e ilimitado, mas está fora dos Termos do WhatsApp e a
|
||||||
|
# Meta pode banir o número. Por isso: chip dedicado, e o intervalo entre
|
||||||
|
# mensagens é respeitado por quem chama (Notificacao::Despachante), não aqui.
|
||||||
|
#
|
||||||
|
# TIMEOUT em tudo: sem ele, uma ponte pendurada trava o worker Puma até o proxy
|
||||||
|
# cortar — o mesmo motivo que levou o ClienteTwilio a configurar timeout.
|
||||||
|
module Notificacao
|
||||||
|
class ClienteWhatsapp
|
||||||
|
TIMEOUT_ABERTURA = 5
|
||||||
|
TIMEOUT_LEITURA = 20
|
||||||
|
|
||||||
|
Resposta = Struct.new(:ok, :dados, :erro, keyword_init: true) do
|
||||||
|
def ok? = ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.padrao
|
||||||
|
config = ConfiguracaoNotificacao.instancia
|
||||||
|
new(url: config.baileys_url_efetiva, token: config.baileys_token_efetivo)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(url:, token:)
|
||||||
|
@url = url.to_s.strip.chomp('/')
|
||||||
|
@token = token.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def configurado?
|
||||||
|
@url.present? && @token.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# { conectado:, numero:, qr:, desde:, ultimo_erro: }
|
||||||
|
def status
|
||||||
|
get('/status')
|
||||||
|
end
|
||||||
|
|
||||||
|
def enviar(para:, texto:)
|
||||||
|
post('/enviar', para: para, texto: texto)
|
||||||
|
end
|
||||||
|
|
||||||
|
def desconectar
|
||||||
|
post('/logout')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def get(caminho)
|
||||||
|
requisitar(Net::HTTP::Get, caminho)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(caminho, **corpo)
|
||||||
|
requisitar(Net::HTTP::Post, caminho, corpo)
|
||||||
|
end
|
||||||
|
|
||||||
|
def requisitar(classe, caminho, corpo = nil)
|
||||||
|
return Resposta.new(ok: false, erro: 'Ponte do WhatsApp não configurada.') unless configurado?
|
||||||
|
|
||||||
|
uri = URI.join("#{@url}/", caminho.delete_prefix('/'))
|
||||||
|
req = classe.new(uri)
|
||||||
|
req['Authorization'] = "Bearer #{@token}"
|
||||||
|
req['Content-Type'] = 'application/json'
|
||||||
|
req.body = corpo.to_json if corpo
|
||||||
|
|
||||||
|
resposta = Net::HTTP.start(uri.hostname, uri.port,
|
||||||
|
use_ssl: uri.scheme == 'https',
|
||||||
|
open_timeout: TIMEOUT_ABERTURA,
|
||||||
|
read_timeout: TIMEOUT_LEITURA) { |http| http.request(req) }
|
||||||
|
|
||||||
|
dados = parse(resposta.body)
|
||||||
|
if resposta.is_a?(Net::HTTPSuccess)
|
||||||
|
Resposta.new(ok: true, dados: dados)
|
||||||
|
else
|
||||||
|
Resposta.new(ok: false, dados: dados, erro: mensagem_de_erro(resposta, dados))
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
# A ponte fora do ar não pode virar 500 na tela nem quebrar um fechamento.
|
||||||
|
Rails.logger.error("[ClienteWhatsapp] #{e.class}: #{e.message}")
|
||||||
|
Resposta.new(ok: false, erro: "#{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse(corpo)
|
||||||
|
JSON.parse(corpo.to_s)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def mensagem_de_erro(resposta, dados)
|
||||||
|
return 'Token da ponte do WhatsApp inválido.' if resposta.code == '401'
|
||||||
|
return 'WhatsApp desconectado — leia o QR code na tela.' if resposta.code == '503'
|
||||||
|
|
||||||
|
dados['erro'].presence || "HTTP #{resposta.code}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
155
app/services/notificacao/despachante.rb
Normal file
155
app/services/notificacao/despachante.rb
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# app/services/notificacao/despachante.rb
|
||||||
|
#
|
||||||
|
# Entrega UMA mensagem já montada aos destinatários de um evento.
|
||||||
|
#
|
||||||
|
# Separação proposital: o Despachante resolve QUEM recebe, por qual canal,
|
||||||
|
# respeita o intervalo entre envios e registra o resultado. O TEXTO vem do
|
||||||
|
# template de blocos do evento (MensagemTemplate), renderizado por canal — HTML
|
||||||
|
# no e-mail, texto puro no WhatsApp, do mesmo template.
|
||||||
|
#
|
||||||
|
# `corpo:`/`assunto:` são o FALLBACK: valem quando o evento ainda não tem
|
||||||
|
# template montado (o texto padrão do código) ou quando a mensagem é digitada na
|
||||||
|
# hora (disparo manual). Sem esse fallback, ligar o editor apagaria as
|
||||||
|
# notificações dos eventos que já funcionavam.
|
||||||
|
#
|
||||||
|
# Destinatários = contatos dos grupos que assinam o evento
|
||||||
|
# + (opcional) a pessoa diretamente envolvida no fato, que é o
|
||||||
|
# comportamento que já existia: o motorista dono do pagamento.
|
||||||
|
#
|
||||||
|
# ⚠️ INTERVALO: no WhatsApp por sessão QR (canal não oficial), disparar em
|
||||||
|
# rajada para dezenas de números é a forma mais rápida de perder o número.
|
||||||
|
# Por isso o envio roda em job, com pausa configurável entre mensagens —
|
||||||
|
# nunca dentro da requisição web.
|
||||||
|
module Notificacao
|
||||||
|
class Despachante
|
||||||
|
# Dispara em background. Nunca levanta: uma notificação não pode derrubar o
|
||||||
|
# fechamento de um pagamento (mesma regra do NotificacaoService).
|
||||||
|
def self.disparar(chave:, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
|
||||||
|
evento = EventoNotificacao.ativos.find_by(chave: chave.to_s)
|
||||||
|
return if evento.nil?
|
||||||
|
|
||||||
|
NotificacaoJob.perform_later(evento.id, (dados || {}).stringify_keys,
|
||||||
|
assunto.to_s, corpo.to_s, envolvido&.id)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Despachante] falha ao agendar #{chave}: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(evento, dados: {}, assunto: nil, corpo: nil, envolvido: nil)
|
||||||
|
@evento = evento
|
||||||
|
@dados = (dados || {}).stringify_keys
|
||||||
|
@assunto = assunto.presence || evento.nome
|
||||||
|
@corpo = corpo.to_s
|
||||||
|
@envolvido = envolvido
|
||||||
|
@config = ConfiguracaoNotificacao.instancia
|
||||||
|
end
|
||||||
|
|
||||||
|
def executar
|
||||||
|
enviar_whatsapp if @config.whatsapp_habilitado?
|
||||||
|
enviar_email if @config.email_habilitado?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def enviar_whatsapp
|
||||||
|
intervalo = @config.intervalo_envio
|
||||||
|
|
||||||
|
destinos_whatsapp.each_with_index do |(destino, contato, user), i|
|
||||||
|
# Pausa ENTRE mensagens, não antes da primeira.
|
||||||
|
sleep(intervalo) if i.positive? && intervalo.positive?
|
||||||
|
|
||||||
|
corpo = corpo_whatsapp(contato, user)
|
||||||
|
envio = registrar(canal: 'whatsapp', destino: destino, contato: contato,
|
||||||
|
user: user, corpo: corpo)
|
||||||
|
resposta = Whatsapp.enviar(para: destino, texto: corpo, config: @config)
|
||||||
|
resposta.ok? ? envio.marcar_enviado! : envio.marcar_falha!(resposta.erro)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Corpo por canal ─────────────────────────────────────────
|
||||||
|
# A renderização acontece POR DESTINATÁRIO, não uma vez só: {{contato}} é o
|
||||||
|
# primeiro nome de quem recebe, então 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 em si.
|
||||||
|
def dados_para(contato, user)
|
||||||
|
nome = contato&.primeiro_nome.presence || user&.nome.to_s.split.first
|
||||||
|
@dados.merge('contato' => nome.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
# O template do editor vence; sem template utilizável, cai no texto que o
|
||||||
|
# chamador passou (texto padrão do código ou mensagem digitada na hora).
|
||||||
|
def corpo_whatsapp(contato, user)
|
||||||
|
template = @evento.template_utilizavel('whatsapp')
|
||||||
|
return @corpo unless template
|
||||||
|
|
||||||
|
template.renderizador(dados_para(contato, user)).texto.presence || @corpo
|
||||||
|
end
|
||||||
|
|
||||||
|
# [assunto, corpo, html?] — com template o e-mail sai em HTML montado; sem
|
||||||
|
# template, texto puro que o mailer formata.
|
||||||
|
def email_montado(contato, user)
|
||||||
|
template = @evento.template_utilizavel('email')
|
||||||
|
return [@assunto, @corpo, false] unless template
|
||||||
|
|
||||||
|
r = template.renderizador(dados_para(contato, user))
|
||||||
|
[r.interpolar(template.assunto).presence || @assunto, r.html, true]
|
||||||
|
end
|
||||||
|
|
||||||
|
def enviar_email
|
||||||
|
destinos_email.each do |destino, contato, user|
|
||||||
|
assunto, corpo, html = email_montado(contato, user)
|
||||||
|
envio = registrar(canal: 'email', destino: destino, contato: contato,
|
||||||
|
user: user, corpo: corpo, assunto: assunto)
|
||||||
|
begin
|
||||||
|
NotificacaoMailer.mensagem(destino, assunto, corpo, html: html).deliver_now
|
||||||
|
envio.marcar_enviado!
|
||||||
|
rescue StandardError => e
|
||||||
|
envio.marcar_falha!("#{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# [[destino, contato, user], ...] sem repetir o mesmo número/e-mail — a mesma
|
||||||
|
# pessoa pode ser contato de um grupo E o envolvido no fato.
|
||||||
|
def destinos_whatsapp
|
||||||
|
lista = @evento.destinatarios('whatsapp').map { |c| [c.telefone, c, c.user] }
|
||||||
|
lista += envolvido_whatsapp
|
||||||
|
dedup(lista)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destinos_email
|
||||||
|
lista = @evento.destinatarios('email').map { |c| [c.email, c, c.user] }
|
||||||
|
lista += envolvido_email
|
||||||
|
dedup(lista)
|
||||||
|
end
|
||||||
|
|
||||||
|
def envolvido_whatsapp
|
||||||
|
return [] unless notificar_envolvido?
|
||||||
|
|
||||||
|
numero = ConfiguracaoNotificacao.normalizar_telefone(@envolvido.telefone)
|
||||||
|
numero.present? ? [[numero, nil, @envolvido]] : []
|
||||||
|
end
|
||||||
|
|
||||||
|
def envolvido_email
|
||||||
|
return [] unless notificar_envolvido? && @envolvido.email.present?
|
||||||
|
|
||||||
|
[[@envolvido.email, nil, @envolvido]]
|
||||||
|
end
|
||||||
|
|
||||||
|
def notificar_envolvido?
|
||||||
|
@envolvido.present? && @evento.notificar_envolvido?
|
||||||
|
end
|
||||||
|
|
||||||
|
def dedup(lista)
|
||||||
|
lista.reject { |destino, _, _| destino.blank? }
|
||||||
|
.uniq { |destino, _, _| destino.to_s.downcase }
|
||||||
|
end
|
||||||
|
|
||||||
|
def registrar(canal:, destino:, contato:, user:, corpo:, assunto: nil)
|
||||||
|
NotificacaoEnvio.create!(
|
||||||
|
evento_notificacao: @evento, contato: contato, user: user,
|
||||||
|
canal: canal, destino: destino, assunto: assunto,
|
||||||
|
corpo: corpo, status: 'pendente'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
133
app/services/notificacao/gatilhos.rb
Normal file
133
app/services/notificacao/gatilhos.rb
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# app/services/notificacao/gatilhos.rb
|
||||||
|
#
|
||||||
|
# Os pontos do sistema que DISPARAM um evento de notificação. Cada método monta
|
||||||
|
# o contexto das {{variaveis}} daquele gatilho e entrega ao Despachante.
|
||||||
|
#
|
||||||
|
# Existe para que o código de negócio (consolidação, edição de lançamento, jobs)
|
||||||
|
# tenha UMA linha de chamada, sem saber de template, canal ou destinatário — e
|
||||||
|
# para que a lista de variáveis que o editor oferece (Notificacao::Variaveis)
|
||||||
|
# tenha um par exato aqui. Variável anunciada na tela e nunca preenchida sai como
|
||||||
|
# vazio na mensagem, o que é pior do que não existir.
|
||||||
|
#
|
||||||
|
# NADA aqui levanta: uma notificação não pode derrubar um fechamento nem uma
|
||||||
|
# edição de lançamento. O Despachante já engole, e os métodos que montam o
|
||||||
|
# contexto também.
|
||||||
|
module Notificacao
|
||||||
|
module Gatilhos
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# Valor do motorista mudou DEPOIS do fechamento (lançamento editado,
|
||||||
|
# desconto aplicado, pilar remarcado). Em rascunho o valor muda a cada
|
||||||
|
# clique do wizard — avisar ali seria spam, não informação.
|
||||||
|
def valor_alterado(consolidacao, consolidacao_motorista, anterior:)
|
||||||
|
return unless consolidacao.finalizada?
|
||||||
|
|
||||||
|
atual = consolidacao_motorista.valor_total.to_f
|
||||||
|
return if anterior.to_f == atual
|
||||||
|
|
||||||
|
Despachante.disparar(
|
||||||
|
chave: 'valor_alterado',
|
||||||
|
dados: Variaveis.comuns_reais.merge(
|
||||||
|
'motorista' => consolidacao_motorista.motorista_nome,
|
||||||
|
'consolidacao' => consolidacao.nome,
|
||||||
|
'periodo' => periodo(consolidacao.data_inicio, consolidacao.data_fim),
|
||||||
|
'valor' => moeda(atual),
|
||||||
|
'entregas' => contar_entregas(consolidacao, consolidacao_motorista).to_s,
|
||||||
|
'link_painel' => link_painel,
|
||||||
|
'o_que_mudou' => "Valor passou de #{moeda(anterior)} para #{moeda(atual)}"
|
||||||
|
),
|
||||||
|
assunto: "Valor alterado — #{consolidacao_motorista.motorista_nome}",
|
||||||
|
corpo: "⚠️ O valor de #{consolidacao_motorista.motorista_nome} em #{consolidacao.nome} " \
|
||||||
|
"passou de #{moeda(anterior)} para #{moeda(atual)}.",
|
||||||
|
# O próprio motorista é parte interessada: o valor DELE mudou depois de
|
||||||
|
# fechado. Fica sob o `notificar_envolvido` do evento, então o ADM pode
|
||||||
|
# desligar sem perder o aviso à diretoria.
|
||||||
|
envolvido: motorista_usuario(consolidacao_motorista)
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Gatilhos] valor_alterado: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dados da operação mudaram. Duas origens, os dois caminham para cá:
|
||||||
|
# - edição de lançamento no SimpliRoute feita por dentro do sistema;
|
||||||
|
# - varredura periódica que compara o espelho com o retrato anterior
|
||||||
|
# (DetectarMudancasOperacaoJob) — é por onde chega a maioria, já que o
|
||||||
|
# espelho sincroniza mudanças feitas fora daqui.
|
||||||
|
def operacao_alterada(operacao:, o_que_mudou:, nf: nil)
|
||||||
|
Despachante.disparar(
|
||||||
|
chave: 'operacao_alterada',
|
||||||
|
dados: Variaveis.comuns_reais.merge(
|
||||||
|
'operacao' => operacao.to_s,
|
||||||
|
'o_que_mudou' => o_que_mudou.to_s,
|
||||||
|
'nf' => nf.to_s
|
||||||
|
),
|
||||||
|
assunto: "Alteração na operação #{operacao}",
|
||||||
|
corpo: "🔄 #{operacao}: #{o_que_mudou}#{nf.present? ? " (NF #{nf})" : ''}"
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Gatilhos] operacao_alterada: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Resumo periódico de um evento `agendado`.
|
||||||
|
def resumo_agendado(evento, inicio:, fim:)
|
||||||
|
numeros = Analytics::ResumoOperacao.new(inicio: inicio, fim: fim).numeros
|
||||||
|
|
||||||
|
Despachante.disparar(
|
||||||
|
chave: evento.chave,
|
||||||
|
dados: Variaveis.comuns_reais.merge(numeros).merge(
|
||||||
|
'periodo' => periodo(inicio, fim)
|
||||||
|
),
|
||||||
|
assunto: "Resumo da operação — #{periodo(inicio, fim)}",
|
||||||
|
corpo: corpo_resumo(numeros, inicio, fim)
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[Gatilhos] resumo_agendado #{evento.chave}: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Auxiliares ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def corpo_resumo(numeros, inicio, fim)
|
||||||
|
"📊 Resumo da operação — #{periodo(inicio, fim)}\n" \
|
||||||
|
"• Entregues: #{numeros['entregues']}\n" \
|
||||||
|
"• Não entregues: #{numeros['recusas']}\n" \
|
||||||
|
"• Em aberto: #{numeros['pendentes']}\n" \
|
||||||
|
"• Retentativas: #{numeros['retentativas']}\n" \
|
||||||
|
"• Notas fora da operação: #{numeros['fora_operacao']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def periodo(inicio, fim)
|
||||||
|
"#{inicio&.strftime('%d/%m/%Y')} a #{fim&.strftime('%d/%m/%Y')}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def link_painel
|
||||||
|
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mesma busca do NotificacaoService (casa pelo nome, que é o que a
|
||||||
|
# consolidação guarda). Só roda em consolidação finalizada, então o custo
|
||||||
|
# por recálculo é irrelevante.
|
||||||
|
def motorista_usuario(consolidacao_motorista)
|
||||||
|
User.motorista.ativos.find_by('LOWER(nome) = ?', consolidacao_motorista.motorista_nome.to_s.downcase)
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def contar_entregas(consolidacao, consolidacao_motorista)
|
||||||
|
consolidacao.consolidacao_entregas
|
||||||
|
.where(motorista_nome: consolidacao_motorista.motorista_nome)
|
||||||
|
.where.not(tipo: :desconto)
|
||||||
|
.count
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Moeda BR com separador de milhar. Duplica o helper do NotificacaoService de
|
||||||
|
# propósito: este módulo é chamado de dentro de models e jobs, onde puxar o
|
||||||
|
# service inteiro só para formatar número seria acoplamento à toa.
|
||||||
|
def moeda(valor)
|
||||||
|
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||||
|
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||||
|
"R$ #{inteiro},#{decimais}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
159
app/services/notificacao/renderizador.rb
Normal file
159
app/services/notificacao/renderizador.rb
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# app/services/notificacao/renderizador.rb
|
||||||
|
#
|
||||||
|
# Blocos + dados => mensagem pronta. Duas saídas do MESMO template:
|
||||||
|
#
|
||||||
|
# #texto → WhatsApp (texto puro, com o *negrito* do WhatsApp)
|
||||||
|
# #html → e-mail (tabelas com estilo inline, que é o que cliente de e-mail
|
||||||
|
# renderiza de forma previsível)
|
||||||
|
#
|
||||||
|
# ⚠️ TODO texto vindo do editor e TODO valor de variável passa por escape no
|
||||||
|
# caminho HTML. O corpo é digitado numa tela e viraria injeção de HTML no
|
||||||
|
# e-mail — e o preview usa este mesmo renderizador, então um XSS aqui
|
||||||
|
# atingiria primeiro o próprio admin.
|
||||||
|
#
|
||||||
|
# O preview da tela chama exatamente estes métodos: uma segunda implementação em
|
||||||
|
# JavaScript inevitavelmente divergiria do que é enviado de verdade.
|
||||||
|
module Notificacao
|
||||||
|
class Renderizador
|
||||||
|
# Cores fixas: cliente de e-mail não lê CSS externo nem variável de tema.
|
||||||
|
LARANJA = '#f97316'.freeze
|
||||||
|
ESCURO = '#111111'.freeze
|
||||||
|
CINZA = '#666666'.freeze
|
||||||
|
|
||||||
|
def initialize(blocos, dados = {})
|
||||||
|
@blocos = Array(blocos).select { |b| b.is_a?(Hash) && Blocos.valido?(b['tipo']) }
|
||||||
|
@dados = (dados || {}).transform_keys(&:to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── WhatsApp ────────────────────────────────────────────────
|
||||||
|
def texto
|
||||||
|
@blocos.filter_map { |bloco| texto_do(bloco) }.join("\n\n").strip
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── E-mail ──────────────────────────────────────────────────
|
||||||
|
def html
|
||||||
|
corpo = @blocos.filter_map { |bloco| html_do(bloco) }.join("\n")
|
||||||
|
<<~HTML
|
||||||
|
<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#{ESCURO};line-height:1.6;max-width:600px;">
|
||||||
|
#{corpo}
|
||||||
|
</div>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
# Substitui {{variavel}} pelos dados. Variável sem valor vira string vazia —
|
||||||
|
# deixar "{{valor}}" cru numa mensagem enviada é pior do que deixar o espaço.
|
||||||
|
def interpolar(texto)
|
||||||
|
texto.to_s.gsub(/\{\{\s*(\w+)\s*\}\}/) { @dados[Regexp.last_match(1)].to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def escapar(valor) = ERB::Util.html_escape(interpolar(valor))
|
||||||
|
|
||||||
|
def texto_do(bloco)
|
||||||
|
case bloco['tipo']
|
||||||
|
when 'cabecalho'
|
||||||
|
[negrito(bloco['titulo']), interpolar(bloco['subtitulo'])].reject(&:blank?).join("\n").presence
|
||||||
|
when 'texto', 'rodape'
|
||||||
|
interpolar(bloco['texto']).presence
|
||||||
|
when 'aviso'
|
||||||
|
conteudo = interpolar(bloco['texto'])
|
||||||
|
conteudo.present? ? "⚠️ #{conteudo}" : nil
|
||||||
|
when 'tabela'
|
||||||
|
linhas = Array(bloco['linhas']).filter_map do |linha|
|
||||||
|
next unless linha.is_a?(Hash)
|
||||||
|
rotulo = interpolar(linha['rotulo'])
|
||||||
|
valor = interpolar(linha['valor'])
|
||||||
|
next if rotulo.blank? && valor.blank?
|
||||||
|
"• #{rotulo}: #{valor}".strip
|
||||||
|
end
|
||||||
|
linhas.any? ? linhas.join("\n") : nil
|
||||||
|
when 'botao'
|
||||||
|
url = interpolar(bloco['url'])
|
||||||
|
return nil if url.blank?
|
||||||
|
[interpolar(bloco['rotulo']).presence, url].compact.join(': ')
|
||||||
|
when 'divisor'
|
||||||
|
'——————————'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def negrito(valor)
|
||||||
|
conteudo = interpolar(valor)
|
||||||
|
conteudo.present? ? "*#{conteudo}*" : ''
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_do(bloco)
|
||||||
|
case bloco['tipo']
|
||||||
|
when 'cabecalho' then html_cabecalho(bloco)
|
||||||
|
when 'texto' then html_paragrafo(bloco['texto'])
|
||||||
|
when 'aviso' then html_aviso(bloco)
|
||||||
|
when 'tabela' then html_tabela(bloco)
|
||||||
|
when 'botao' then html_botao(bloco)
|
||||||
|
when 'divisor' then %(<hr style="border:none;border-top:1px solid #e5e5e5;margin:20px 0;">)
|
||||||
|
when 'rodape' then html_rodape(bloco)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_cabecalho(bloco)
|
||||||
|
titulo = escapar(bloco['titulo'])
|
||||||
|
sub = escapar(bloco['subtitulo'])
|
||||||
|
return nil if titulo.blank? && sub.blank?
|
||||||
|
|
||||||
|
partes = []
|
||||||
|
partes << %(<h1 style="margin:0 0 4px;font-size:20px;color:#{ESCURO};">#{titulo}</h1>) if titulo.present?
|
||||||
|
partes << %(<p style="margin:0 0 16px;font-size:14px;color:#{CINZA};">#{sub}</p>) if sub.present?
|
||||||
|
partes.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
# `simple_format` não serve aqui: ele produz HTML sem os estilos inline que o
|
||||||
|
# cliente de e-mail precisa. As quebras de linha viram <br>, já escapadas.
|
||||||
|
def html_paragrafo(valor)
|
||||||
|
conteudo = escapar(valor)
|
||||||
|
return nil if conteudo.blank?
|
||||||
|
|
||||||
|
%(<p style="margin:0 0 14px;">#{conteudo.gsub("\n", '<br>')}</p>)
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_aviso(bloco)
|
||||||
|
conteudo = escapar(bloco['texto'])
|
||||||
|
return nil if conteudo.blank?
|
||||||
|
|
||||||
|
%(<div style="margin:0 0 16px;padding:12px 14px;background:#fff8e1;border-left:4px solid #f0ad4e;) +
|
||||||
|
%(font-size:14px;color:#5c4400;">#{conteudo.gsub("\n", '<br>')}</div>)
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_tabela(bloco)
|
||||||
|
linhas = Array(bloco['linhas']).filter_map do |linha|
|
||||||
|
next unless linha.is_a?(Hash)
|
||||||
|
rotulo = escapar(linha['rotulo'])
|
||||||
|
valor = escapar(linha['valor'])
|
||||||
|
next if rotulo.blank? && valor.blank?
|
||||||
|
|
||||||
|
%(<tr><td style="padding:8px 12px;border-bottom:1px solid #eee;color:#{CINZA};">#{rotulo}</td>) +
|
||||||
|
%(<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right;font-weight:bold;">#{valor}</td></tr>)
|
||||||
|
end
|
||||||
|
return nil if linhas.empty?
|
||||||
|
|
||||||
|
%(<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:0 0 16px;font-size:14px;">) +
|
||||||
|
linhas.join + '</table>'
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_botao(bloco)
|
||||||
|
url = interpolar(bloco['url']).to_s.strip
|
||||||
|
# Só http(s): `javascript:` num href montado na tela seria clique armado.
|
||||||
|
return nil unless url.match?(%r{\Ahttps?://}i)
|
||||||
|
|
||||||
|
rotulo = escapar(bloco['rotulo']).presence || 'Abrir'
|
||||||
|
%(<p style="margin:0 0 18px;"><a href="#{ERB::Util.html_escape(url)}" ) +
|
||||||
|
%(style="display:inline-block;padding:11px 20px;background:#{LARANJA};color:#000;) +
|
||||||
|
%(text-decoration:none;border-radius:8px;font-weight:bold;font-size:14px;">#{rotulo}</a></p>)
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_rodape(bloco)
|
||||||
|
conteudo = escapar(bloco['texto'])
|
||||||
|
return nil if conteudo.blank?
|
||||||
|
|
||||||
|
%(<p style="margin:24px 0 0;font-size:12px;color:#999;">#{conteudo.gsub("\n", '<br>')}</p>)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
78
app/services/notificacao/variaveis.rb
Normal file
78
app/services/notificacao/variaveis.rb
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# app/services/notificacao/variaveis.rb
|
||||||
|
#
|
||||||
|
# Quais {{variaveis}} existem em cada gatilho, o que elas significam e um valor
|
||||||
|
# de AMOSTRA para o preview do editor.
|
||||||
|
#
|
||||||
|
# A amostra é o que faz o preview valer alguma coisa: mostrar "{{valor}}" cru na
|
||||||
|
# tela não diz se a mensagem ficou boa. Aqui ela é um exemplo plausível, não um
|
||||||
|
# dado real do banco — o preview não deve depender de haver consolidação
|
||||||
|
# fechada no ambiente para funcionar.
|
||||||
|
module Notificacao
|
||||||
|
module Variaveis
|
||||||
|
# Disponíveis em qualquer evento.
|
||||||
|
COMUNS = {
|
||||||
|
'empresa' => { descricao: 'Nome da empresa', amostra: 'Reem Transportes' },
|
||||||
|
'data' => { descricao: 'Data de hoje', amostra: '24/08/2026' },
|
||||||
|
'contato' => { descricao: 'Primeiro nome de quem recebe', amostra: 'Carlos' }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Gatilhos que falam de um fechamento/pagamento de motorista.
|
||||||
|
FECHAMENTO = {
|
||||||
|
'motorista' => { descricao: 'Nome do motorista', amostra: 'Carlos Matheus Pimentel' },
|
||||||
|
'consolidacao' => { descricao: 'Nome da consolidação', amostra: 'UBS NORTE 01–14/08' },
|
||||||
|
'periodo' => { descricao: 'Período do fechamento', amostra: '01/08/2026 a 14/08/2026' },
|
||||||
|
'valor' => { descricao: 'Valor do motorista', amostra: 'R$ 5.060,00' },
|
||||||
|
'entregas' => { descricao: 'Entregas atendidas', amostra: '280' },
|
||||||
|
'link_painel' => { descricao: 'Link do painel do motorista', amostra: 'https://app.reem.com.br/motorista' }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
POR_GATILHO = {
|
||||||
|
'manual' => {},
|
||||||
|
'consolidacao_finalizada' => FECHAMENTO,
|
||||||
|
'pagamento_efetuado' => FECHAMENTO,
|
||||||
|
'valor_alterado' => FECHAMENTO.merge(
|
||||||
|
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: 'Desconto de R$ 120,00 aplicado (avaria)' }
|
||||||
|
),
|
||||||
|
'operacao_alterada' => {
|
||||||
|
'operacao' => { descricao: 'Operação afetada', amostra: 'UBS NORTE AGO 2026' },
|
||||||
|
'o_que_mudou' => { descricao: 'O que foi alterado', amostra: '3 NFs entraram, 1 saiu' },
|
||||||
|
'nf' => { descricao: 'Nota fiscal', amostra: '85382' }
|
||||||
|
},
|
||||||
|
'agendado' => {
|
||||||
|
'operacao' => { descricao: 'Operação', amostra: 'UBS NORTE AGO 2026' },
|
||||||
|
'periodo' => { descricao: 'Período do resumo', amostra: '01/08/2026 a 24/08/2026' },
|
||||||
|
'entregues' => { descricao: 'Notas entregues', amostra: '4.851' },
|
||||||
|
'recusas' => { descricao: 'Notas não entregues', amostra: '122' },
|
||||||
|
'pendentes' => { descricao: 'Notas em aberto', amostra: '0' },
|
||||||
|
'retentativas' => { descricao: 'Visitas além da primeira', amostra: '4' },
|
||||||
|
'fora_operacao' => { descricao: 'Notas fora da operação', amostra: '2' }
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.para(gatilho)
|
||||||
|
COMUNS.merge(POR_GATILHO.fetch(gatilho.to_s, {}))
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.nomes(gatilho) = para(gatilho).keys
|
||||||
|
|
||||||
|
# { 'valor' => 'R$ 5.060,00', ... } — o contexto do PREVIEW.
|
||||||
|
#
|
||||||
|
# ⚠️ Só para preview. Num envio de verdade a amostra colocaria uma data fixa
|
||||||
|
# e um valor inventado dentro da mensagem que o contato recebe.
|
||||||
|
def self.amostra(gatilho)
|
||||||
|
para(gatilho).transform_values { |meta| meta[:amostra] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Valores REAIS das variáveis comuns — o que um envio de verdade deve usar
|
||||||
|
# quando o gatilho não traz contexto próprio (ex.: disparo manual).
|
||||||
|
def self.comuns_reais
|
||||||
|
{
|
||||||
|
'empresa' => Configuracao.valor('empresa_nome').presence || 'Reem Transportes',
|
||||||
|
'data' => Date.current.strftime('%d/%m/%Y')
|
||||||
|
}
|
||||||
|
rescue StandardError
|
||||||
|
# Tabela de configuração indisponível não pode impedir um disparo.
|
||||||
|
{ 'data' => Date.current.strftime('%d/%m/%Y') }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
41
app/services/notificacao/whatsapp.rb
Normal file
41
app/services/notificacao/whatsapp.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# app/services/notificacao/whatsapp.rb
|
||||||
|
#
|
||||||
|
# Ponto ÚNICO de envio de WhatsApp: escolhe o provedor configurado e devolve
|
||||||
|
# sempre a mesma resposta, venha de onde vier.
|
||||||
|
#
|
||||||
|
# Existe para que trocar Twilio ↔ Baileys seja um campo na tela, e não um `if`
|
||||||
|
# espalhado por cada chamador. O NotificacaoService (aviso ao motorista) e o
|
||||||
|
# Despachante (grupos) passam os dois por aqui.
|
||||||
|
module Notificacao
|
||||||
|
module Whatsapp
|
||||||
|
Resposta = ClienteWhatsapp::Resposta
|
||||||
|
|
||||||
|
def self.enviar(para:, texto:, config: ConfiguracaoNotificacao.instancia)
|
||||||
|
destino = ConfiguracaoNotificacao.normalizar_telefone(para)
|
||||||
|
return Resposta.new(ok: false, erro: 'Número inválido.') if destino.blank?
|
||||||
|
|
||||||
|
config.baileys? ? via_baileys(destino, texto) : via_twilio(destino, texto, config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.via_baileys(destino, texto)
|
||||||
|
ClienteWhatsapp.padrao.enviar(para: destino, texto: texto)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.via_twilio(destino, texto, config)
|
||||||
|
credenciais = config.credenciais_whatsapp
|
||||||
|
if credenciais.blank? || credenciais[:from].blank?
|
||||||
|
return Resposta.new(ok: false, erro: 'Twilio sem credenciais configuradas.')
|
||||||
|
end
|
||||||
|
|
||||||
|
ClienteTwilio.montar(credenciais[:sid], credenciais[:token])
|
||||||
|
.messages.create(from: credenciais[:from],
|
||||||
|
to: ConfiguracaoNotificacao.canal(destino),
|
||||||
|
body: texto)
|
||||||
|
Resposta.new(ok: true)
|
||||||
|
rescue StandardError => e
|
||||||
|
# Falha de envio nunca pode subir: quem chama está no meio de um
|
||||||
|
# fechamento de pagamento.
|
||||||
|
Resposta.new(ok: false, erro: "#{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
class NotificacaoService
|
class NotificacaoService
|
||||||
EVENTOS = {
|
EVENTOS = {
|
||||||
fechado: { mailer: :pagamento_fechado,
|
fechado: { mailer: :pagamento_fechado,
|
||||||
|
chave: 'consolidacao_finalizada',
|
||||||
emoji: '🚚',
|
emoji: '🚚',
|
||||||
titulo: 'Seu pagamento de entregas foi fechado.',
|
titulo: 'Seu pagamento de entregas foi fechado.',
|
||||||
cta: 'Acesse seu painel para baixar o extrato:' },
|
cta: 'Acesse seu painel para baixar o extrato:' },
|
||||||
pago: { mailer: :pagamento_efetuado,
|
pago: { mailer: :pagamento_efetuado,
|
||||||
|
chave: 'pagamento_efetuado',
|
||||||
emoji: '✅',
|
emoji: '✅',
|
||||||
titulo: 'Seu pagamento foi efetuado.',
|
titulo: 'Seu pagamento foi efetuado.',
|
||||||
cta: 'Acesse seu painel para conferir:' }
|
cta: 'Acesse seu painel para conferir:' }
|
||||||
@@ -48,10 +50,70 @@ class NotificacaoService
|
|||||||
|
|
||||||
def notificar(cm, evento)
|
def notificar(cm, evento)
|
||||||
user = motorista_de(cm)
|
user = motorista_de(cm)
|
||||||
return unless user
|
|
||||||
|
|
||||||
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
|
# Aviso pessoal ao motorista: continua com o e-mail formatado do
|
||||||
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
|
# ConsolidacaoMailer (não vira texto puro) e passa a usar o provedor de
|
||||||
|
# WhatsApp escolhido na tela — Baileys por QR ou Twilio.
|
||||||
|
if user
|
||||||
|
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
|
||||||
|
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cópia para os grupos que assinam este evento (diretoria, operação...).
|
||||||
|
# `envolvido: nil` de propósito: o motorista já foi avisado acima e receberia
|
||||||
|
# a mensagem duas vezes.
|
||||||
|
avisar_grupos(cm, evento, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# O corpo aqui é o mesmo texto do WhatsApp — na etapa do editor de blocos ele
|
||||||
|
# passa a vir do template configurado pelo ADM.
|
||||||
|
def avisar_grupos(cm, evento, user)
|
||||||
|
evt = EVENTOS.fetch(evento)
|
||||||
|
Notificacao::Despachante.disparar(
|
||||||
|
chave: evt[:chave],
|
||||||
|
dados: variaveis(cm, user),
|
||||||
|
assunto: "#{evt[:titulo]} — #{@consolidacao.nome}",
|
||||||
|
corpo: mensagem_para_grupos(cm, evento, user)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Contexto das {{variaveis}} do editor de blocos. As chaves são as mesmas de
|
||||||
|
# Notificacao::Variaveis::FECHAMENTO — é o que o ADM vê na paleta da tela.
|
||||||
|
def variaveis(cm, user)
|
||||||
|
Notificacao::Variaveis.comuns_reais.merge(
|
||||||
|
'motorista' => user&.nome.presence || cm.motorista_nome,
|
||||||
|
'consolidacao' => @consolidacao.nome,
|
||||||
|
'periodo' => "#{l_data(@consolidacao.data_inicio)} a #{l_data(@consolidacao.data_fim)}",
|
||||||
|
'valor' => moeda(cm.valor_total),
|
||||||
|
# ConsolidacaoMotorista não guarda contagem — só valor_total. As entregas
|
||||||
|
# do motorista são as linhas classificadas na consolidação (mesma fonte do
|
||||||
|
# #recalcular_valor), descontando os lançamentos de desconto, que não são
|
||||||
|
# entregas.
|
||||||
|
'entregas' => entregas_do_motorista(cm).to_s,
|
||||||
|
'link_painel' => "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def l_data(data)
|
||||||
|
data&.strftime('%d/%m/%Y').to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def entregas_do_motorista(cm)
|
||||||
|
@consolidacao.consolidacao_entregas
|
||||||
|
.where(motorista_nome: cm.motorista_nome)
|
||||||
|
.where.not(tipo: :desconto)
|
||||||
|
.count
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[Notificacao] contagem de entregas indisponível: #{e.class}: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def mensagem_para_grupos(cm, evento, user)
|
||||||
|
evt = EVENTOS.fetch(evento)
|
||||||
|
"#{evt[:emoji]} #{evt[:titulo]}\n" \
|
||||||
|
"👤 #{user&.nome || cm.motorista_nome}\n" \
|
||||||
|
"📋 #{@consolidacao.nome}\n" \
|
||||||
|
"💰 Valor: #{moeda(cm.valor_total)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def motorista_de(cm)
|
def motorista_de(cm)
|
||||||
@@ -59,34 +121,57 @@ class NotificacaoService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def enviar_whatsapp(user, cm, evento)
|
def enviar_whatsapp(user, cm, evento)
|
||||||
# O telefone no cadastro pode estar como "(11) 92005-1157"; mandar esse
|
# O telefone no cadastro pode estar como "(11) 92005-1157"; a normalização
|
||||||
# texto cru para o Twilio devolve erro 21211 (destino inválido), que o
|
# para E.164 acontece dentro de Notificacao::Whatsapp — mandar o texto cru
|
||||||
# rescue abaixo esconderia. Normalizar para E.164 é o que faz o envio
|
# devolve erro de destino inválido que o rescue esconderia.
|
||||||
# realmente funcionar.
|
destino = ConfiguracaoNotificacao.normalizar_telefone(user.telefone)
|
||||||
destino = ConfiguracaoNotificacao.canal(user.telefone)
|
|
||||||
return if destino.blank?
|
return if destino.blank?
|
||||||
|
|
||||||
credenciais = @config.credenciais_whatsapp
|
texto = mensagem(user, cm, evento)
|
||||||
return if credenciais.blank? || credenciais[:from].blank?
|
envio = registrar_envio(evento, canal: 'whatsapp', destino: destino, user: user, corpo: texto)
|
||||||
|
|
||||||
Notificacao::ClienteTwilio
|
resposta = Notificacao::Whatsapp.enviar(para: destino, texto: texto, config: @config)
|
||||||
.montar(credenciais[:sid], credenciais[:token])
|
if resposta.ok?
|
||||||
.messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento))
|
envio&.marcar_enviado!
|
||||||
|
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
|
||||||
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
|
else
|
||||||
rescue StandardError => e
|
envio&.marcar_falha!(resposta.erro)
|
||||||
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}")
|
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{resposta.erro}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def enviar_email(user, cm, evento)
|
def enviar_email(user, cm, evento)
|
||||||
ConsolidacaoMailer
|
evt = EVENTOS.fetch(evento)
|
||||||
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
|
envio = registrar_envio(evento, canal: 'email', destino: user.email, user: user,
|
||||||
.deliver_later
|
corpo: mensagem(user, cm, evento), assunto: evt[:titulo])
|
||||||
|
|
||||||
|
ConsolidacaoMailer.public_send(evt[:mailer], user, @consolidacao, cm).deliver_later
|
||||||
|
# `deliver_later` só ENFILEIRA: marcar como enviado aqui significa "saiu do
|
||||||
|
# nosso lado". Uma falha do SMTP aparece no log do Rails, não aqui.
|
||||||
|
envio&.marcar_enviado!
|
||||||
Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}")
|
Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}")
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
envio&.marcar_falha!("#{e.class}: #{e.message}")
|
||||||
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
|
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Log do aviso pessoal. Nunca levanta: se a tabela ainda não existir (deploy
|
||||||
|
# antes do migrate), o envio precisa seguir mesmo sem registro.
|
||||||
|
def registrar_envio(evento, canal:, destino:, user:, corpo:, assunto: nil)
|
||||||
|
NotificacaoEnvio.create!(
|
||||||
|
evento_notificacao: evento_registrado(evento), user: user, canal: canal,
|
||||||
|
destino: destino, assunto: assunto, corpo: corpo, status: 'pendente'
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[Notificacao] sem log de envio (#{e.class}: #{e.message})")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def evento_registrado(evento)
|
||||||
|
@eventos_cache ||= {}
|
||||||
|
@eventos_cache[evento] ||= EventoNotificacao.find_by(chave: EVENTOS.fetch(evento)[:chave])
|
||||||
|
end
|
||||||
|
|
||||||
def mensagem(user, cm, evento)
|
def mensagem(user, cm, evento)
|
||||||
evt = EVENTOS.fetch(evento)
|
evt = EVENTOS.fetch(evento)
|
||||||
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \
|
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \
|
||||||
|
|||||||
52
app/views/admin/contatos/_form.html.erb
Normal file
52
app/views/admin/contatos/_form.html.erb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<%= form_with model: [:admin, contato], data: { turbo: false }, class: 'space-y-5' do |f| %>
|
||||||
|
<% if contato.errors.any? %>
|
||||||
|
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<% contato.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :nome, required: true,
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= f.label :telefone, 'WhatsApp', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :telefone, placeholder: '(11) 92005-1157',
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Pode digitar com máscara — é normalizado ao salvar.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :email, 'E-mail', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.email_field :email,
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :grupo_contato_id, 'Grupo', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.collection_select :grupo_contato_id, @grupos, :id, :nome, { include_blank: 'Sem grupo (não recebe nada)' },
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">É o grupo que define quais eventos este contato recebe.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :observacao, 'Observação', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :observacao,
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<%= f.check_box :ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||||
|
Ativo
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||||
|
<%= link_to 'Cancelar', admin_contatos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
4
app/views/admin/contatos/edit.html.erb
Normal file
4
app/views/admin/contatos/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Editar contato</h1>
|
||||||
|
<%= render "form", contato: @contato %>
|
||||||
|
</div>
|
||||||
74
app/views/admin/contatos/index.html.erb
Normal file
74
app/views/admin/contatos/index.html.erb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Contatos</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-0.5">
|
||||||
|
Quem recebe as mensagens. Quem tem só número recebe só WhatsApp; quem tem só
|
||||||
|
e-mail recebe só e-mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= link_to 'Novo contato', new_admin_contato_path(grupo_id: params[:grupo_id]), data: { turbo: false },
|
||||||
|
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="<%= admin_contatos_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="text" name="q" value="<%= params[:q] %>" placeholder="Buscar por nome, número ou e-mail"
|
||||||
|
class="bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[260px]">
|
||||||
|
<select name="grupo_id" class="bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500">
|
||||||
|
<option value="">Todos os grupos</option>
|
||||||
|
<% @grupos.each do |grupo| %>
|
||||||
|
<option value="<%= grupo.id %>" <%= 'selected' if params[:grupo_id].to_s == grupo.id.to_s %>><%= grupo.nome %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500">Filtrar</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||||
|
<% if @contatos.empty? %>
|
||||||
|
<p class="px-6 py-10 text-center text-gray-500 text-sm">Nenhum contato encontrado.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Nome</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">WhatsApp</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">E-mail</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Grupo</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Situação</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5">
|
||||||
|
<% @contatos.each do |contato| %>
|
||||||
|
<tr class="hover:bg-white/5">
|
||||||
|
<td class="px-6 py-2.5 text-white"><%= contato.nome %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300 font-mono"><%= contato.telefone.presence || '—' %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300"><%= contato.email.presence || '—' %></td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<% if contato.grupo_contato %>
|
||||||
|
<span class="text-gray-300"><%= contato.grupo_contato.nome %></span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-amber-400" title="Sem grupo não recebe nada">sem grupo</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="<%= contato.ativo? ? 'text-green-400' : 'text-gray-500' %>">
|
||||||
|
<%= contato.ativo? ? 'Ativo' : 'Inativo' %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||||
|
<%= link_to 'Editar', edit_admin_contato_path(contato), data: { turbo: false },
|
||||||
|
class: 'text-gray-300 hover:text-orange-400 mr-3' %>
|
||||||
|
<%= button_to 'Excluir', admin_contato_path(contato), method: :delete,
|
||||||
|
form: { data: { turbo_confirm: "Excluir #{contato.nome}?" } },
|
||||||
|
class: 'text-red-400 hover:text-red-300 inline' %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/admin/contatos/new.html.erb
Normal file
4
app/views/admin/contatos/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Novo contato</h1>
|
||||||
|
<%= render "form", contato: @contato %>
|
||||||
|
</div>
|
||||||
102
app/views/admin/eventos_notificacao/_form.html.erb
Normal file
102
app/views/admin/eventos_notificacao/_form.html.erb
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<%= form_with model: [:admin, evento], url: (evento.persisted? ? admin_evento_path(evento) : admin_eventos_path),
|
||||||
|
data: { turbo: false }, class: 'space-y-5' do |f| %>
|
||||||
|
<% if evento.errors.any? %>
|
||||||
|
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<% evento.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :nome, required: true, placeholder: 'Aviso de fechamento para a diretoria',
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :gatilho, 'Gatilho (o que faz disparar)', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<% if evento.sistema? %>
|
||||||
|
<p class="text-white text-sm bg-[#141414] border border-white/10 rounded-xl px-3 py-2.5"><%= evento.gatilho_label %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Evento de sistema: o gatilho e a chave são fixos porque o código dispara por eles.</p>
|
||||||
|
<% else %>
|
||||||
|
<%= f.select :gatilho, EventoNotificacao::GATILHOS.map { |g| [EventoNotificacao::GATILHO_LABEL[g], g] }, {},
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :descricao, 'Descrição', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :descricao,
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Só faz sentido no gatilho `agendado`; deixo sempre visível para não
|
||||||
|
depender de JS, com a explicação de quando vale. %>
|
||||||
|
<fieldset class="border border-white/10 rounded-xl p-4 space-y-3">
|
||||||
|
<legend class="text-gray-400 text-sm px-2">Agendamento (só vale no gatilho "Agendado")</legend>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<%= f.label :frequencia, 'Frequência', class: 'block text-gray-500 text-xs mb-1' %>
|
||||||
|
<%= f.select :frequencia, EventoNotificacao::FREQUENCIAS.map { |x| [x.capitalize, x] }, { include_blank: '—' },
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :hora, 'Hora', class: 'block text-gray-500 text-xs mb-1' %>
|
||||||
|
<%= f.number_field :hora, min: 0, max: 23,
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :dia_semana, 'Dia (semanal)', class: 'block text-gray-500 text-xs mb-1' %>
|
||||||
|
<%= f.select :dia_semana, Date::DAYNAMES.each_with_index.map { |nome, i| [nome, i] }, { include_blank: '—' },
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-2 text-sm text-gray-300">
|
||||||
|
<%= f.check_box :notificar_envolvido, class: 'mt-1 rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||||
|
<span>
|
||||||
|
Avisar também a pessoa envolvida no fato
|
||||||
|
<span class="block text-xs text-gray-500">Ex.: o motorista dono do pagamento, além dos grupos. Só se aplica aos gatilhos de consolidação/pagamento/valor.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<%= f.check_box :ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||||
|
Ativo
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<%# Assinatura por grupo: TODOS os grupos ativos aparecem, marcados os que já
|
||||||
|
assinam. Sem isso a tela só mostraria os já configurados e não haveria por
|
||||||
|
onde adicionar. %>
|
||||||
|
<fieldset class="border border-white/10 rounded-xl p-4 space-y-3">
|
||||||
|
<legend class="text-gray-400 text-sm px-2">Quais grupos recebem</legend>
|
||||||
|
<% if @linhas_assinatura.blank? %>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Nenhum grupo cadastrado ainda —
|
||||||
|
<%= link_to 'crie um grupo', new_admin_grupo_path, data: { turbo: false }, class: 'text-orange-400 underline' %>
|
||||||
|
antes.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<% Array(@linhas_assinatura).each do |linha| %>
|
||||||
|
<% grupo = linha[:grupo]; assinatura = linha[:assinatura] %>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300 min-w-[200px]">
|
||||||
|
<%= check_box_tag "assinaturas[#{grupo.id}][ativo]", '1', assinatura&.ativo,
|
||||||
|
class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||||
|
<%= grupo.nome %>
|
||||||
|
<span class="text-xs text-gray-500">(<%= grupo.contatos.size %>)</span>
|
||||||
|
</label>
|
||||||
|
<%= select_tag "assinaturas[#{grupo.id}][canal]",
|
||||||
|
options_for_select(GrupoEventoAssinatura::CANAIS.map { |c| [GrupoEventoAssinatura::CANAL_LABEL[c], c] }, assinatura&.canal || 'ambos'),
|
||||||
|
class: 'bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||||
|
<%= link_to 'Cancelar', admin_eventos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
4
app/views/admin/eventos_notificacao/edit.html.erb
Normal file
4
app/views/admin/eventos_notificacao/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Editar evento</h1>
|
||||||
|
<%= render "form", evento: @evento %>
|
||||||
|
</div>
|
||||||
90
app/views/admin/eventos_notificacao/index.html.erb
Normal file
90
app/views/admin/eventos_notificacao/index.html.erb
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Eventos de notificação</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-0.5">
|
||||||
|
O que dispara mensagem e para quais grupos. Você cria quantos eventos quiser —
|
||||||
|
o <strong class="text-gray-300">gatilho</strong> sai de uma lista fixa, porque gatilho é código.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= link_to 'Novo evento', new_admin_evento_path, data: { turbo: false },
|
||||||
|
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% @eventos.each do |evento| %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-white font-bold flex items-center gap-2 flex-wrap">
|
||||||
|
<%= evento.nome %>
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-400"><%= evento.gatilho_label %></span>
|
||||||
|
<% if evento.sistema? %>
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-blue-500/15 border border-blue-500/30 text-xs text-blue-300" title="Nasce com o sistema: pode desativar, não apagar">sistema</span>
|
||||||
|
<% end %>
|
||||||
|
<% unless evento.ativo? %>
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-white/5 border border-white/10 text-xs text-gray-500">desativado</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1"><%= evento.descricao.presence || '—' %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
<% assinantes = evento.assinaturas.select(&:ativo) %>
|
||||||
|
<% if assinantes.any? %>
|
||||||
|
Grupos: <%= assinantes.map { |a| "#{a.grupo_contato.nome} (#{a.canal_label})" }.join(' · ') %>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-amber-400">Nenhum grupo assina — este evento não manda nada.</span>
|
||||||
|
<% end %>
|
||||||
|
<% if evento.agendado? %>
|
||||||
|
· <%= evento.frequencia == 'semanal' ? "toda #{Date::DAYNAMES[evento.dia_semana.to_i]}" : 'todo dia' %> às <%= format('%02d:00', evento.hora.to_i) %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
<%# Editor de blocos, um por canal. O ✓ diz se já existe mensagem
|
||||||
|
montada — sem ela o evento usa o texto padrão do sistema. %>
|
||||||
|
<% %w[whatsapp email].each do |canal| %>
|
||||||
|
<% montado = evento.template_utilizavel(canal).present? %>
|
||||||
|
<%= link_to "#{montado ? '✓ ' : ''}#{canal == 'email' ? 'E-mail' : 'WhatsApp'}",
|
||||||
|
template_admin_evento_path(evento, canal: canal), data: { turbo: false },
|
||||||
|
title: montado ? 'Mensagem montada no editor' : 'Sem mensagem — usa o texto padrão',
|
||||||
|
class: "text-sm #{montado ? 'text-green-400 hover:text-green-300' : 'text-gray-400 hover:text-orange-400'}" %>
|
||||||
|
<% end %>
|
||||||
|
<span class="text-gray-700">|</span>
|
||||||
|
<%= link_to 'Editar', edit_admin_evento_path(evento), data: { turbo: false },
|
||||||
|
class: 'text-gray-300 hover:text-orange-400 text-sm' %>
|
||||||
|
<% if evento.apagavel? %>
|
||||||
|
<%= button_to 'Excluir', admin_evento_path(evento), method: :delete,
|
||||||
|
form: { data: { turbo_confirm: "Excluir o evento #{evento.nome}?" } },
|
||||||
|
class: 'text-red-400 hover:text-red-300 text-sm inline' %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Disparo manual: manda mensagem DE VERDADE. O texto é digitado aqui
|
||||||
|
porque o editor de blocos ainda não existe. %>
|
||||||
|
<% if evento.manual? && evento.ativo? %>
|
||||||
|
<details class="mt-4 border-t border-white/5 pt-4">
|
||||||
|
<summary class="cursor-pointer text-sm text-orange-400 hover:text-orange-300 select-none">Disparar agora</summary>
|
||||||
|
<% com_template = %w[whatsapp email].any? { |c| evento.template_utilizavel(c) } %>
|
||||||
|
<%= form_with url: disparar_admin_evento_path(evento), method: :post,
|
||||||
|
data: { turbo: false }, class: 'mt-3 space-y-3' do |f| %>
|
||||||
|
<% if com_template %>
|
||||||
|
<p class="text-xs text-green-400">
|
||||||
|
Este evento tem mensagem montada no editor — ela será usada nos canais configurados.
|
||||||
|
O texto abaixo só vale para o canal que ainda não tem mensagem própria.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<%= f.text_field :assunto, placeholder: 'Assunto (só e-mail)', value: evento.nome,
|
||||||
|
class: 'w-full 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.text_area :corpo, rows: 4, required: !com_template, placeholder: com_template ? 'Opcional — só para o canal sem mensagem montada' : 'Mensagem que será enviada…',
|
||||||
|
class: 'w-full 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 para os grupos assinantes',
|
||||||
|
data: { turbo_confirm: 'Isso envia mensagem de verdade para todos os contatos dos grupos assinantes. Confirmar?' },
|
||||||
|
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||||
|
<% end %>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/admin/eventos_notificacao/new.html.erb
Normal file
4
app/views/admin/eventos_notificacao/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Novo evento</h1>
|
||||||
|
<%= render "form", evento: @evento %>
|
||||||
|
</div>
|
||||||
36
app/views/admin/grupos_contato/_form.html.erb
Normal file
36
app/views/admin/grupos_contato/_form.html.erb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<%# URL explícita: a rota se chama `grupo`/`grupos` (ver routes.rb), então a
|
||||||
|
rota polimórfica de `model:` procuraria admin_grupo_contato_path e estouraria.
|
||||||
|
O `model:` continua valendo para nomear os campos (grupo_contato[...]). %>
|
||||||
|
<%= form_with model: [:admin, grupo],
|
||||||
|
url: (grupo.persisted? ? admin_grupo_path(grupo) : admin_grupos_path),
|
||||||
|
data: { turbo: false }, class: 'space-y-5' do |f| %>
|
||||||
|
<% if grupo.errors.any? %>
|
||||||
|
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<% grupo.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :nome, class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :nome, required: true, placeholder: 'Diretoria',
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :descricao, 'Descrição', class: 'block text-gray-400 text-sm mb-1' %>
|
||||||
|
<%= f.text_field :descricao, placeholder: 'Quem acompanha o fechamento',
|
||||||
|
class: 'w-full bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<%= f.check_box :ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||||
|
Ativo (grupo inativo não recebe nada)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<%= f.submit 'Salvar', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||||
|
<%= link_to 'Cancelar', admin_grupos_path, data: { turbo: false }, class: 'text-gray-400 hover:text-white text-sm' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
4
app/views/admin/grupos_contato/edit.html.erb
Normal file
4
app/views/admin/grupos_contato/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Editar grupo</h1>
|
||||||
|
<%= render 'form', grupo: @grupo %>
|
||||||
|
</div>
|
||||||
65
app/views/admin/grupos_contato/index.html.erb
Normal file
65
app/views/admin/grupos_contato/index.html.erb
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Grupos de contato</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-0.5">
|
||||||
|
É o grupo que assina os eventos — cadastrar alguém novo é escolher o grupo,
|
||||||
|
não repetir a configuração pessoa por pessoa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= link_to 'Novo grupo', new_admin_grupo_path, data: { turbo: false },
|
||||||
|
class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||||
|
<% if @grupos.empty? %>
|
||||||
|
<p class="px-6 py-10 text-center text-gray-500 text-sm">
|
||||||
|
Nenhum grupo ainda. Crie um (ex.: <strong class="text-gray-300">Diretoria</strong>,
|
||||||
|
<strong class="text-gray-300">Motoristas</strong>) e depois cadastre os contatos dentro dele.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Grupo</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Descrição</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium">Contatos</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Assina os eventos</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Situação</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5">
|
||||||
|
<% @grupos.each do |grupo| %>
|
||||||
|
<tr class="hover:bg-white/5">
|
||||||
|
<td class="px-6 py-2.5 text-white font-semibold"><%= grupo.nome %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-400"><%= grupo.descricao.presence || '—' %></td>
|
||||||
|
<td class="px-4 py-2.5 text-right">
|
||||||
|
<%= link_to grupo.contatos.size, admin_contatos_path(grupo_id: grupo.id),
|
||||||
|
data: { turbo: false }, class: 'text-orange-400 hover:text-orange-300 underline' %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300">
|
||||||
|
<% nomes = grupo.assinaturas.select(&:ativo).map { |a| a.evento_notificacao.nome } %>
|
||||||
|
<%= nomes.any? ? nomes.join(', ') : '— nenhum' %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="<%= grupo.ativo? ? 'text-green-400' : 'text-gray-500' %>">
|
||||||
|
<%= grupo.ativo? ? 'Ativo' : 'Inativo' %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||||
|
<%= link_to 'Editar', edit_admin_grupo_path(grupo), data: { turbo: false },
|
||||||
|
class: 'text-gray-300 hover:text-orange-400 mr-3' %>
|
||||||
|
<%= button_to 'Excluir', admin_grupo_path(grupo), method: :delete,
|
||||||
|
form: { data: { turbo_confirm: "Excluir #{grupo.nome}? Os #{grupo.contatos.size} contato(s) ficam sem grupo e param de receber." } },
|
||||||
|
class: 'text-red-400 hover:text-red-300 inline' %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
4
app/views/admin/grupos_contato/new.html.erb
Normal file
4
app/views/admin/grupos_contato/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="max-w-xl space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Novo grupo de contato</h1>
|
||||||
|
<%= render 'form', grupo: @grupo %>
|
||||||
|
</div>
|
||||||
314
app/views/admin/mensagem_templates/edit.html.erb
Normal file
314
app/views/admin/mensagem_templates/edit.html.erb
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<%# Editor de blocos de UM evento em UM canal.
|
||||||
|
O estado vive num array JS; o HTML dos cards é desenhado a partir dele e
|
||||||
|
serializado num hidden ao salvar. Desenhar a partir do estado (em vez de ler
|
||||||
|
o DOM) é o que faz arrastar, remover e reordenar não perderem conteúdo. %>
|
||||||
|
<% outro_canal = @canal == 'email' ? 'whatsapp' : 'email' %>
|
||||||
|
<div class="space-y-6" id="editor-mensagem"
|
||||||
|
data-preview-url="<%= preview_template_admin_evento_path(@evento, canal: @canal) %>">
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Mensagem — <%= @evento.nome %></h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-0.5">
|
||||||
|
Canal: <strong class="text-white"><%= @canal == 'email' ? 'E-mail' : 'WhatsApp' %></strong>
|
||||||
|
· Gatilho: <%= @evento.gatilho_label %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= link_to "Editar #{outro_canal == 'email' ? 'e-mail' : 'WhatsApp'}",
|
||||||
|
template_admin_evento_path(@evento, canal: outro_canal), data: { turbo: false },
|
||||||
|
class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
|
||||||
|
<%= link_to 'Voltar', admin_eventos_path, data: { turbo: false },
|
||||||
|
class: 'px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @template.errors.any? %>
|
||||||
|
<div class="bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 text-red-300 text-sm">
|
||||||
|
<ul class="list-disc list-inside space-y-1">
|
||||||
|
<% @template.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: template_admin_evento_path(@evento, canal: @canal), method: :patch,
|
||||||
|
data: { turbo: false }, id: 'form-template' do |f| %>
|
||||||
|
<%= hidden_field_tag :blocos, @template.blocos.to_json, id: 'campo-blocos' %>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-[16rem_minmax(0,1fr)_22rem] gap-4">
|
||||||
|
|
||||||
|
<%# ── Paleta ──────────────────────────────────────────── %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||||
|
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Blocos</p>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">Arraste para a mensagem — ou clique.</p>
|
||||||
|
<div id="paleta" class="space-y-2">
|
||||||
|
<% Notificacao::Blocos::CATALOGO.each do |tipo, meta| %>
|
||||||
|
<div class="cursor-grab select-none bg-[#141414] border border-white/10 rounded-xl px-3 py-2.5 hover:border-orange-500"
|
||||||
|
data-tipo="<%= tipo %>" title="<%= meta[:dica] %>">
|
||||||
|
<span class="text-gray-500 mr-1">⠿</span>
|
||||||
|
<span class="text-sm text-white"><%= meta[:rotulo] %></span>
|
||||||
|
<span class="block text-xs text-gray-500 mt-0.5"><%= meta[:dica] %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Variáveis ─────────────────────────────────────── %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||||
|
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Variáveis</p>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
Clique num campo da mensagem e depois numa variável para inseri-la ali.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<% @variaveis.each do |nome, meta| %>
|
||||||
|
<button type="button" class="chip-variavel px-2 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-orange-300 hover:border-orange-500"
|
||||||
|
data-variavel="<%= nome %>" title="<%= meta[:descricao] %> (ex.: <%= meta[:amostra] %>)">
|
||||||
|
<%= "{{#{nome}}}" %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Mensagem ────────────────────────────────────────── %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% if @canal == 'email' %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||||
|
<label class="block text-gray-400 text-sm mb-1" for="assunto">Assunto</label>
|
||||||
|
<%= text_field_tag :assunto, @template.assunto, id: 'assunto', class: 'campo-editavel w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4">
|
||||||
|
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider mb-3">Mensagem</p>
|
||||||
|
<div id="canvas" class="space-y-3 min-h-[8rem]"></div>
|
||||||
|
<p id="canvas-vazio" class="text-sm text-gray-500 text-center py-8">
|
||||||
|
Arraste um bloco para cá. Sem blocos, o evento continua usando o texto padrão do sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<%= submit_tag 'Salvar mensagem', class: 'px-4 py-2.5 rounded-xl text-sm font-semibold bg-orange-500 text-black hover:bg-orange-400 cursor-pointer' %>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<%= check_box_tag :ativo, '1', @template.ativo, class: 'rounded border-white/20 bg-[#1a1a1a] text-orange-500' %>
|
||||||
|
Usar esta mensagem
|
||||||
|
</label>
|
||||||
|
<button type="button" id="btn-preview" class="px-4 py-2.5 rounded-xl text-sm bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500">
|
||||||
|
Atualizar preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Preview ─────────────────────────────────────────── %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 space-y-3">
|
||||||
|
<p class="text-gray-400 text-xs font-medium uppercase tracking-wider">Preview</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Com valores de exemplo, renderizado pelo mesmo código do envio.
|
||||||
|
</p>
|
||||||
|
<p id="preview-assunto" class="text-sm text-white font-semibold break-words"></p>
|
||||||
|
<%# iframe sandbox: o HTML do e-mail é montado pelo nosso renderizador
|
||||||
|
(que escapa tudo), mas exibir dentro de um sandbox sem scripts evita
|
||||||
|
que os estilos do e-mail vazem para o admin — e vice-versa. %>
|
||||||
|
<iframe id="preview-email" class="w-full h-[26rem] rounded-xl bg-white border-0 <%= 'hidden' unless @canal == 'email' %>"
|
||||||
|
sandbox="" title="Preview do e-mail"></iframe>
|
||||||
|
<pre id="preview-whatsapp" class="whitespace-pre-wrap break-words text-sm text-gray-200 bg-[#0b141a] rounded-xl p-4 min-h-[10rem] <%= 'hidden' if @canal == 'email' %>"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var raiz = document.getElementById('editor-mensagem');
|
||||||
|
if (!raiz) return;
|
||||||
|
|
||||||
|
var CATALOGO = <%= raw Notificacao::Blocos::CATALOGO.to_json %>;
|
||||||
|
var CANAL = <%= raw @canal.to_json %>;
|
||||||
|
var campo = document.getElementById('campo-blocos');
|
||||||
|
var canvas = document.getElementById('canvas');
|
||||||
|
var vazio = document.getElementById('canvas-vazio');
|
||||||
|
|
||||||
|
var blocos;
|
||||||
|
try { blocos = JSON.parse(campo.value) || []; } catch (e) { blocos = []; }
|
||||||
|
|
||||||
|
// Último campo focado: é nele que a variável clicada é inserida.
|
||||||
|
var ultimoCampo = null;
|
||||||
|
document.addEventListener('focusin', function (e) {
|
||||||
|
if (e.target.classList && e.target.classList.contains('campo-editavel')) ultimoCampo = e.target;
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapar(t) {
|
||||||
|
return String(t == null ? '' : t)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocoVazio(tipo) {
|
||||||
|
var b = { tipo: tipo };
|
||||||
|
var campos = (CATALOGO[tipo] || {}).campos || {};
|
||||||
|
Object.keys(campos).forEach(function (c) { b[c] = campos[c] === 'lista' ? [] : ''; });
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function campoHtml(indice, chave, especie, valor) {
|
||||||
|
var id = 'b' + indice + '_' + chave;
|
||||||
|
var rotulo = chave.charAt(0).toUpperCase() + chave.slice(1);
|
||||||
|
if (especie === 'multilinha') {
|
||||||
|
return '<label class="block text-xs text-gray-500 mb-1" for="' + id + '">' + rotulo + '</label>' +
|
||||||
|
'<textarea id="' + id + '" rows="3" data-indice="' + indice + '" data-campo="' + chave + '"' +
|
||||||
|
' class="campo-editavel campo-bloco w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||||
|
escapar(valor) + '</textarea>';
|
||||||
|
}
|
||||||
|
return '<label class="block text-xs text-gray-500 mb-1" for="' + id + '">' + rotulo + '</label>' +
|
||||||
|
'<input id="' + id + '" type="text" value="' + escapar(valor) + '" data-indice="' + indice + '" data-campo="' + chave + '"' +
|
||||||
|
' class="campo-editavel campo-bloco w-full bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">';
|
||||||
|
}
|
||||||
|
|
||||||
|
function listaHtml(indice, linhas) {
|
||||||
|
var html = '<p class="text-xs text-gray-500 mb-1">Linhas da tabela</p>';
|
||||||
|
(linhas || []).forEach(function (linha, j) {
|
||||||
|
html += '<div class="flex gap-2 mb-2">' +
|
||||||
|
'<input type="text" value="' + escapar(linha.rotulo) + '" placeholder="Rótulo" data-indice="' + indice + '" data-linha="' + j + '" data-parte="rotulo"' +
|
||||||
|
' class="campo-editavel campo-linha flex-1 bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||||
|
'<input type="text" value="' + escapar(linha.valor) + '" placeholder="Valor" data-indice="' + indice + '" data-linha="' + j + '" data-parte="valor"' +
|
||||||
|
' class="campo-editavel campo-linha flex-1 bg-[#141414] border border-white/10 rounded-xl text-white text-sm px-3 py-2 focus:outline-none focus:border-orange-500">' +
|
||||||
|
'<button type="button" class="btn-remove-linha px-2 text-red-400 hover:text-red-300" data-indice="' + indice + '" data-linha="' + j + '" title="Remover linha">✕</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += '<button type="button" class="btn-add-linha text-xs text-orange-400 hover:text-orange-300" data-indice="' + indice + '">+ linha</button>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function desenhar() {
|
||||||
|
canvas.innerHTML = '';
|
||||||
|
vazio.classList.toggle('hidden', blocos.length > 0);
|
||||||
|
|
||||||
|
blocos.forEach(function (bloco, i) {
|
||||||
|
var meta = CATALOGO[bloco.tipo] || { rotulo: bloco.tipo, campos: {} };
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = 'bg-[#141414] border border-white/10 rounded-xl p-3';
|
||||||
|
card.dataset.indice = i;
|
||||||
|
|
||||||
|
var corpo = '<div class="flex items-center justify-between mb-2">' +
|
||||||
|
'<span class="text-sm text-white"><span class="alca cursor-grab text-gray-500 mr-1">⠿</span>' + escapar(meta.rotulo) + '</span>' +
|
||||||
|
'<button type="button" class="btn-remove text-red-400 hover:text-red-300 text-sm" data-indice="' + i + '" title="Remover bloco">✕</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
var campos = meta.campos || {};
|
||||||
|
Object.keys(campos).forEach(function (chave) {
|
||||||
|
corpo += '<div class="mb-2">' +
|
||||||
|
(campos[chave] === 'lista' ? listaHtml(i, bloco[chave]) : campoHtml(i, chave, campos[chave], bloco[chave])) +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
if (!Object.keys(campos).length) {
|
||||||
|
corpo += '<p class="text-xs text-gray-500">Sem campos — só a linha separadora.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = corpo;
|
||||||
|
canvas.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
campo.value = JSON.stringify(blocos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edição dos campos ──────────────────────────────────────
|
||||||
|
canvas.addEventListener('input', function (e) {
|
||||||
|
var alvo = e.target;
|
||||||
|
if (alvo.classList.contains('campo-bloco')) {
|
||||||
|
blocos[+alvo.dataset.indice][alvo.dataset.campo] = alvo.value;
|
||||||
|
} else if (alvo.classList.contains('campo-linha')) {
|
||||||
|
var bloco = blocos[+alvo.dataset.indice];
|
||||||
|
bloco.linhas = bloco.linhas || [];
|
||||||
|
bloco.linhas[+alvo.dataset.linha][alvo.dataset.parte] = alvo.value;
|
||||||
|
} else { return; }
|
||||||
|
campo.value = JSON.stringify(blocos); // sem redesenhar: perderia o cursor
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('click', function (e) {
|
||||||
|
var alvo = e.target;
|
||||||
|
if (alvo.classList.contains('btn-remove')) {
|
||||||
|
blocos.splice(+alvo.dataset.indice, 1); desenhar();
|
||||||
|
} else if (alvo.classList.contains('btn-add-linha')) {
|
||||||
|
var b = blocos[+alvo.dataset.indice];
|
||||||
|
b.linhas = (b.linhas || []).concat([{ rotulo: '', valor: '' }]); desenhar();
|
||||||
|
} else if (alvo.classList.contains('btn-remove-linha')) {
|
||||||
|
blocos[+alvo.dataset.indice].linhas.splice(+alvo.dataset.linha, 1); desenhar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Paleta: clicar adiciona, arrastar insere na posição ────
|
||||||
|
document.getElementById('paleta').addEventListener('click', function (e) {
|
||||||
|
var item = e.target.closest('[data-tipo]');
|
||||||
|
if (!item) return;
|
||||||
|
blocos.push(blocoVazio(item.dataset.tipo));
|
||||||
|
desenhar();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.Sortable) {
|
||||||
|
Sortable.create(document.getElementById('paleta'), {
|
||||||
|
group: { name: 'blocos', pull: 'clone', put: false },
|
||||||
|
sort: false
|
||||||
|
});
|
||||||
|
Sortable.create(canvas, {
|
||||||
|
group: 'blocos',
|
||||||
|
handle: '.alca',
|
||||||
|
animation: 150,
|
||||||
|
onAdd: function (evt) {
|
||||||
|
// O clone da paleta é DOM sem estado: converte em bloco e redesenha.
|
||||||
|
var tipo = evt.item.dataset.tipo;
|
||||||
|
evt.item.parentNode.removeChild(evt.item);
|
||||||
|
blocos.splice(evt.newIndex, 0, blocoVazio(tipo));
|
||||||
|
desenhar();
|
||||||
|
},
|
||||||
|
onUpdate: function (evt) {
|
||||||
|
var movido = blocos.splice(evt.oldIndex, 1)[0];
|
||||||
|
blocos.splice(evt.newIndex, 0, movido);
|
||||||
|
desenhar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Variáveis: insere no cursor do último campo focado ─────
|
||||||
|
document.querySelectorAll('.chip-variavel').forEach(function (chip) {
|
||||||
|
chip.addEventListener('click', function () {
|
||||||
|
var texto = '{{' + chip.dataset.variavel + '}}';
|
||||||
|
if (!ultimoCampo) { alert('Clique primeiro no campo onde a variável deve entrar.'); return; }
|
||||||
|
var ini = ultimoCampo.selectionStart != null ? ultimoCampo.selectionStart : ultimoCampo.value.length;
|
||||||
|
var fim = ultimoCampo.selectionEnd != null ? ultimoCampo.selectionEnd : ini;
|
||||||
|
ultimoCampo.value = ultimoCampo.value.slice(0, ini) + texto + ultimoCampo.value.slice(fim);
|
||||||
|
ultimoCampo.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
ultimoCampo.focus();
|
||||||
|
ultimoCampo.selectionStart = ultimoCampo.selectionEnd = ini + texto.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Preview: renderizado no servidor, pelo mesmo código do envio ──
|
||||||
|
var token = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
document.getElementById('btn-preview').addEventListener('click', function () {
|
||||||
|
var assunto = document.getElementById('assunto');
|
||||||
|
fetch(raiz.dataset.previewUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-Token': token ? token.content : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ blocos: campo.value, assunto: assunto ? assunto.value : '' })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (dados) {
|
||||||
|
document.getElementById('preview-assunto').textContent = dados.assunto || '';
|
||||||
|
if (CANAL === 'email') {
|
||||||
|
document.getElementById('preview-email').srcdoc = dados.html || '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('preview-whatsapp').textContent = dados.html || '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { alert('Não foi possível gerar o preview agora.'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
desenhar();
|
||||||
|
document.getElementById('btn-preview').click();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
95
app/views/admin/notificacao_envios/index.html.erb
Normal file
95
app/views/admin/notificacao_envios/index.html.erb
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<% ctx = { status: params[:status], canal: params[:canal] }.compact_blank %>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Envios</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-0.5">
|
||||||
|
O que saiu, para quem e se chegou. Uma falha de envio nunca trava o fechamento —
|
||||||
|
então é aqui que ela aparece.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<% [['', '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 %>
|
||||||
|
<span class="text-gray-600">|</span>
|
||||||
|
<% [['', '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? %>
|
||||||
|
<span class="ml-auto text-sm text-red-400"><%= @total_falhas %> falha(s) neste filtro</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
|
||||||
|
<% if @envios.empty? %>
|
||||||
|
<p class="px-6 py-10 text-center text-gray-500 text-sm">Nenhum envio registrado ainda.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-white/5 text-gray-400 text-xs uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 font-medium">Quando</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Evento</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Destinatário</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Canal</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Situação</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Mensagem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5">
|
||||||
|
<% @envios.each do |envio| %>
|
||||||
|
<tr class="hover:bg-white/5 align-top">
|
||||||
|
<td class="px-6 py-2.5 text-gray-400 whitespace-nowrap"><%= envio.created_at.strftime('%d/%m/%Y %H:%M') %></td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300"><%= envio.evento_notificacao&.nome || '—' %></td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="text-white"><%= envio.contato&.nome || envio.user&.nome || '—' %></span>
|
||||||
|
<span class="block text-xs text-gray-500 font-mono"><%= envio.destino %></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-300"><%= envio.canal == 'whatsapp' ? 'WhatsApp' : 'E-mail' %></td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<% cor = { 'enviado' => 'text-green-400', 'falhou' => 'text-red-400' }.fetch(envio.status, 'text-yellow-400') %>
|
||||||
|
<span class="<%= cor %>"><%= envio.status.capitalize %></span>
|
||||||
|
<% if envio.falhou? && envio.erro.present? %>
|
||||||
|
<span class="block text-xs text-red-300/80 max-w-[22rem]"><%= envio.erro.truncate(160) %></span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-gray-400 max-w-[24rem]">
|
||||||
|
<span class="block truncate" title="<%= envio.corpo %>"><%= envio.corpo.to_s.truncate(90) %></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @pagy.pages > 1 %>
|
||||||
|
<% pag = ->(p) { admin_envios_path(ctx.merge(page: p)) } %>
|
||||||
|
<nav class="flex items-center justify-center gap-1.5 py-4 flex-wrap border-t border-white/5">
|
||||||
|
<% if @pagy.prev %>
|
||||||
|
<%= link_to '‹', pag.call(@pagy.prev), data: { turbo: false },
|
||||||
|
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||||
|
<% end %>
|
||||||
|
<% @pagy.series.each do |item| %>
|
||||||
|
<% if item == :gap %>
|
||||||
|
<span class="px-2 text-gray-500">…</span>
|
||||||
|
<% elsif item.is_a?(String) %>
|
||||||
|
<span class="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg bg-orange-500 text-black font-bold"><%= item %></span>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to item, pag.call(item), data: { turbo: false },
|
||||||
|
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% if @pagy.next %>
|
||||||
|
<%= link_to '›', pag.call(@pagy.next), data: { turbo: false },
|
||||||
|
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
114
app/views/admin/whatsapp_sessoes/show.html.erb
Normal file
114
app/views/admin/whatsapp_sessoes/show.html.erb
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<div class="max-w-3xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">WhatsApp — conexão</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-0.5">
|
||||||
|
Pareia o número da empresa com este servidor lendo um QR code, como no WhatsApp Web.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# O aviso fica na TELA, não só no código: quem parear precisa saber o que
|
||||||
|
está aceitando. %>
|
||||||
|
<div class="bg-amber-500/10 border border-amber-500/30 rounded-2xl px-5 py-4 text-sm text-amber-200 space-y-1">
|
||||||
|
<p class="font-bold">⚠️ Este canal não é oficial.</p>
|
||||||
|
<p>
|
||||||
|
A conexão por QR usa a porta do WhatsApp Web por engenharia reversa. É gratuita e ilimitada,
|
||||||
|
mas está <strong>fora dos Termos do WhatsApp</strong> — a Meta pode banir o número sem aviso.
|
||||||
|
Use um <strong>chip dedicado</strong>, nunca o número principal da operação.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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: <strong><%= @config.intervalo_envio %>s</strong>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6" data-estado-url="<%= status_admin_whatsapp_sessao_path %>" id="painel-whatsapp">
|
||||||
|
<% if @estado['conectado'] %>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500"></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-bold">Conectado</p>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Número <span class="font-mono text-gray-300"><%= @estado['numero'] %></span>
|
||||||
|
<% if @config.whatsapp_conectado_em %> · desde <%= l @config.whatsapp_conectado_em, format: :short rescue @config.whatsapp_conectado_em %><% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 border-t border-white/5 pt-5 space-y-4">
|
||||||
|
<%= form_with url: testar_admin_whatsapp_sessao_path, method: :post, data: { turbo: false },
|
||||||
|
class: 'flex flex-wrap items-end gap-3' do |f| %>
|
||||||
|
<div>
|
||||||
|
<%= 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' %>
|
||||||
|
</div>
|
||||||
|
<%= 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' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% elsif @estado['qr'].present? %>
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<p class="text-white font-bold">Leia o QR code</p>
|
||||||
|
<p class="text-sm text-gray-400 max-w-md">
|
||||||
|
No celular do chip dedicado: WhatsApp → <strong>Aparelhos conectados</strong> →
|
||||||
|
<strong>Conectar um aparelho</strong> → aponte para o código abaixo.
|
||||||
|
</p>
|
||||||
|
<%# 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. %>
|
||||||
|
<img src="<%= @estado['qr'] %>" alt="QR code do WhatsApp" width="320" height="320"
|
||||||
|
class="rounded-xl bg-white p-3" id="qr-whatsapp">
|
||||||
|
<p class="text-xs text-gray-500">O código se renova sozinho a cada poucos segundos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% else %>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500 mt-1.5"></span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-bold">Desconectado</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
|
<%= @estado['erro'].presence || @estado['ultimo_erro'].presence ||
|
||||||
|
'A ponte ainda não gerou um QR code. Aguarde alguns segundos e recarregue.' %>
|
||||||
|
</p>
|
||||||
|
<% unless @cliente.configurado? %>
|
||||||
|
<p class="text-sm text-amber-300 mt-3">
|
||||||
|
Falta configurar a ponte: defina <code>WHATSAPP_TOKEN</code> no <code>.env</code>
|
||||||
|
(o mesmo valor do container <code>whatsapp</code>) e suba os containers.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Polling do estado enquanto não está conectado: o QR expira sozinho e a
|
||||||
|
// página precisa buscar o novo, senão o operador lê um código já morto.
|
||||||
|
(function () {
|
||||||
|
var painel = document.getElementById('painel-whatsapp');
|
||||||
|
if (!painel) return;
|
||||||
|
var conectado = <%= @estado['conectado'] ? 'true' : 'false' %>;
|
||||||
|
if (conectado) return;
|
||||||
|
|
||||||
|
var url = painel.dataset.estadoUrl;
|
||||||
|
setInterval(function () {
|
||||||
|
fetch(url, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (dados) {
|
||||||
|
// Conectou: recarrega para mostrar o painel de conectado.
|
||||||
|
if (dados.conectado) { window.location.reload(); return; }
|
||||||
|
var img = document.getElementById('qr-whatsapp');
|
||||||
|
if (dados.qr && img) { img.src = dados.qr; }
|
||||||
|
else if (dados.qr && !img) { window.location.reload(); }
|
||||||
|
})
|
||||||
|
.catch(function () { /* ponte fora do ar: a próxima tentativa resolve */ });
|
||||||
|
}, 8000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -63,6 +63,20 @@
|
|||||||
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
|
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%# 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. %>
|
||||||
|
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||||
|
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Notificações</p>
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
12
app/views/notificacao_mailer/mensagem.html.erb
Normal file
12
app/views/notificacao_mailer/mensagem.html.erb
Normal file
@@ -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 %>
|
||||||
|
<div style="font-family: Arial, Helvetica, sans-serif; font-size: 15px; color: #222; line-height: 1.6;">
|
||||||
|
<%= simple_format(@corpo) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -111,6 +111,38 @@ Rails.application.routes.draw do
|
|||||||
resource :planilha_simpli_route, only: [:show], controller: 'planilhas_simpli_route' do
|
resource :planilha_simpli_route, only: [:show], controller: 'planilhas_simpli_route' do
|
||||||
get :baixar
|
get :baixar
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# API interna — Dashboard métricas
|
# API interna — Dashboard métricas
|
||||||
|
|||||||
@@ -19,3 +19,22 @@ end
|
|||||||
every "0 1 * * *" do
|
every "0 1 * * *" do
|
||||||
rake "simpli_route:series"
|
rake "simpli_route:series"
|
||||||
end
|
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
|
||||||
|
|||||||
41
db/migrate/20260824000001_create_notificacao_contatos.rb
Normal file
41
db/migrate/20260824000001_create_notificacao_contatos.rb
Normal file
@@ -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
|
||||||
70
db/migrate/20260824000002_create_notificacao_eventos.rb
Normal file
70
db/migrate/20260824000002_create_notificacao_eventos.rb
Normal file
@@ -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
|
||||||
31
db/migrate/20260824000003_create_notificacao_envios.rb
Normal file
31
db/migrate/20260824000003_create_notificacao_envios.rb
Normal file
@@ -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
|
||||||
@@ -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
|
||||||
29
db/migrate/20260824000005_create_mensagem_templates.rb
Normal file
29
db/migrate/20260824000005_create_mensagem_templates.rb
Normal file
@@ -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
|
||||||
27
db/migrate/20260824000006_create_operacao_snapshots.rb
Normal file
27
db/migrate/20260824000006_create_operacao_snapshots.rb
Normal file
@@ -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
|
||||||
35
db/migrate/20260824000007_semear_eventos_de_gatilho.rb
Normal file
35
db/migrate/20260824000007_semear_eventos_de_gatilho.rb
Normal file
@@ -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
|
||||||
@@ -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"
|
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.
|
# O banco PostgreSQL já existe externamente.
|
||||||
# Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL.
|
# 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:
|
volumes:
|
||||||
bundle_cache:
|
bundle_cache:
|
||||||
|
whatsapp_auth:
|
||||||
|
|||||||
11
lib/tasks/notificacao.rake
Normal file
11
lib/tasks/notificacao.rake
Normal file
@@ -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
|
||||||
@@ -53,6 +53,34 @@ FactoryBot.define do
|
|||||||
valor_aplicado { 15.0 }
|
valor_aplicado { 15.0 }
|
||||||
end
|
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
|
factory :historico_estimado do
|
||||||
data_hora { Time.current }
|
data_hora { Time.current }
|
||||||
valor_total_estimado { 150.0 }
|
valor_total_estimado { 150.0 }
|
||||||
|
|||||||
59
spec/jobs/detectar_mudancas_operacao_job_spec.rb
Normal file
59
spec/jobs/detectar_mudancas_operacao_job_spec.rb
Normal file
@@ -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
|
||||||
72
spec/jobs/disparar_eventos_agendados_job_spec.rb
Normal file
72
spec/jobs/disparar_eventos_agendados_job_spec.rb
Normal file
@@ -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
|
||||||
43
spec/models/contato_spec.rb
Normal file
43
spec/models/contato_spec.rb
Normal file
@@ -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
|
||||||
69
spec/models/evento_notificacao_spec.rb
Normal file
69
spec/models/evento_notificacao_spec.rb
Normal file
@@ -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
|
||||||
67
spec/models/mensagem_template_spec.rb
Normal file
67
spec/models/mensagem_template_spec.rb
Normal file
@@ -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
|
||||||
134
spec/services/notificacao/despachante_spec.rb
Normal file
134
spec/services/notificacao/despachante_spec.rb
Normal file
@@ -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('<p') # HTML montado
|
||||||
|
expect(email.assunto).to include('Fechamento')
|
||||||
|
end
|
||||||
|
|
||||||
|
# {{contato}} é o primeiro nome de QUEM RECEBE: um render compartilhado
|
||||||
|
# mandaria o nome da primeira pessoa para todo mundo.
|
||||||
|
it 'renderiza {{contato}} por destinatário' do
|
||||||
|
create(:contato, grupo_contato: diretoria, nome: 'Ana Paula', telefone: '11 93005-1157', email: nil)
|
||||||
|
MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true,
|
||||||
|
blocos: [{ 'tipo' => '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
|
||||||
69
spec/services/notificacao/gatilhos_spec.rb
Normal file
69
spec/services/notificacao/gatilhos_spec.rb
Normal file
@@ -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
|
||||||
73
spec/services/notificacao/renderizador_spec.rb
Normal file
73
spec/services/notificacao/renderizador_spec.rb
Normal file
@@ -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('<h1')
|
||||||
|
expect(html).to include('style=') # cliente de e-mail não lê CSS externo
|
||||||
|
expect(html).to include('Olá Carlos!')
|
||||||
|
expect(html).to include('R$ 5.060,00')
|
||||||
|
expect(html).to include('<a href="https://app.reem.com.br/motorista"')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'variável sem valor vira vazio, não fica "{{cru}}" na mensagem' do
|
||||||
|
texto = render([{ 'tipo' => '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' => '<script>alert(1)</script> {{contato}}' }],
|
||||||
|
{ 'contato' => '<img src=x onerror=alert(2)>' }
|
||||||
|
)
|
||||||
|
html = r.html
|
||||||
|
expect(html).not_to include('<script>')
|
||||||
|
expect(html).not_to include('<img src=x')
|
||||||
|
expect(html).to include('<script>')
|
||||||
|
end
|
||||||
|
|
||||||
|
# href montado na tela com `javascript:` seria clique armado dentro do e-mail.
|
||||||
|
it 'ignora botão cuja URL não é http(s)' do
|
||||||
|
html = render([{ 'tipo' => 'botao', 'rotulo' => 'Clique', 'url' => 'javascript:alert(1)' }]).html
|
||||||
|
expect(html).not_to include('javascript:')
|
||||||
|
expect(html).not_to include('<a ')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'descarta bloco de tipo desconhecido' do
|
||||||
|
r = render([{ 'tipo' => 'malicioso', 'texto' => 'nada' },
|
||||||
|
{ 'tipo' => 'texto', 'texto' => 'ok' }])
|
||||||
|
expect(r.texto).to eq('ok')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'não deixa bloco vazio virar linha em branco na mensagem' do
|
||||||
|
texto = render([{ 'tipo' => 'texto', 'texto' => '' },
|
||||||
|
{ 'tipo' => 'texto', 'texto' => 'único' }]).texto
|
||||||
|
expect(texto).to eq('único')
|
||||||
|
end
|
||||||
|
end
|
||||||
35
spec/services/notificacao/whatsapp_spec.rb
Normal file
35
spec/services/notificacao/whatsapp_spec.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Notificacao::Whatsapp do
|
||||||
|
let(:config) { ConfiguracaoNotificacao.new(whatsapp_ativo: true) }
|
||||||
|
|
||||||
|
it 'recusa número que não dá para normalizar, sem chamar provedor nenhum' do
|
||||||
|
expect(described_class).not_to receive(:via_baileys)
|
||||||
|
resposta = described_class.enviar(para: '123', texto: 'oi', config: config)
|
||||||
|
|
||||||
|
expect(resposta).not_to be_ok
|
||||||
|
expect(resposta.erro).to include('inválido')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'usa a ponte Baileys quando o provedor é baileys' do
|
||||||
|
config.whatsapp_provedor = 'baileys'
|
||||||
|
ponte = instance_double(Notificacao::ClienteWhatsapp,
|
||||||
|
enviar: Notificacao::ClienteWhatsapp::Resposta.new(ok: true))
|
||||||
|
allow(Notificacao::ClienteWhatsapp).to receive(:padrao).and_return(ponte)
|
||||||
|
|
||||||
|
expect(described_class.enviar(para: '(11) 92005-1157', texto: 'oi', config: config)).to be_ok
|
||||||
|
expect(ponte).to have_received(:enviar).with(para: '+5511920051157', texto: 'oi')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'devolve erro em vez de levantar quando o Twilio falha' do
|
||||||
|
config.whatsapp_provedor = 'twilio'
|
||||||
|
allow(config).to receive(:credenciais_whatsapp)
|
||||||
|
.and_return({ sid: 'AC1', token: 'x', from: 'whatsapp:+5511000000000' })
|
||||||
|
allow(Notificacao::ClienteTwilio).to receive(:montar).and_raise(StandardError, 'rede fora')
|
||||||
|
|
||||||
|
resposta = described_class.enviar(para: '(11) 92005-1157', texto: 'oi', config: config)
|
||||||
|
|
||||||
|
expect(resposta).not_to be_ok
|
||||||
|
expect(resposta.erro).to include('rede fora')
|
||||||
|
end
|
||||||
|
end
|
||||||
22
whatsapp/Dockerfile
Normal file
22
whatsapp/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Ponte WhatsApp (Baileys). Container separado porque nao existe biblioteca Ruby
|
||||||
|
# que fale o protocolo do WhatsApp Web — e porque isolar a sessao num processo
|
||||||
|
# proprio evita que uma queda dela derrube o Puma.
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Baileys usa libs nativas de cripto em algumas plataformas.
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
|
||||||
|
# A sessao pareada vive AQUI e precisa de volume: sem isso, cada deploy exige
|
||||||
|
# escanear o QR de novo.
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENV WHATSAPP_DATA_DIR=/data
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "server.js"]
|
||||||
15
whatsapp/package.json
Normal file
15
whatsapp/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "reem-whatsapp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Ponte HTTP entre o Rails e o WhatsApp via Baileys (sessao pareada por QR code).",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": { "start": "node server.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"@whiskeysockets/baileys": "^6.7.9",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=20" }
|
||||||
|
}
|
||||||
205
whatsapp/server.js
Normal file
205
whatsapp/server.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Ponte HTTP entre o Rails e o WhatsApp, via Baileys (sessao pareada por QR).
|
||||||
|
//
|
||||||
|
// ⚠️ NAO E API OFICIAL. Baileys fala o protocolo do WhatsApp Web por engenharia
|
||||||
|
// reversa. Funciona e e gratuito, mas esta fora dos Termos do WhatsApp: a
|
||||||
|
// Meta pode banir o numero. Use um chip dedicado, nao o numero principal da
|
||||||
|
// operacao, e respeite o intervalo entre mensagens (o Rails controla isso;
|
||||||
|
// aqui a fila serializa os envios para nunca sair rajada em paralelo).
|
||||||
|
//
|
||||||
|
// Superficie HTTP (toda rota, menos /health, exige Authorization: Bearer TOKEN):
|
||||||
|
// GET /health -> { ok: true } (sem auth, para o compose)
|
||||||
|
// GET /status -> { conectado, numero, qr, desde }
|
||||||
|
// POST /enviar -> { para, texto } => { ok, id }
|
||||||
|
// POST /logout -> encerra a sessao e apaga as credenciais
|
||||||
|
//
|
||||||
|
// A porta NAO e publicada no docker-compose: so o container do Rails alcanca.
|
||||||
|
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFile, rm, mkdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import makeWASocket, {
|
||||||
|
DisconnectReason,
|
||||||
|
useMultiFileAuthState,
|
||||||
|
fetchLatestBaileysVersion,
|
||||||
|
} from '@whiskeysockets/baileys';
|
||||||
|
import pino from 'pino';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const PORTA = Number(process.env.PORT || 3001);
|
||||||
|
const TOKEN = process.env.WHATSAPP_TOKEN || '';
|
||||||
|
const DATA_DIR = process.env.WHATSAPP_DATA_DIR || '/data';
|
||||||
|
const AUTH_DIR = join(DATA_DIR, 'auth');
|
||||||
|
|
||||||
|
const log = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||||
|
|
||||||
|
// Estado observavel pela tela do admin.
|
||||||
|
const estado = {
|
||||||
|
conectado: false,
|
||||||
|
numero: null,
|
||||||
|
qr: null, // data URL do QR enquanto nao pareado
|
||||||
|
desde: null,
|
||||||
|
ultimoErro: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sock = null;
|
||||||
|
let parando = false;
|
||||||
|
|
||||||
|
// ── Fila serial de envios ───────────────────────────────────────────────────
|
||||||
|
// Disparo em paralelo para dezenas de contatos e a forma mais rapida de perder
|
||||||
|
// o numero. Uma promise encadeada garante um envio por vez, na ordem.
|
||||||
|
let fila = Promise.resolve();
|
||||||
|
function enfileirar(tarefa) {
|
||||||
|
const resultado = fila.then(tarefa, tarefa);
|
||||||
|
fila = resultado.catch(() => {});
|
||||||
|
return resultado;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function conectar() {
|
||||||
|
await mkdir(AUTH_DIR, { recursive: true });
|
||||||
|
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
|
||||||
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
|
|
||||||
|
sock = makeWASocket({
|
||||||
|
version,
|
||||||
|
auth: state,
|
||||||
|
printQRInTerminal: false,
|
||||||
|
logger: pino({ level: 'silent' }),
|
||||||
|
browser: ['Reem Notas', 'Chrome', '1.0.0'],
|
||||||
|
markOnlineOnConnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.ev.on('creds.update', saveCreds);
|
||||||
|
|
||||||
|
sock.ev.on('connection.update', async (update) => {
|
||||||
|
const { connection, lastDisconnect, qr } = update;
|
||||||
|
|
||||||
|
if (qr) {
|
||||||
|
estado.qr = await QRCode.toDataURL(qr, { margin: 1, width: 320 });
|
||||||
|
estado.conectado = false;
|
||||||
|
log.info('QR novo gerado — aguardando leitura');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'open') {
|
||||||
|
estado.conectado = true;
|
||||||
|
estado.qr = null;
|
||||||
|
estado.desde = new Date().toISOString();
|
||||||
|
estado.ultimoErro = null;
|
||||||
|
estado.numero = (sock.user?.id || '').split(':')[0] || null;
|
||||||
|
log.info({ numero: estado.numero }, 'WhatsApp conectado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection === 'close') {
|
||||||
|
estado.conectado = false;
|
||||||
|
const codigo = lastDisconnect?.error?.output?.statusCode;
|
||||||
|
estado.ultimoErro = lastDisconnect?.error?.message || null;
|
||||||
|
|
||||||
|
// loggedOut = a sessao foi encerrada no celular. Reconectar em loop so
|
||||||
|
// gera trafego suspeito; as credenciais precisam ser apagadas e o QR
|
||||||
|
// lido de novo.
|
||||||
|
if (codigo === DisconnectReason.loggedOut) {
|
||||||
|
log.warn('sessao encerrada no aparelho — apagando credenciais');
|
||||||
|
await rm(AUTH_DIR, { recursive: true, force: true }).catch(() => {});
|
||||||
|
estado.numero = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parando) {
|
||||||
|
log.info({ codigo }, 'reconectando em 5s');
|
||||||
|
setTimeout(() => conectar().catch((e) => log.error(e)), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP ────────────────────────────────────────────────────────────────────
|
||||||
|
function json(res, status, corpo) {
|
||||||
|
const texto = JSON.stringify(corpo);
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||||
|
res.end(texto);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autorizado(req) {
|
||||||
|
if (!TOKEN) return false; // sem token configurado, nada passa
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const enviado = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||||
|
// Comparacao de tamanho fixo evita vazar o token por tempo de resposta.
|
||||||
|
if (enviado.length !== TOKEN.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < TOKEN.length; i += 1) diff |= enviado.charCodeAt(i) ^ TOKEN.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lerCorpo(req) {
|
||||||
|
const partes = [];
|
||||||
|
let total = 0;
|
||||||
|
for await (const parte of req) {
|
||||||
|
total += parte.length;
|
||||||
|
if (total > 64 * 1024) throw new Error('corpo grande demais');
|
||||||
|
partes.push(parte);
|
||||||
|
}
|
||||||
|
return partes.length ? JSON.parse(Buffer.concat(partes).toString('utf8')) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+55 11 92005-1157" / "5511920051157" -> "5511920051157@s.whatsapp.net"
|
||||||
|
function jidDe(numero) {
|
||||||
|
const digitos = String(numero || '').replace(/\D/g, '');
|
||||||
|
if (digitos.length < 12 || digitos.length > 13) return null;
|
||||||
|
return `${digitos}@s.whatsapp.net`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const servidor = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, 'http://local');
|
||||||
|
|
||||||
|
if (url.pathname === '/health') return json(res, 200, { ok: true });
|
||||||
|
|
||||||
|
if (!autorizado(req)) return json(res, 401, { erro: 'nao autorizado' });
|
||||||
|
|
||||||
|
if (url.pathname === '/status' && req.method === 'GET') {
|
||||||
|
return json(res, 200, {
|
||||||
|
conectado: estado.conectado,
|
||||||
|
numero: estado.numero,
|
||||||
|
qr: estado.qr,
|
||||||
|
desde: estado.desde,
|
||||||
|
ultimo_erro: estado.ultimoErro,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/enviar' && req.method === 'POST') {
|
||||||
|
const { para, texto } = await lerCorpo(req);
|
||||||
|
const jid = jidDe(para);
|
||||||
|
if (!jid) return json(res, 422, { erro: 'numero invalido' });
|
||||||
|
if (!texto || !String(texto).trim()) return json(res, 422, { erro: 'texto vazio' });
|
||||||
|
if (!estado.conectado || !sock) return json(res, 503, { erro: 'whatsapp desconectado' });
|
||||||
|
|
||||||
|
const resultado = await enfileirar(async () => {
|
||||||
|
// Mandar para numero que nao tem WhatsApp e um dos sinais que a Meta usa
|
||||||
|
// para marcar conta de spam — conferir antes vale o round-trip.
|
||||||
|
const [existe] = await sock.onWhatsApp(jid.split('@')[0]);
|
||||||
|
if (!existe?.exists) throw new Error('numero sem WhatsApp');
|
||||||
|
return sock.sendMessage(jid, { text: String(texto) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(res, 200, { ok: true, id: resultado?.key?.id || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/logout' && req.method === 'POST') {
|
||||||
|
if (sock) await sock.logout().catch(() => {});
|
||||||
|
await rm(AUTH_DIR, { recursive: true, force: true }).catch(() => {});
|
||||||
|
estado.conectado = false;
|
||||||
|
estado.numero = null;
|
||||||
|
estado.qr = null;
|
||||||
|
setTimeout(() => conectar().catch((e) => log.error(e)), 1000);
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 404, { erro: 'rota desconhecida' });
|
||||||
|
} catch (e) {
|
||||||
|
log.error({ err: e }, 'falha na requisicao');
|
||||||
|
return json(res, 500, { erro: e.message || 'erro interno' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => { parando = true; servidor.close(() => process.exit(0)); });
|
||||||
|
|
||||||
|
servidor.listen(PORTA, '0.0.0.0', () => log.info(`ponte WhatsApp na porta ${PORTA}`));
|
||||||
|
conectar().catch((e) => log.error({ err: e }, 'falha ao conectar'));
|
||||||
Reference in New Issue
Block a user