Implantação da geração de mensagem de forma livre e mudança na engine de mensagem

This commit is contained in:
2026-08-24 17:32:16 -03:00
parent 94bbe7556f
commit 88f19dd8f5
77 changed files with 4650 additions and 25 deletions

View 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

View File

@@ -118,6 +118,15 @@ class Admin::EdicaoLancamentosController < ApplicationController
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|
format.json do
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
@@ -157,6 +166,18 @@ class Admin::EdicaoLancamentosController < ApplicationController
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
@client ||= SimpliRoute::Client.new
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -11,9 +11,15 @@ class ConfiguracaoNotificacao < ApplicationRecord
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
# 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/
REMETENTE_PADRAO = 'noreply@gade.com.br'
@@ -28,9 +34,16 @@ class ConfiguracaoNotificacao < ApplicationRecord
validates :email_admin, format: { with: EMAIL_RE }, allow_blank: true
validate :senha_smtp_presente, if: :smtp_ativo?
validates :twilio_account_sid, presence: true, if: :whatsapp_ativo?
validates :twilio_from, presence: true, if: :whatsapp_ativo?
validate :token_twilio_presente, if: :whatsapp_ativo?
validates :whatsapp_provedor, inclusion: { in: PROVEDORES_WHATSAPP }
validates :whatsapp_intervalo_segundos,
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 ───────────────────────────────────────────────
def self.instancia
@@ -102,6 +115,32 @@ class ConfiguracaoNotificacao < ApplicationRecord
email_notificacoes_ativo?
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 ───────────────────────────────────────
def whatsapp_pronto?
whatsapp_ativo? && twilio_account_sid.present? &&
@@ -109,7 +148,9 @@ class ConfiguracaoNotificacao < ApplicationRecord
end
def whatsapp_habilitado?
whatsapp_ativo? && credenciais_whatsapp.present?
return false unless whatsapp_ativo?
baileys? ? baileys_pronto? : credenciais_whatsapp.present?
end
# 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.email_admin = email_admin.to_s.strip.downcase.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
# 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.whatsapp_admin = self.class.normalizar_telefone(whatsapp_admin) || whatsapp_admin.presence
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
return if smtp_password.present?

View File

@@ -377,12 +377,19 @@ class Consolidacao < ApplicationRecord
cm = consolidacao_motoristas.find_by(motorista_nome: nome)
return unless cm
anterior = cm.valor_total
total = consolidacao_entregas.where(motorista_nome: nome).sum do |e|
e.desconto? ? -e.valor_aplicado : e.valor_aplicado
end
cm.update_column(:valor_total, total)
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
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
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos

68
app/models/contato.rb Normal file
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,3 @@
# app/policies/grupo_contato_policy.rb — ver ContatoPolicy.
class GrupoContatoPolicy < ContatoPolicy
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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 0114/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

View 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

View File

@@ -13,10 +13,12 @@
class NotificacaoService
EVENTOS = {
fechado: { mailer: :pagamento_fechado,
chave: 'consolidacao_finalizada',
emoji: '🚚',
titulo: 'Seu pagamento de entregas foi fechado.',
cta: 'Acesse seu painel para baixar o extrato:' },
pago: { mailer: :pagamento_efetuado,
chave: 'pagamento_efetuado',
emoji: '✅',
titulo: 'Seu pagamento foi efetuado.',
cta: 'Acesse seu painel para conferir:' }
@@ -48,10 +50,70 @@ class NotificacaoService
def notificar(cm, evento)
user = motorista_de(cm)
return unless user
enviar_whatsapp(user, cm, evento) if @config.whatsapp_habilitado?
enviar_email(user, cm, evento) if @config.email_habilitado? && user.email.present?
# Aviso pessoal ao motorista: continua com o e-mail formatado do
# 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
def motorista_de(cm)
@@ -59,34 +121,57 @@ class NotificacaoService
end
def enviar_whatsapp(user, cm, evento)
# O telefone no cadastro pode estar como "(11) 92005-1157"; mandar esse
# texto cru para o Twilio devolve erro 21211 (destino inválido), que o
# rescue abaixo esconderia. Normalizar para E.164 é o que faz o envio
# realmente funcionar.
destino = ConfiguracaoNotificacao.canal(user.telefone)
# O telefone no cadastro pode estar como "(11) 92005-1157"; a normalização
# para E.164 acontece dentro de Notificacao::Whatsapp — mandar o texto cru
# devolve erro de destino inválido que o rescue esconderia.
destino = ConfiguracaoNotificacao.normalizar_telefone(user.telefone)
return if destino.blank?
credenciais = @config.credenciais_whatsapp
return if credenciais.blank? || credenciais[:from].blank?
texto = mensagem(user, cm, evento)
envio = registrar_envio(evento, canal: 'whatsapp', destino: destino, user: user, corpo: texto)
Notificacao::ClienteTwilio
.montar(credenciais[:sid], credenciais[:token])
.messages.create(from: credenciais[:from], to: destino, body: mensagem(user, cm, evento))
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
rescue StandardError => e
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{e.class}: #{e.message}")
resposta = Notificacao::Whatsapp.enviar(para: destino, texto: texto, config: @config)
if resposta.ok?
envio&.marcar_enviado!
Rails.logger.info("[Notificacao] WhatsApp (#{evento}) enviado para #{user.nome}")
else
envio&.marcar_falha!(resposta.erro)
Rails.logger.error("[Notificacao] Falha WhatsApp (#{evento}) #{user.nome}: #{resposta.erro}")
end
end
def enviar_email(user, cm, evento)
ConsolidacaoMailer
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
.deliver_later
evt = EVENTOS.fetch(evento)
envio = registrar_envio(evento, canal: 'email', destino: user.email, user: user,
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}")
rescue StandardError => e
envio&.marcar_falha!("#{e.class}: #{e.message}")
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
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)
evt = EVENTOS.fetch(evento)
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \

View 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 %>

View 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>

View 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>

View 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>

View 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 %>

View 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>

View 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>

View 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>

View 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 %>

View 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>

View 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>

View 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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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>

View 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>

View 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>

View File

@@ -63,6 +63,20 @@
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
<% end %>
</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 %>
</nav>

View 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 %>