Adição da função de configuração de email e whatts
This commit is contained in:
101
app/controllers/admin/configuracao_notificacoes_controller.rb
Normal file
101
app/controllers/admin/configuracao_notificacoes_controller.rb
Normal file
@@ -0,0 +1,101 @@
|
||||
# app/controllers/admin/configuracao_notificacoes_controller.rb
|
||||
#
|
||||
# Tela única (singleton) de SMTP + WhatsApp. Só admin — ver
|
||||
# ConfiguracaoNotificacaoPolicy.
|
||||
class Admin::ConfiguracaoNotificacoesController < ApplicationController
|
||||
before_action :set_config
|
||||
|
||||
CAMPOS = %i[
|
||||
smtp_ativo smtp_address smtp_port smtp_username smtp_password smtp_domain
|
||||
smtp_autenticacao remetente_email remetente_nome
|
||||
email_notificacoes_ativo email_admin
|
||||
whatsapp_ativo twilio_account_sid twilio_auth_token twilio_from whatsapp_admin
|
||||
].freeze
|
||||
|
||||
def show
|
||||
authorize @config
|
||||
end
|
||||
|
||||
# PATCH /admin/configuracao_notificacao
|
||||
#
|
||||
# Os botões "enviar teste" são submits DESTE MESMO form, diferenciados por
|
||||
# params[:acao]. Assim o teste sempre usa o que está na tela — evita o
|
||||
# clássico "digitei a senha nova, cliquei em testar, e ele testou a antiga".
|
||||
def update
|
||||
authorize @config
|
||||
|
||||
antes = resumo
|
||||
|
||||
unless @config.update(config_params)
|
||||
flash.now[:alert] = @config.errors.full_messages.to_sentence
|
||||
return render(:show, status: :unprocessable_entity)
|
||||
end
|
||||
|
||||
auditar!(antes)
|
||||
|
||||
case params[:acao]
|
||||
when 'testar_email'
|
||||
redirecionar(Notificacao::TesteEmail.new(@config, usuario: current_user).executar)
|
||||
when 'testar_whatsapp'
|
||||
redirecionar(Notificacao::TesteWhatsapp.new(@config).executar)
|
||||
else
|
||||
redirect_to admin_configuracao_notificacao_path,
|
||||
notice: 'Configurações de notificação salvas.'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_config
|
||||
@config = ConfiguracaoNotificacao.instancia
|
||||
end
|
||||
|
||||
def redirecionar(resultado)
|
||||
redirect_to admin_configuracao_notificacao_path, **resultado.para_flash
|
||||
end
|
||||
|
||||
def config_params
|
||||
permitidos = params.require(:configuracao_notificacao).permit(*CAMPOS)
|
||||
# Campo de senha em branco significa "mantenha a que já está gravada" — a
|
||||
# tela nunca exibe o valor, então salvar sem redigitar não pode apagá-lo.
|
||||
permitidos.delete(:smtp_password) if permitidos[:smtp_password].blank?
|
||||
permitidos.delete(:twilio_auth_token) if permitidos[:twilio_auth_token].blank?
|
||||
permitidos
|
||||
end
|
||||
|
||||
# ⚠️ NUNCA colocar senha ou token aqui: AuditoriaLog#dados_novos é exibido em
|
||||
# /admin/auditoria_logs, que gerente também acessa. Só o booleano de
|
||||
# "está definida ou não".
|
||||
def resumo
|
||||
{
|
||||
smtp_ativo: @config.smtp_ativo,
|
||||
smtp_address: @config.smtp_address,
|
||||
smtp_port: @config.smtp_port,
|
||||
smtp_username: @config.smtp_username,
|
||||
smtp_domain: @config.smtp_domain,
|
||||
smtp_autenticacao: @config.smtp_autenticacao,
|
||||
remetente_email: @config.remetente_email,
|
||||
remetente_nome: @config.remetente_nome,
|
||||
email_admin: @config.email_admin,
|
||||
email_notificacoes_ativo: @config.email_notificacoes_ativo,
|
||||
whatsapp_ativo: @config.whatsapp_ativo,
|
||||
twilio_account_sid: @config.twilio_account_sid,
|
||||
twilio_from: @config.twilio_from,
|
||||
whatsapp_admin: @config.whatsapp_admin,
|
||||
smtp_password_definida: @config.smtp_password?,
|
||||
twilio_auth_token_definido: @config.twilio_auth_token?
|
||||
}
|
||||
end
|
||||
|
||||
def auditar!(antes)
|
||||
AuditoriaLog.registrar(
|
||||
user: current_user,
|
||||
acao: 'editar_notificacoes',
|
||||
entidade: 'ConfiguracaoNotificacao',
|
||||
entidade_id: @config.id,
|
||||
dados_anteriores: antes,
|
||||
dados_novos: resumo,
|
||||
request: request
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,22 @@
|
||||
# app/mailers/application_mailer.rb
|
||||
#
|
||||
# O remetente e as credenciais SMTP saem do BANCO (ConfiguracaoNotificacao), em
|
||||
# tempo de ENVIO — mudar na tela /admin/configuracao_notificacao vale sem
|
||||
# reiniciar o container.
|
||||
#
|
||||
# ⚠️ Tem que ser `proc`, NÃO lambda. O Devise avalia o `default from:` com
|
||||
# `instance_eval(&proc)`, que passa 1 argumento; um lambda de aridade 0
|
||||
# estouraria ArgumentError em todo "esqueci minha senha".
|
||||
#
|
||||
# ⚠️ E tem que ser `delivery_method_options`, NÃO um before_action mexendo em
|
||||
# `message.delivery_method`: o `mail()` roda DEPOIS dos callbacks e chama
|
||||
# `wrap_delivery_behavior!`, que sobrescreveria o callback. O
|
||||
# `delivery_method_options` é lido dentro do próprio `mail()` e MERGEADO por
|
||||
# cima do smtp_settings da classe — é daí que vem, de graça, o fallback para
|
||||
# o que o config/initializers/smtp.rb montou a partir do .env.
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br')
|
||||
layout 'mailer'
|
||||
|
||||
default from: proc { ConfiguracaoNotificacao.remetente },
|
||||
delivery_method_options: proc { ConfiguracaoNotificacao.opcoes_de_entrega }
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# app/mailers/consolidacao_mailer.rb
|
||||
class ConsolidacaoMailer < ApplicationMailer
|
||||
default from: ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br')
|
||||
# Sem `default from:` aqui de propósito: redeclarar sobrescreveria o proc do
|
||||
# ApplicationMailer e congelaria o remetente no valor que o .env tinha no boot.
|
||||
|
||||
def pagamento_fechado(user, consolidacao, consolidacao_motorista)
|
||||
@user = user
|
||||
|
||||
14
app/mailers/teste_mailer.rb
Normal file
14
app/mailers/teste_mailer.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# app/mailers/teste_mailer.rb
|
||||
#
|
||||
# E-mail disparado pelo botão "Salvar e enviar e-mail de teste" da tela
|
||||
# /admin/configuracao_notificacao. Serve só para provar que as credenciais SMTP
|
||||
# gravadas funcionam de verdade.
|
||||
class TesteMailer < ApplicationMailer
|
||||
def teste(destinatario, config, usuario = nil)
|
||||
@config = config
|
||||
@usuario = usuario
|
||||
@quando = Time.current
|
||||
|
||||
mail to: destinatario, subject: '[Reem Notas] E-mail de teste'
|
||||
end
|
||||
end
|
||||
71
app/models/concerns/atributo_cifrado.rb
Normal file
71
app/models/concerns/atributo_cifrado.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# app/models/concerns/atributo_cifrado.rb
|
||||
#
|
||||
# Cifra atributos sensíveis (senha SMTP, token do Twilio) em colunas
|
||||
# `<nome>_cifrado` usando ActiveSupport::MessageEncryptor (AES-256-GCM).
|
||||
#
|
||||
# POR QUE NÃO ActiveRecord Encryption (`encrypts :campo`): este projeto não usa
|
||||
# `config/credentials.yml.enc` nem `config/master.key` — tudo vem do .env. Ligar
|
||||
# o AR Encryption exigiria configurar 3 chaves novas e depender da ordem de
|
||||
# execução dos initializers do framework. Aqui a chave é DERIVADA do
|
||||
# secret_key_base, sem nada novo para o operador gerar.
|
||||
#
|
||||
# ⚠️ ROTAÇÃO: se o SECRET_KEY_BASE do servidor for trocado, os valores já
|
||||
# gravados viram ilegíveis. O reader devolve nil (nunca estoura 500), o app
|
||||
# cai no fallback do .env e a tela mostra um aviso pedindo para redigitar.
|
||||
# Para ficar imune a isso, defina NOTIFICACAO_SECRET no .env (qualquer string
|
||||
# longa e fixa) — a derivação passa a usar ela em vez do secret_key_base.
|
||||
module AtributoCifrado
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
SALT = 'reem-notas/notificacao/v1'
|
||||
|
||||
class_methods do
|
||||
def atributo_cifrado(*nomes)
|
||||
nomes.each do |nome|
|
||||
coluna = :"#{nome}_cifrado"
|
||||
|
||||
define_method(nome) { AtributoCifrado.decifrar(self[coluna]) }
|
||||
|
||||
define_method(:"#{nome}=") do |valor|
|
||||
texto = valor.to_s
|
||||
# Sem isso, salvar a tela sem mexer no campo geraria um criptograma
|
||||
# novo (o IV é aleatório) e o dirty tracking acusaria mudança à toa.
|
||||
next if texto == send(nome)
|
||||
|
||||
self[coluna] = texto.blank? ? nil : AtributoCifrado.cifrar(texto)
|
||||
end
|
||||
|
||||
define_method(:"#{nome}?") { send(nome).present? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.cifrar(texto)
|
||||
cofre.encrypt_and_sign(texto)
|
||||
end
|
||||
|
||||
def self.decifrar(blob)
|
||||
return nil if blob.blank?
|
||||
|
||||
cofre.decrypt_and_verify(blob)
|
||||
rescue ActiveSupport::MessageEncryptor::InvalidMessage,
|
||||
ActiveSupport::MessageVerifier::InvalidSignature => e
|
||||
Rails.logger.error("[AtributoCifrado] valor ilegível (SECRET_KEY_BASE mudou?): #{e.class}")
|
||||
nil
|
||||
end
|
||||
|
||||
# O default do MessageEncryptor no Rails 7.1 é aes-256-gcm → chave de 32 bytes.
|
||||
def self.cofre
|
||||
@cofre ||= ActiveSupport::MessageEncryptor.new(chave)
|
||||
end
|
||||
|
||||
def self.chave
|
||||
if (segredo = ENV['NOTIFICACAO_SECRET'].presence)
|
||||
ActiveSupport::KeyGenerator
|
||||
.new(segredo, hash_digest_class: OpenSSL::Digest::SHA256)
|
||||
.generate_key(SALT, 32)
|
||||
else
|
||||
Rails.application.key_generator.generate_key(SALT, 32)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,10 @@ class Configuracao < ApplicationRecord
|
||||
preco_extraordinaria
|
||||
preco_termo
|
||||
preco_termo_especial
|
||||
# ⚠️ OBSOLETAS: quem liga/desliga notificação agora é ConfiguracaoNotificacao
|
||||
# (tela /admin/configuracao_notificacao). Nenhum código lê estas duas chaves
|
||||
# desde a migration CreateConfiguracaoNotificacoes, que copiou os valores.
|
||||
# Continuam aqui só para não quebrar as linhas já existentes no banco.
|
||||
notificacao_whatsapp
|
||||
notificacao_email
|
||||
empresa_nome
|
||||
|
||||
192
app/models/configuracao_notificacao.rb
Normal file
192
app/models/configuracao_notificacao.rb
Normal file
@@ -0,0 +1,192 @@
|
||||
# app/models/configuracao_notificacao.rb
|
||||
#
|
||||
# Configuração de envio (SMTP + WhatsApp/Twilio) editável pelo ADM na tela
|
||||
# /admin/configuracao_notificacao. Tabela SINGLETON: sempre uma linha só.
|
||||
#
|
||||
# HIERARQUIA: banco > .env. Enquanto a tela não estiver preenchida e marcada
|
||||
# como ativa, `smtp_settings` devolve nil e `credenciais_whatsapp` cai no ENV —
|
||||
# ou seja, o comportamento é idêntico ao que existia antes desta tela.
|
||||
class ConfiguracaoNotificacao < ApplicationRecord
|
||||
include AtributoCifrado
|
||||
|
||||
self.table_name = 'configuracao_notificacoes'
|
||||
|
||||
atributo_cifrado :smtp_password, :twilio_auth_token
|
||||
|
||||
AUTENTICACOES = %w[plain login cram_md5].freeze
|
||||
EMAIL_RE = /\A[^@\s]+@[^@\s]+\z/
|
||||
REMETENTE_PADRAO = 'noreply@gade.com.br'
|
||||
|
||||
before_validation :normalizar
|
||||
|
||||
validates :smtp_address, presence: true, if: :smtp_ativo?
|
||||
validates :smtp_username, presence: true, if: :smtp_ativo?
|
||||
validates :smtp_port, numericality: { only_integer: true,
|
||||
greater_than: 0, less_than_or_equal_to: 65_535 }
|
||||
validates :smtp_autenticacao, inclusion: { in: AUTENTICACOES }
|
||||
validates :remetente_email, format: { with: EMAIL_RE }, allow_blank: true
|
||||
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?
|
||||
|
||||
# ── Singleton ───────────────────────────────────────────────
|
||||
def self.instancia
|
||||
first || create!
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# Dois workers Puma criando a primeira linha ao mesmo tempo.
|
||||
first
|
||||
rescue ActiveRecord::StatementInvalid,
|
||||
ActiveRecord::NoDatabaseError,
|
||||
ActiveRecord::ConnectionNotEstablished => e
|
||||
# Boot do container antes do db:migrate, assets:precompile, console sem banco…
|
||||
Rails.logger.warn("[ConfiguracaoNotificacao] tabela indisponível (#{e.class}) — usando .env")
|
||||
new
|
||||
end
|
||||
|
||||
# ── Usados pelo ApplicationMailer: NUNCA podem estourar ─────
|
||||
# (um erro aqui derrubaria todo envio de e-mail, inclusive o reset de senha)
|
||||
|
||||
def self.remetente
|
||||
instancia.remetente_formatado
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[ConfiguracaoNotificacao.remetente] #{e.class}: #{e.message}")
|
||||
ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO)
|
||||
end
|
||||
|
||||
# nil => o ActionMailer faz `.merge({})` e mantém o que o
|
||||
# config/initializers/smtp.rb montou a partir do .env no boot.
|
||||
def self.opcoes_de_entrega
|
||||
instancia.smtp_settings
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[ConfiguracaoNotificacao.opcoes_de_entrega] #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# ── SMTP ────────────────────────────────────────────────────
|
||||
def smtp_pronto?
|
||||
smtp_ativo? && smtp_address.present? && smtp_port.to_i.positive? &&
|
||||
smtp_username.present? && smtp_password.present?
|
||||
end
|
||||
|
||||
def smtp_settings
|
||||
return nil unless smtp_pronto?
|
||||
|
||||
porta = smtp_port.to_i
|
||||
base = {
|
||||
address: smtp_address,
|
||||
port: porta,
|
||||
user_name: smtp_username,
|
||||
password: smtp_password,
|
||||
domain: smtp_domain.presence || dominio_padrao,
|
||||
authentication: (smtp_autenticacao.presence || 'plain').to_sym,
|
||||
# Sem timeout, uma porta filtrada pelo firewall do VPS pendura o worker
|
||||
# Puma até o Nginx cortar — o botão "enviar teste" ficaria girando.
|
||||
open_timeout: 10,
|
||||
read_timeout: 10
|
||||
}
|
||||
# 465 = SSL implícito; 587/25 = STARTTLS.
|
||||
base.merge(porta == 465 ? { ssl: true, tls: true } : { enable_starttls_auto: true })
|
||||
end
|
||||
|
||||
def remetente_formatado
|
||||
endereco = remetente_email.presence ||
|
||||
smtp_username.presence ||
|
||||
ENV.fetch('SMTP_USERNAME', REMETENTE_PADRAO)
|
||||
remetente_nome.present? ? %("#{remetente_nome}" <#{endereco}>) : endereco
|
||||
end
|
||||
|
||||
def email_habilitado?
|
||||
email_notificacoes_ativo?
|
||||
end
|
||||
|
||||
# ── WhatsApp / Twilio ───────────────────────────────────────
|
||||
def whatsapp_pronto?
|
||||
whatsapp_ativo? && twilio_account_sid.present? &&
|
||||
twilio_auth_token.present? && twilio_from.present?
|
||||
end
|
||||
|
||||
def whatsapp_habilitado?
|
||||
whatsapp_ativo? && credenciais_whatsapp.present?
|
||||
end
|
||||
|
||||
# Tudo-ou-nada: nunca mistura o SID do banco com o token do .env (a conta
|
||||
# seria outra e o Twilio devolveria 20003 sem explicação óbvia).
|
||||
def credenciais_whatsapp
|
||||
if whatsapp_pronto?
|
||||
{ sid: twilio_account_sid,
|
||||
token: twilio_auth_token,
|
||||
from: self.class.canal(twilio_from) }
|
||||
elsif ENV['TWILIO_ACCOUNT_SID'].present? && ENV['TWILIO_AUTH_TOKEN'].present?
|
||||
{ sid: ENV['TWILIO_ACCOUNT_SID'],
|
||||
token: ENV['TWILIO_AUTH_TOKEN'],
|
||||
from: self.class.canal(ENV['TWILIO_WHATSAPP_FROM']) }
|
||||
end
|
||||
end
|
||||
|
||||
# ── Telefone ────────────────────────────────────────────────
|
||||
# "11 920051157", "(11) 92005-1157", "011 92005-1157", "+55 11 92005-1157"
|
||||
# e "whatsapp:+5511920051157" → "+5511920051157".
|
||||
def self.normalizar_telefone(valor)
|
||||
return nil if valor.blank?
|
||||
|
||||
d = valor.to_s.gsub(/\D/, '').sub(/\A0+/, '')
|
||||
# Só tira o 55 quando sobra número demais — senão quebraria o DDD 55
|
||||
# (Santa Maria/RS), onde "55991234567" já é o número completo.
|
||||
d = d.sub(/\A55/, '') if d.length > 11 && d.start_with?('55')
|
||||
return nil unless d.length.between?(10, 11)
|
||||
|
||||
"+55#{d}"
|
||||
end
|
||||
|
||||
def self.canal(numero)
|
||||
e164 = normalizar_telefone(numero)
|
||||
return nil if e164.blank?
|
||||
|
||||
"whatsapp:#{e164}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dominio_padrao
|
||||
(remetente_email.presence || smtp_username).to_s.split('@').last.presence || 'localhost'
|
||||
end
|
||||
|
||||
def normalizar
|
||||
self.smtp_address = smtp_address.to_s.strip.presence
|
||||
self.smtp_username = smtp_username.to_s.strip.presence
|
||||
self.smtp_domain = smtp_domain.to_s.strip.presence
|
||||
self.remetente_email = remetente_email.to_s.strip.downcase.presence
|
||||
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
|
||||
# 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 senha_smtp_presente
|
||||
return if smtp_password.present?
|
||||
|
||||
errors.add(:smtp_password,
|
||||
if smtp_password_cifrado.present?
|
||||
'gravada está ilegível (o SECRET_KEY_BASE do servidor mudou?). Digite a senha novamente.'
|
||||
else
|
||||
'é obrigatória quando o envio de e-mail está ativo'
|
||||
end)
|
||||
end
|
||||
|
||||
def token_twilio_presente
|
||||
return if twilio_auth_token.present?
|
||||
|
||||
errors.add(:twilio_auth_token,
|
||||
if twilio_auth_token_cifrado.present?
|
||||
'gravado está ilegível (o SECRET_KEY_BASE do servidor mudou?). Cole o token novamente.'
|
||||
else
|
||||
'é obrigatório quando o WhatsApp está ativo'
|
||||
end)
|
||||
end
|
||||
end
|
||||
22
app/policies/configuracao_notificacao_policy.rb
Normal file
22
app/policies/configuracao_notificacao_policy.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# app/policies/configuracao_notificacao_policy.rb
|
||||
#
|
||||
# Mais restrito que ConfiguracaoPolicy (que libera index? para gerente): estas
|
||||
# são credenciais de envio — senha de e-mail e token do Twilio —, então só ADM.
|
||||
#
|
||||
# ⚠️ Cada action nova do controller precisa de um método `<action>?` aqui,
|
||||
# senão o `authorize` estoura.
|
||||
class ConfiguracaoNotificacaoPolicy < ApplicationPolicy
|
||||
def show?
|
||||
admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
admin?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
scope.all
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/services/notificacao/cliente_twilio.rb
Normal file
26
app/services/notificacao/cliente_twilio.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# app/services/notificacao/cliente_twilio.rb
|
||||
#
|
||||
# Monta o Twilio::REST::Client com timeout. Sem timeout, uma saída de internet
|
||||
# bloqueada no servidor pendura o worker Puma até o proxy cortar a requisição —
|
||||
# e o botão "enviar teste" da tela ficaria girando sem resposta.
|
||||
#
|
||||
# O construtor com http_client é posicional na twilio-ruby 7.x
|
||||
# (username, password, account_sid, region, http_client). Se a assinatura mudar
|
||||
# numa atualização da gem, cai no construtor simples em vez de quebrar o envio.
|
||||
module Notificacao
|
||||
module ClienteTwilio
|
||||
TIMEOUT = 15
|
||||
|
||||
def self.montar(sid, token)
|
||||
require 'twilio-ruby'
|
||||
|
||||
begin
|
||||
http = Twilio::HTTP::Client.new(timeout: TIMEOUT)
|
||||
Twilio::REST::Client.new(sid, token, nil, nil, http)
|
||||
rescue ArgumentError, NameError => e
|
||||
Rails.logger.warn("[ClienteTwilio] sem timeout customizado (#{e.class}: #{e.message})")
|
||||
Twilio::REST::Client.new(sid, token)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
27
app/services/notificacao/resultado.rb
Normal file
27
app/services/notificacao/resultado.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# app/services/notificacao/resultado.rb
|
||||
#
|
||||
# Retorno dos testes de envio. Existe para o controller não precisar saber nada
|
||||
# sobre SMTP nem sobre a API do Twilio — só perguntar `ok?` e jogar no flash.
|
||||
module Notificacao
|
||||
Resultado = Struct.new(:ok, :mensagem, :detalhe, keyword_init: true) do
|
||||
def self.sucesso(mensagem)
|
||||
new(ok: true, mensagem: mensagem)
|
||||
end
|
||||
|
||||
def self.falha(mensagem, detalhe = nil)
|
||||
new(ok: false, mensagem: mensagem, detalhe: detalhe)
|
||||
end
|
||||
|
||||
def ok?
|
||||
!!ok
|
||||
end
|
||||
|
||||
# O detalhe técnico (mensagem crua do servidor/Twilio) só aparece no erro —
|
||||
# é o que permite ao admin resolver sozinho sem abrir o log do container.
|
||||
def para_flash
|
||||
texto = mensagem.to_s
|
||||
texto += " [detalhe técnico: #{detalhe.to_s.truncate(180)}]" if !ok? && detalhe.present?
|
||||
{ ok? ? :notice : :alert => texto }
|
||||
end
|
||||
end
|
||||
end
|
||||
69
app/services/notificacao/teste_email.rb
Normal file
69
app/services/notificacao/teste_email.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# app/services/notificacao/teste_email.rb
|
||||
#
|
||||
# Dispara um e-mail de teste com as credenciais que ACABARAM de ser salvas e
|
||||
# devolve o erro REAL traduzido para português. É o oposto do NotificacaoService,
|
||||
# que engole as falhas de propósito para não derrubar o fechamento — aqui o
|
||||
# objetivo é justamente mostrar o problema.
|
||||
module Notificacao
|
||||
class TesteEmail
|
||||
def initialize(config = ConfiguracaoNotificacao.instancia, destinatario: nil, usuario: nil)
|
||||
@config = config
|
||||
@destinatario = destinatario.presence || config.email_admin.presence
|
||||
@usuario = usuario
|
||||
end
|
||||
|
||||
def executar
|
||||
if @destinatario.blank?
|
||||
return Resultado.falha('Preencha o "E-mail do administrador" antes de enviar o teste.')
|
||||
end
|
||||
|
||||
unless @config.smtp_pronto?
|
||||
return Resultado.falha('Marque "Ativar envio de e-mail" e preencha servidor, porta, ' \
|
||||
'usuário e senha antes de testar.')
|
||||
end
|
||||
|
||||
entrega = TesteMailer.teste(@destinatario, @config, @usuario)
|
||||
# development.rb define raise_delivery_errors = false e o mail() copia isso
|
||||
# para a mensagem — sem estas duas linhas o teste "passaria" em silêncio.
|
||||
entrega.message.raise_delivery_errors = true
|
||||
entrega.message.perform_deliveries = true
|
||||
# NUNCA deliver_later: o adapter é :async (thread in-process) e a exceção
|
||||
# nunca voltaria para o flash.
|
||||
entrega.deliver_now
|
||||
|
||||
Resultado.sucesso("E-mail de teste enviado para #{@destinatario}. " \
|
||||
'Confira a caixa de entrada e também o spam.')
|
||||
rescue Net::SMTPAuthenticationError => e
|
||||
Resultado.falha('Usuário ou senha do SMTP recusados pelo servidor. No Gmail/Google ' \
|
||||
'Workspace é preciso usar uma "Senha de app" de 16 caracteres, ' \
|
||||
'não a senha da conta.', e.message)
|
||||
rescue Net::SMTPSyntaxError, Net::SMTPFatalError => e
|
||||
Resultado.falha("O servidor recusou a mensagem. Normalmente o remetente " \
|
||||
"(#{@config.remetente_email.presence || @config.smtp_username}) precisa ser " \
|
||||
'o mesmo usuário que autenticou.', e.message)
|
||||
rescue Net::SMTPServerBusy => e
|
||||
Resultado.falha('Servidor SMTP ocupado ou aplicando limite de envio. ' \
|
||||
'Tente de novo em alguns minutos.', e.message)
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e
|
||||
Resultado.falha("Tempo esgotado ao conectar em #{@config.smtp_address}:#{@config.smtp_port}. " \
|
||||
'A porta pode estar bloqueada pelo provedor ou pelo firewall — ' \
|
||||
'tente 587 (STARTTLS) ou 465 (SSL).', e.message)
|
||||
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH => e
|
||||
Resultado.falha("Conexão recusada por #{@config.smtp_address}:#{@config.smtp_port}. " \
|
||||
'Servidor ou porta incorretos.', e.message)
|
||||
rescue SocketError => e
|
||||
Resultado.falha("Não foi possível resolver o endereço \"#{@config.smtp_address}\". " \
|
||||
'Confira a grafia do servidor.', e.message)
|
||||
rescue OpenSSL::SSL::SSLError => e
|
||||
Resultado.falha('Erro de TLS/SSL. A porta 465 exige SSL direto e a 587 exige STARTTLS — ' \
|
||||
'confira se a porta escolhida bate com o servidor.', e.message)
|
||||
rescue Net::SMTPError, EOFError, IOError => e
|
||||
# Net::SMTPError é um módulo incluído nas classes acima; por isso vem
|
||||
# depois delas, como rede de segurança.
|
||||
Resultado.falha("O servidor SMTP interrompeu a conexão: #{e.message}", e.message)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[TesteEmail] #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
|
||||
Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
74
app/services/notificacao/teste_whatsapp.rb
Normal file
74
app/services/notificacao/teste_whatsapp.rb
Normal file
@@ -0,0 +1,74 @@
|
||||
# app/services/notificacao/teste_whatsapp.rb
|
||||
#
|
||||
# Dispara um WhatsApp de teste pelo Twilio para o número administrativo e
|
||||
# traduz o código de erro da API para uma instrução acionável em português —
|
||||
# os erros de Sandbox (opt-in) são de longe os mais comuns e o código cru
|
||||
# ("63003") não diz nada para quem está configurando.
|
||||
module Notificacao
|
||||
class TesteWhatsapp
|
||||
MENSAGENS = {
|
||||
20003 => 'Account SID ou Auth Token inválidos: o Twilio recusou a autenticação.',
|
||||
20404 => 'Account SID não encontrado no Twilio. Confira se copiou o SID certo (começa com "AC").',
|
||||
21211 => 'Número de destino inválido. Informe com DDD, ex.: 11 92005-1157.',
|
||||
21606 => 'O número remetente não é um número Twilio habilitado para envio.',
|
||||
21608 => 'Conta trial do Twilio: só é possível enviar para números verificados no console da Twilio.',
|
||||
21610 => 'Este número pediu descadastro (enviou STOP). Ele precisa enviar START para voltar a receber.',
|
||||
63003 => 'O Twilio não encontrou o destinatário nesse canal. No Sandbox do WhatsApp o número ' \
|
||||
'precisa enviar "join <sua-palavra>" para o número do sandbox ANTES de conseguir receber.',
|
||||
63007 => 'O número remetente não está habilitado como canal de WhatsApp. Use exatamente o ' \
|
||||
'número do Sandbox (ou do Sender aprovado) do Twilio.',
|
||||
63015 => 'No Sandbox só dá para enviar a números que fizeram o opt-in ("join <sua-palavra>").',
|
||||
63016 => 'Passou da janela de 24h desde a última mensagem do destinatário — nesse caso o Twilio ' \
|
||||
'só aceita template aprovado. Peça para ele mandar qualquer mensagem e tente de novo.',
|
||||
63018 => 'Limite de mensagens do Twilio atingido. Aguarde e tente novamente.',
|
||||
63038 => 'Limite diário de mensagens da conta Twilio atingido.'
|
||||
}.freeze
|
||||
|
||||
def initialize(config = ConfiguracaoNotificacao.instancia, destino: nil)
|
||||
@config = config
|
||||
@destino = ConfiguracaoNotificacao.canal(destino.presence || config.whatsapp_admin)
|
||||
end
|
||||
|
||||
def executar
|
||||
if @destino.blank?
|
||||
return Resultado.falha('Preencha o "WhatsApp do administrador" com DDD ' \
|
||||
'(ex.: 11 92005-1157) antes de testar.')
|
||||
end
|
||||
|
||||
credenciais = @config.credenciais_whatsapp
|
||||
if credenciais.blank?
|
||||
return Resultado.falha('Marque "Ativar WhatsApp" e preencha Account SID, Auth Token e o ' \
|
||||
'número remetente do Twilio.')
|
||||
end
|
||||
if credenciais[:from].blank?
|
||||
return Resultado.falha('O número remetente do Twilio está vazio ou em formato inválido.')
|
||||
end
|
||||
|
||||
msg = ClienteTwilio
|
||||
.montar(credenciais[:sid], credenciais[:token])
|
||||
.messages.create(from: credenciais[:from], to: @destino, body: corpo)
|
||||
|
||||
Resultado.sucesso("WhatsApp de teste aceito pelo Twilio para " \
|
||||
"#{@destino.sub('whatsapp:', '')} (SID #{msg.sid}, status \"#{msg.status}\"). " \
|
||||
'Atenção: "queued"/"accepted" significa que a Twilio recebeu — ' \
|
||||
'a entrega final ainda pode falhar depois.')
|
||||
rescue Twilio::REST::RestError => e
|
||||
Resultado.falha(MENSAGENS[e.code] || "O Twilio recusou o envio (código #{e.code}).",
|
||||
"[#{e.code}] #{e.message}")
|
||||
rescue Twilio::REST::TwilioError => e
|
||||
Resultado.falha('Não foi possível falar com a API do Twilio (rede ou timeout). ' \
|
||||
'Verifique a saída de internet do servidor.', e.message)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[TesteWhatsapp] #{e.class}: #{e.message}")
|
||||
Resultado.falha("Falha inesperada ao enviar (#{e.class}).", e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
"✅ Teste do Reem Notas\n" \
|
||||
"Se você recebeu esta mensagem, a integração com o Twilio está funcionando.\n" \
|
||||
"Enviado em #{Time.current.strftime('%d/%m/%Y às %H:%M')}."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,27 @@
|
||||
# app/services/notificacao_service.rb
|
||||
#
|
||||
# Envia notificações aos motoristas quando uma consolidação é finalizada.
|
||||
# Canais controlados pelas configurações: notificacao_whatsapp / notificacao_email.
|
||||
# Falhas de envio NUNCA quebram o fluxo de finalização (apenas logam).
|
||||
# Envia notificações aos motoristas quando uma consolidação é finalizada ou
|
||||
# quando o pagamento é efetuado.
|
||||
#
|
||||
# Canais e credenciais vêm de ConfiguracaoNotificacao (tela /admin/
|
||||
# configuracao_notificacao), com fallback para o .env.
|
||||
#
|
||||
# Falhas de envio NUNCA quebram o fluxo de finalização — apenas logam. Isso é
|
||||
# intencional: um SMTP fora do ar não pode impedir o fechamento de um pagamento.
|
||||
# Para DIAGNOSTICAR o envio, use os botões de teste da tela, que usam
|
||||
# Notificacao::TesteEmail / Notificacao::TesteWhatsapp e mostram o erro real.
|
||||
class NotificacaoService
|
||||
EVENTOS = {
|
||||
fechado: { mailer: :pagamento_fechado,
|
||||
emoji: '🚚',
|
||||
titulo: 'Seu pagamento de entregas foi fechado.',
|
||||
cta: 'Acesse seu painel para baixar o extrato:' },
|
||||
pago: { mailer: :pagamento_efetuado,
|
||||
emoji: '✅',
|
||||
titulo: 'Seu pagamento foi efetuado.',
|
||||
cta: 'Acesse seu painel para conferir:' }
|
||||
}.freeze
|
||||
|
||||
def self.notificar_finalizacao(consolidacao)
|
||||
new(consolidacao).notificar_todos
|
||||
end
|
||||
@@ -16,106 +33,73 @@ class NotificacaoService
|
||||
|
||||
def initialize(consolidacao)
|
||||
@consolidacao = consolidacao
|
||||
@config = ConfiguracaoNotificacao.instancia
|
||||
end
|
||||
|
||||
def notificar_todos
|
||||
@consolidacao.consolidacao_motoristas.ativos.each do |cm|
|
||||
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
next unless user
|
||||
|
||||
enviar_whatsapp(user, cm) if whatsapp_ativo?
|
||||
enviar_email(user, cm) if email_ativo? && user.email.present?
|
||||
end
|
||||
@consolidacao.consolidacao_motoristas.ativos.each { |cm| notificar(cm, :fechado) }
|
||||
end
|
||||
|
||||
def enviar_aviso_pagamento(cm)
|
||||
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
return unless user
|
||||
|
||||
enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo?
|
||||
enviar_email_pagamento(user, cm) if email_ativo? && user.email.present?
|
||||
def enviar_aviso_pagamento(consolidacao_motorista)
|
||||
notificar(consolidacao_motorista, :pago)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def whatsapp_ativo?
|
||||
Configuracao.valor('notificacao_whatsapp') == 'true' &&
|
||||
ENV['TWILIO_ACCOUNT_SID'].present?
|
||||
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?
|
||||
end
|
||||
|
||||
def email_ativo?
|
||||
Configuracao.valor('notificacao_email') == 'true'
|
||||
def motorista_de(cm)
|
||||
User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
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)
|
||||
return if destino.blank?
|
||||
|
||||
credenciais = @config.credenciais_whatsapp
|
||||
return if credenciais.blank? || credenciais[:from].blank?
|
||||
|
||||
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}")
|
||||
end
|
||||
|
||||
def enviar_email(user, cm, evento)
|
||||
ConsolidacaoMailer
|
||||
.public_send(EVENTOS.fetch(evento)[:mailer], user, @consolidacao, cm)
|
||||
.deliver_later
|
||||
Rails.logger.info("[Notificacao] Email (#{evento}) agendado para #{user.email}")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Notificacao] Falha email (#{evento}) #{user.email}: #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
def mensagem(user, cm, evento)
|
||||
evt = EVENTOS.fetch(evento)
|
||||
"Olá #{user.nome.split.first}! #{evt[:emoji]}\n" \
|
||||
"#{evt[:titulo]}\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{moeda(cm.valor_total)}\n" \
|
||||
"#{evt[:cta]} https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
# Moeda BR com separador de milhar: R$ 1.234,56
|
||||
def moeda(v)
|
||||
inteiro, decimais = format('%.2f', v.to_f).split('.')
|
||||
def moeda(valor)
|
||||
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
"R$ #{inteiro},#{decimais}"
|
||||
end
|
||||
|
||||
def mensagem(user, cm)
|
||||
valor = moeda(cm.valor_total)
|
||||
"Olá #{user.nome.split.first}! 🚚\n" \
|
||||
"Seu pagamento de entregas foi fechado.\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{valor}\n" \
|
||||
"Acesse seu painel para baixar o extrato: " \
|
||||
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
def enviar_whatsapp(user, cm)
|
||||
return if user.telefone.blank?
|
||||
|
||||
require 'twilio-ruby'
|
||||
cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN'])
|
||||
cliente.messages.create(
|
||||
from: ENV['TWILIO_WHATSAPP_FROM'],
|
||||
to: "whatsapp:#{user.telefone}",
|
||||
body: mensagem(user, cm)
|
||||
)
|
||||
Rails.logger.info("[Notificacao] WhatsApp enviado para #{user.nome}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha WhatsApp #{user.nome}: #{e.message}")
|
||||
end
|
||||
|
||||
def enviar_email(user, cm)
|
||||
ConsolidacaoMailer.pagamento_fechado(user, @consolidacao, cm).deliver_later
|
||||
Rails.logger.info("[Notificacao] Email agendado para #{user.email}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}")
|
||||
end
|
||||
|
||||
# ── Aviso de pagamento efetuado ─────────────────────────────
|
||||
def mensagem_pagamento(user, cm)
|
||||
valor = moeda(cm.valor_total)
|
||||
"Olá #{user.nome.split.first}! ✅\n" \
|
||||
"Seu pagamento foi efetuado.\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{valor}\n" \
|
||||
"Acesse seu painel para conferir: " \
|
||||
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
def enviar_whatsapp_pagamento(user, cm)
|
||||
return if user.telefone.blank?
|
||||
|
||||
require 'twilio-ruby'
|
||||
cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN'])
|
||||
cliente.messages.create(
|
||||
from: ENV['TWILIO_WHATSAPP_FROM'],
|
||||
to: "whatsapp:#{user.telefone}",
|
||||
body: mensagem_pagamento(user, cm)
|
||||
)
|
||||
Rails.logger.info("[Notificacao] WhatsApp pagamento enviado para #{user.nome}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha WhatsApp pagamento #{user.nome}: #{e.message}")
|
||||
end
|
||||
|
||||
def enviar_email_pagamento(user, cm)
|
||||
ConsolidacaoMailer.pagamento_efetuado(user, @consolidacao, cm).deliver_later
|
||||
Rails.logger.info("[Notificacao] Email pagamento agendado para #{user.email}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha email pagamento #{user.email}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
262
app/views/admin/configuracao_notificacoes/show.html.erb
Normal file
262
app/views/admin/configuracao_notificacoes/show.html.erb
Normal file
@@ -0,0 +1,262 @@
|
||||
<%# app/views/admin/configuracao_notificacoes/show.html.erb %>
|
||||
<%
|
||||
input = 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1
|
||||
focus:ring-[#f97316] transition-colors'
|
||||
rotulo = 'block text-sm font-medium text-gray-300 mb-1.5'
|
||||
card = 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6'
|
||||
toggle = "w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]"
|
||||
ajuda = 'text-gray-500 text-xs mt-1.5'
|
||||
botao_2 = 'px-6 py-3 border border-white/10 hover:border-white/20 text-gray-300
|
||||
hover:text-white rounded-xl transition-colors cursor-pointer min-h-[48px]'
|
||||
%>
|
||||
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<%= icone :email, espaco: false %> Notificações e E-mail
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Servidor de e-mail (SMTP) e WhatsApp (Twilio) usados pelo sistema
|
||||
</p>
|
||||
</div>
|
||||
<%= link_to 'Voltar', admin_configuracoes_path,
|
||||
class: 'px-4 py-2 text-gray-400 hover:text-white border border-white/10
|
||||
hover:border-white/20 rounded-xl transition-colors text-sm whitespace-nowrap' %>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<%# Status atual — o que o sistema REALMENTE vai usar agora %>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-[#1a1a1a] border border-white/5 rounded-xl text-sm">
|
||||
<span class="w-2 h-2 rounded-full <%= @config.smtp_pronto? ? 'bg-green-400' : 'bg-gray-600' %>"></span>
|
||||
<span class="text-gray-400">E-mail:</span>
|
||||
<span class="<%= @config.smtp_pronto? ? 'text-green-400' : 'text-gray-500' %> font-medium">
|
||||
<%= @config.smtp_pronto? ? 'configurado' : 'usando .env / desligado' %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-[#1a1a1a] border border-white/5 rounded-xl text-sm">
|
||||
<span class="w-2 h-2 rounded-full <%= @config.whatsapp_pronto? ? 'bg-green-400' : 'bg-gray-600' %>"></span>
|
||||
<span class="text-gray-400">WhatsApp:</span>
|
||||
<span class="<%= @config.whatsapp_pronto? ? 'text-green-400' : 'text-gray-500' %> font-medium">
|
||||
<%= @config.whatsapp_pronto? ? 'configurado' : 'usando .env / desligado' %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Segredo gravado que não pôde ser decifrado (SECRET_KEY_BASE rotacionado) %>
|
||||
<% ilegiveis = [] %>
|
||||
<% ilegiveis << 'senha do SMTP' if @config.smtp_password_cifrado.present? && @config.smtp_password.blank? %>
|
||||
<% ilegiveis << 'token do Twilio' if @config.twilio_auth_token_cifrado.present? && @config.twilio_auth_token.blank? %>
|
||||
<% if ilegiveis.any? %>
|
||||
<div class="p-4 bg-yellow-900/30 border border-yellow-500/40 rounded-xl text-yellow-300 text-sm">
|
||||
Não foi possível ler <%= ilegiveis.to_sentence %> que estava gravada no banco
|
||||
(o <code class="font-mono">SECRET_KEY_BASE</code> do servidor mudou).
|
||||
Digite novamente e salve. Enquanto isso o sistema usa o que está no arquivo <code class="font-mono">.env</code>.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with(model: @config, url: admin_configuracao_notificacao_path, method: :patch,
|
||||
class: 'space-y-6') do |f| %>
|
||||
|
||||
<% if @config.errors.any? %>
|
||||
<div class="p-4 bg-red-900/30 border border-red-500/40 rounded-xl">
|
||||
<p class="text-red-400 text-sm font-medium mb-2">
|
||||
<%= pluralize(@config.errors.count, 'erro', 'erros') %> encontrado<%= @config.errors.count > 1 ? 's' : '' %>:
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<% @config.errors.full_messages.each do |msg| %>
|
||||
<li class="text-red-300 text-sm"><%= msg %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# ─────────────────────────── E-MAIL / SMTP ─────────────────────────── %>
|
||||
<div class="<%= card %>">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<%= icone :email, espaco: false %> Servidor de e-mail (SMTP)
|
||||
</h2>
|
||||
<p class="text-gray-500 text-xs mt-0.5">
|
||||
Usado no envio de extratos, avisos de pagamento e recuperação de senha
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-4">
|
||||
<%= f.check_box :smtp_ativo, class: 'sr-only peer' %>
|
||||
<div class="<%= toggle %>"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= f.label :smtp_address, 'Servidor', class: rotulo %>
|
||||
<%= f.text_field :smtp_address, class: input, placeholder: 'smtp.gmail.com' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :smtp_port, 'Porta', class: rotulo %>
|
||||
<%= f.number_field :smtp_port, min: 1, max: 65535, class: input, placeholder: '587' %>
|
||||
<p class="<%= ajuda %>">587 para STARTTLS (padrão) ou 465 para SSL direto.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :smtp_username, 'Usuário', class: rotulo %>
|
||||
<%= f.text_field :smtp_username, autocomplete: 'off', class: input,
|
||||
placeholder: 'sistema@suaempresa.com.br' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :smtp_password, 'Senha', class: rotulo %>
|
||||
<%# value: nil — a senha gravada NUNCA volta para o HTML. %>
|
||||
<%= f.password_field :smtp_password, value: nil, autocomplete: 'new-password', class: input,
|
||||
placeholder: @config.smtp_password? ? '•••••••• (em branco mantém a atual)' : 'senha ou senha de app' %>
|
||||
<p class="<%= ajuda %>">
|
||||
No Gmail/Google Workspace use uma <strong>Senha de app</strong> de 16 caracteres,
|
||||
não a senha da conta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :smtp_autenticacao, 'Autenticação', class: rotulo %>
|
||||
<%= f.select :smtp_autenticacao,
|
||||
options_for_select(ConfiguracaoNotificacao::AUTENTICACOES, @config.smtp_autenticacao),
|
||||
{}, class: "#{input} cursor-pointer" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :smtp_domain, 'Domínio (opcional)', class: rotulo %>
|
||||
<%= f.text_field :smtp_domain, class: input, placeholder: 'suaempresa.com.br' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :remetente_email, 'E-mail remetente', class: rotulo %>
|
||||
<%= f.email_field :remetente_email, class: input, placeholder: 'nao-responda@suaempresa.com.br' %>
|
||||
<p class="<%= ajuda %>">Em branco, usa o próprio usuário do SMTP.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :remetente_nome, 'Nome do remetente', class: rotulo %>
|
||||
<%= f.text_field :remetente_nome, class: input, placeholder: 'Reem Transporte' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center border-t border-white/5 pt-6">
|
||||
<div class="flex-1">
|
||||
<%= f.label :email_notificacoes_ativo, 'Avisar motoristas por e-mail', class: 'block text-sm font-medium text-gray-300' %>
|
||||
<p class="text-gray-500 text-xs mt-0.5">
|
||||
Envia o aviso de pagamento fechado e de pagamento efetuado para o motorista
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-4">
|
||||
<%= f.check_box :email_notificacoes_ativo, class: 'sr-only peer' %>
|
||||
<div class="<%= toggle %>"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ────────────────────────── WHATSAPP / TWILIO ───────────────────────── %>
|
||||
<div class="<%= card %>">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<%= icone :telefone, espaco: false %> WhatsApp (Twilio)
|
||||
</h2>
|
||||
<p class="text-gray-500 text-xs mt-0.5">
|
||||
Credenciais em Twilio Console → Account Info
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-4">
|
||||
<%= f.check_box :whatsapp_ativo, class: 'sr-only peer' %>
|
||||
<div class="<%= toggle %>"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= f.label :twilio_account_sid, 'Account SID', class: rotulo %>
|
||||
<%= f.text_field :twilio_account_sid, autocomplete: 'off', class: "#{input} font-mono text-sm",
|
||||
placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :twilio_auth_token, 'Auth Token', class: rotulo %>
|
||||
<%= f.password_field :twilio_auth_token, value: nil, autocomplete: 'new-password',
|
||||
class: "#{input} font-mono text-sm",
|
||||
placeholder: @config.twilio_auth_token? ? '•••••••• (em branco mantém o atual)' : 'token da conta Twilio' %>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<%= f.label :twilio_from, 'Número remetente (o que envia)', class: rotulo %>
|
||||
<%= f.text_field :twilio_from, class: input, placeholder: '+1 415 523 8886 (sandbox) ou seu número aprovado' %>
|
||||
<p class="<%= ajuda %>">
|
||||
Precisa ser o número do <strong>Sandbox</strong> ou um Sender aprovado no Twilio —
|
||||
não dá para usar um número de WhatsApp comum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-[#0a0a0a] border border-white/5 rounded-xl">
|
||||
<p class="text-gray-400 text-xs leading-relaxed">
|
||||
<strong class="text-gray-300">Sandbox do Twilio:</strong> antes do primeiro envio, o
|
||||
número que vai <em>receber</em> precisa mandar <code class="font-mono text-[#f97316]">join <sua-palavra></code>
|
||||
no WhatsApp para o número do sandbox. Sem esse opt-in o Twilio recusa com o erro 63003/63015.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ───────────────────── DESTINATÁRIOS ADMINISTRATIVOS ────────────────── %>
|
||||
<div class="<%= card %>">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<%= icone :pessoa, espaco: false %> Destinatários administrativos
|
||||
</h2>
|
||||
<p class="text-gray-500 text-xs mt-0.5">
|
||||
Para onde vão os testes abaixo e os avisos destinados à administração
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= f.label :email_admin, 'E-mail do administrador', class: rotulo %>
|
||||
<%= f.email_field :email_admin, class: input, placeholder: 'voce@suaempresa.com.br' %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :whatsapp_admin, 'WhatsApp do administrador', class: rotulo %>
|
||||
<%= f.text_field :whatsapp_admin, class: input, placeholder: '11 92005-1157' %>
|
||||
<p class="<%= ajuda %>">Com DDD. É gravado no formato internacional (+55…).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ──────────────────────────────── AÇÕES ────────────────────────────── %>
|
||||
<%# Os três botões submetem ESTE form; o controller decide pelo name="acao".
|
||||
Assim o teste roda sobre o que está na tela, já salvo. %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<%= f.button 'Salvar', type: :submit, name: 'acao', value: 'salvar',
|
||||
class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold
|
||||
rounded-xl transition-colors cursor-pointer min-h-[48px]' %>
|
||||
|
||||
<%= f.button 'Salvar e enviar e-mail de teste', type: :submit,
|
||||
name: 'acao', value: 'testar_email', class: botao_2 %>
|
||||
|
||||
<%= f.button 'Salvar e enviar WhatsApp de teste', type: :submit,
|
||||
name: 'acao', value: 'testar_whatsapp', class: botao_2 %>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs">
|
||||
Os testes salvam as alterações antes de enviar e mostram o erro exato retornado
|
||||
pelo servidor de e-mail ou pelo Twilio.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -8,6 +8,26 @@
|
||||
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<%# Configurações sensíveis (credenciais de envio) — só ADM %>
|
||||
<% if current_user.admin? %>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :travado, espaco: false %> Configurações do Administrador</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(18rem,1fr))] gap-4">
|
||||
<%= link_to admin_configuracao_notificacao_path,
|
||||
class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 hover:border-[#f97316]/50
|
||||
p-6 flex items-start gap-4 transition-colors group' do %>
|
||||
<div class="text-3xl">✉️</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white font-semibold group-hover:text-[#f97316] transition-colors">Notificações e E-mail</p>
|
||||
<p class="text-gray-500 text-xs mt-1 leading-relaxed">
|
||||
Servidor de e-mail (SMTP), WhatsApp via Twilio e os contatos que recebem os avisos da administração
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Pilares de preço %>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> Pilares de Preço</h2>
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<%= nav_link_to 'Planilha SimpliRoute', admin_planilha_simpli_route_path, icon: :planilha %>
|
||||
<% if current_user.admin? %>
|
||||
<%= nav_link_to 'Editar Lançamento', admin_edicao_lancamento_path, icon: :editar_lancamento %>
|
||||
<%= nav_link_to 'Notificações', admin_configuracao_notificacao_path, icon: :email %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
42
app/views/teste_mailer/teste.html.erb
Normal file
42
app/views/teste_mailer/teste.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<%# app/views/teste_mailer/teste.html.erb %>
|
||||
<div style="font-family: Arial, sans-serif; max-width: 520px; margin: 0 auto; background: #0a0a0a; border-radius: 12px; overflow: hidden;">
|
||||
|
||||
<div style="background: #0a0a0a; padding: 24px; border-left: 6px solid #f97316;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 20px;">REEM TRANSPORTE</h1>
|
||||
<p style="color: #f97316; margin: 4px 0 0; font-size: 13px;">Sistema de Logística</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #ffffff; padding: 28px;">
|
||||
<h2 style="color: #0a0a0a; margin-top: 0;">✅ Configuração de e-mail funcionando</h2>
|
||||
|
||||
<p style="color: #374151; font-size: 15px; line-height: 1.6;">
|
||||
Se você está lendo esta mensagem, o servidor de e-mail cadastrado na tela
|
||||
<strong>Configurações → Notificações e E-mail</strong> está enviando corretamente.
|
||||
</p>
|
||||
|
||||
<table style="width: 100%; margin: 20px 0; border-collapse: collapse; font-size: 14px;">
|
||||
<tr>
|
||||
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Servidor</td>
|
||||
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @config.smtp_address %>:<%= @config.smtp_port %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Remetente</td>
|
||||
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @config.remetente_formatado %></td>
|
||||
</tr>
|
||||
<% if @usuario %>
|
||||
<tr>
|
||||
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Disparado por</td>
|
||||
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @usuario.nome_display %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<tr>
|
||||
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Data/hora</td>
|
||||
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @quando.strftime('%d/%m/%Y às %H:%M') %></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
|
||||
Mensagem automática de teste — não é necessário responder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user