# 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
#{corpo}
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 %(
)
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 << %(#{titulo}
) if titulo.present?
partes << %(#{sub}
) 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
, já escapadas.
def html_paragrafo(valor)
conteudo = escapar(valor)
return nil if conteudo.blank?
%(#{conteudo.gsub("\n", '
')}
)
end
def html_aviso(bloco)
conteudo = escapar(bloco['texto'])
return nil if conteudo.blank?
%(#{conteudo.gsub("\n", '
')}
)
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?
%(| #{rotulo} | ) +
%(#{valor} |
)
end
return nil if linhas.empty?
%('
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'
%(#{rotulo}
)
end
def html_rodape(bloco)
conteudo = escapar(bloco['texto'])
return nil if conteudo.blank?
%(#{conteudo.gsub("\n", '
')}
)
end
end
end