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