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

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