diff --git a/.env.example b/.env.example index d07d150..709af88 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,15 @@ SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui # deixe vazio/false para não forçar https em localhost. FORCE_SSL=true +# ══════════════════════════════════════════════════════════════ +# NOTIFICAÇÕES (WhatsApp e e-mail) +# +# ⚠️ Estas variáveis são apenas FALLBACK. A configuração preferencial fica no +# BANCO e é editada pelo ADM em Configurações → Notificações e E-mail +# (/admin/configuracao_notificacao), valendo sem reiniciar o container. +# Enquanto a tela não for preenchida e ativada, valem os valores abaixo. +# ══════════════════════════════════════════════════════════════ + # ── Twilio — WhatsApp (opcional, Fase 8) ───────────────────── TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= @@ -57,6 +66,15 @@ SMTP_USERNAME= SMTP_PASSWORD= SMTP_DOMAIN=gade.com.br +# ── Criptografia dos segredos gravados pela tela (opcional) ── +# A senha do SMTP e o token do Twilio salvos no banco são cifrados com uma +# chave derivada do SECRET_KEY_BASE. Se você trocar o SECRET_KEY_BASE, esses +# dois valores viram ilegíveis (o sistema volta a usar o .env e a tela avisa +# para redigitar). Defina NOTIFICACAO_SECRET com uma string longa e FIXA para +# desacoplar os segredos da rotação do SECRET_KEY_BASE. +# ⚠️ Se mudar depois de já ter salvo, os valores gravados também se perdem. +NOTIFICACAO_SECRET= + # ── App ─────────────────────────────────────────────────────── APP_HOST=localhost:3000 APP_NAME=Reem Logística diff --git a/app/controllers/admin/configuracao_notificacoes_controller.rb b/app/controllers/admin/configuracao_notificacoes_controller.rb new file mode 100644 index 0000000..1352454 --- /dev/null +++ b/app/controllers/admin/configuracao_notificacoes_controller.rb @@ -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 diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 60e89c5..c74dca7 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -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 diff --git a/app/mailers/consolidacao_mailer.rb b/app/mailers/consolidacao_mailer.rb index 6b07434..481be47 100644 --- a/app/mailers/consolidacao_mailer.rb +++ b/app/mailers/consolidacao_mailer.rb @@ -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 diff --git a/app/mailers/teste_mailer.rb b/app/mailers/teste_mailer.rb new file mode 100644 index 0000000..b36cff3 --- /dev/null +++ b/app/mailers/teste_mailer.rb @@ -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 diff --git a/app/models/concerns/atributo_cifrado.rb b/app/models/concerns/atributo_cifrado.rb new file mode 100644 index 0000000..f65d18c --- /dev/null +++ b/app/models/concerns/atributo_cifrado.rb @@ -0,0 +1,71 @@ +# app/models/concerns/atributo_cifrado.rb +# +# Cifra atributos sensíveis (senha SMTP, token do Twilio) em colunas +# `_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 diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index d42932a..2143321 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -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 diff --git a/app/models/configuracao_notificacao.rb b/app/models/configuracao_notificacao.rb new file mode 100644 index 0000000..64eb8df --- /dev/null +++ b/app/models/configuracao_notificacao.rb @@ -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 diff --git a/app/policies/configuracao_notificacao_policy.rb b/app/policies/configuracao_notificacao_policy.rb new file mode 100644 index 0000000..04b85c6 --- /dev/null +++ b/app/policies/configuracao_notificacao_policy.rb @@ -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 `?` 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 diff --git a/app/services/notificacao/cliente_twilio.rb b/app/services/notificacao/cliente_twilio.rb new file mode 100644 index 0000000..7e9868e --- /dev/null +++ b/app/services/notificacao/cliente_twilio.rb @@ -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 diff --git a/app/services/notificacao/resultado.rb b/app/services/notificacao/resultado.rb new file mode 100644 index 0000000..e259850 --- /dev/null +++ b/app/services/notificacao/resultado.rb @@ -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 diff --git a/app/services/notificacao/teste_email.rb b/app/services/notificacao/teste_email.rb new file mode 100644 index 0000000..81e2fca --- /dev/null +++ b/app/services/notificacao/teste_email.rb @@ -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 diff --git a/app/services/notificacao/teste_whatsapp.rb b/app/services/notificacao/teste_whatsapp.rb new file mode 100644 index 0000000..9f1b93f --- /dev/null +++ b/app/services/notificacao/teste_whatsapp.rb @@ -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 " 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 ").', + 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 diff --git a/app/services/notificacao_service.rb b/app/services/notificacao_service.rb index 53fee92..6f30929 100644 --- a/app/services/notificacao_service.rb +++ b/app/services/notificacao_service.rb @@ -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 diff --git a/app/views/admin/configuracao_notificacoes/show.html.erb b/app/views/admin/configuracao_notificacoes/show.html.erb new file mode 100644 index 0000000..62fdf5f --- /dev/null +++ b/app/views/admin/configuracao_notificacoes/show.html.erb @@ -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]' +%> + +
+ +
+
+

+ <%= icone :email, espaco: false %> Notificações e E-mail +

+

+ Servidor de e-mail (SMTP) e WhatsApp (Twilio) usados pelo sistema +

+
+ <%= 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' %> +
+ + <%= render 'shared/flash' %> + + <%# Status atual — o que o sistema REALMENTE vai usar agora %> +
+
+ + E-mail: + + <%= @config.smtp_pronto? ? 'configurado' : 'usando .env / desligado' %> + +
+
+ + WhatsApp: + + <%= @config.whatsapp_pronto? ? 'configurado' : 'usando .env / desligado' %> + +
+
+ + <%# 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? %> +
+ Não foi possível ler <%= ilegiveis.to_sentence %> que estava gravada no banco + (o SECRET_KEY_BASE do servidor mudou). + Digite novamente e salve. Enquanto isso o sistema usa o que está no arquivo .env. +
+ <% end %> + + <%= form_with(model: @config, url: admin_configuracao_notificacao_path, method: :patch, + class: 'space-y-6') do |f| %> + + <% if @config.errors.any? %> +
+

+ <%= pluralize(@config.errors.count, 'erro', 'erros') %> encontrado<%= @config.errors.count > 1 ? 's' : '' %>: +

+
    + <% @config.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + + <%# ─────────────────────────── E-MAIL / SMTP ─────────────────────────── %> +
+
+
+

+ <%= icone :email, espaco: false %> Servidor de e-mail (SMTP) +

+

+ Usado no envio de extratos, avisos de pagamento e recuperação de senha +

+
+ +
+ +
+
+ <%= f.label :smtp_address, 'Servidor', class: rotulo %> + <%= f.text_field :smtp_address, class: input, placeholder: 'smtp.gmail.com' %> +
+ +
+ <%= f.label :smtp_port, 'Porta', class: rotulo %> + <%= f.number_field :smtp_port, min: 1, max: 65535, class: input, placeholder: '587' %> +

587 para STARTTLS (padrão) ou 465 para SSL direto.

+
+ +
+ <%= f.label :smtp_username, 'Usuário', class: rotulo %> + <%= f.text_field :smtp_username, autocomplete: 'off', class: input, + placeholder: 'sistema@suaempresa.com.br' %> +
+ +
+ <%= 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' %> +

+ No Gmail/Google Workspace use uma Senha de app de 16 caracteres, + não a senha da conta. +

+
+ +
+ <%= f.label :smtp_autenticacao, 'Autenticação', class: rotulo %> + <%= f.select :smtp_autenticacao, + options_for_select(ConfiguracaoNotificacao::AUTENTICACOES, @config.smtp_autenticacao), + {}, class: "#{input} cursor-pointer" %> +
+ +
+ <%= f.label :smtp_domain, 'Domínio (opcional)', class: rotulo %> + <%= f.text_field :smtp_domain, class: input, placeholder: 'suaempresa.com.br' %> +
+ +
+ <%= f.label :remetente_email, 'E-mail remetente', class: rotulo %> + <%= f.email_field :remetente_email, class: input, placeholder: 'nao-responda@suaempresa.com.br' %> +

Em branco, usa o próprio usuário do SMTP.

+
+ +
+ <%= f.label :remetente_nome, 'Nome do remetente', class: rotulo %> + <%= f.text_field :remetente_nome, class: input, placeholder: 'Reem Transporte' %> +
+
+ +
+
+ <%= f.label :email_notificacoes_ativo, 'Avisar motoristas por e-mail', class: 'block text-sm font-medium text-gray-300' %> +

+ Envia o aviso de pagamento fechado e de pagamento efetuado para o motorista +

+
+ +
+
+ + <%# ────────────────────────── WHATSAPP / TWILIO ───────────────────────── %> +
+
+
+

+ <%= icone :telefone, espaco: false %> WhatsApp (Twilio) +

+

+ Credenciais em Twilio Console → Account Info +

+
+ +
+ +
+
+ <%= 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' %> +
+ +
+ <%= 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' %> +
+ +
+ <%= 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' %> +

+ Precisa ser o número do Sandbox ou um Sender aprovado no Twilio — + não dá para usar um número de WhatsApp comum. +

+
+
+ +
+

+ Sandbox do Twilio: antes do primeiro envio, o + número que vai receber precisa mandar join <sua-palavra> + no WhatsApp para o número do sandbox. Sem esse opt-in o Twilio recusa com o erro 63003/63015. +

+
+
+ + <%# ───────────────────── DESTINATÁRIOS ADMINISTRATIVOS ────────────────── %> +
+
+

+ <%= icone :pessoa, espaco: false %> Destinatários administrativos +

+

+ Para onde vão os testes abaixo e os avisos destinados à administração +

+
+ +
+
+ <%= f.label :email_admin, 'E-mail do administrador', class: rotulo %> + <%= f.email_field :email_admin, class: input, placeholder: 'voce@suaempresa.com.br' %> +
+ +
+ <%= f.label :whatsapp_admin, 'WhatsApp do administrador', class: rotulo %> + <%= f.text_field :whatsapp_admin, class: input, placeholder: '11 92005-1157' %> +

Com DDD. É gravado no formato internacional (+55…).

+
+
+
+ + <%# ──────────────────────────────── 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. %> +
+
+ <%= 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 %> +
+

+ Os testes salvam as alterações antes de enviar e mostram o erro exato retornado + pelo servidor de e-mail ou pelo Twilio. +

+
+ <% end %> +
diff --git a/app/views/admin/configuracoes/index.html.erb b/app/views/admin/configuracoes/index.html.erb index 8fbfee7..846710a 100644 --- a/app/views/admin/configuracoes/index.html.erb +++ b/app/views/admin/configuracoes/index.html.erb @@ -8,6 +8,26 @@ <%= render 'shared/flash' %> + <%# Configurações sensíveis (credenciais de envio) — só ADM %> + <% if current_user.admin? %> +
+

<%= icone :travado, espaco: false %> Configurações do Administrador

+
+ <%= 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 %> +
✉️
+
+

Notificações e E-mail

+

+ Servidor de e-mail (SMTP), WhatsApp via Twilio e os contatos que recebem os avisos da administração +

+
+ <% end %> +
+
+ <% end %> + <%# Pilares de preço %>

<%= icone :dinheiro, espaco: false %> Pilares de Preço

diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index 197d730..e07e198 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -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 %>
<% end %> diff --git a/app/views/teste_mailer/teste.html.erb b/app/views/teste_mailer/teste.html.erb new file mode 100644 index 0000000..6c300d3 --- /dev/null +++ b/app/views/teste_mailer/teste.html.erb @@ -0,0 +1,42 @@ +<%# app/views/teste_mailer/teste.html.erb %> +
+ +
+

REEM TRANSPORTE

+

Sistema de Logística

+
+ +
+

✅ Configuração de e-mail funcionando

+ +

+ Se você está lendo esta mensagem, o servidor de e-mail cadastrado na tela + Configurações → Notificações e E-mail está enviando corretamente. +

+ + + + + + + + + + + <% if @usuario %> + + + + + <% end %> + + + + +
Servidor<%= @config.smtp_address %>:<%= @config.smtp_port %>
Remetente<%= @config.remetente_formatado %>
Disparado por<%= @usuario.nome_display %>
Data/hora<%= @quando.strftime('%d/%m/%Y às %H:%M') %>
+ +

+ Mensagem automática de teste — não é necessário responder. +

+
+
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 93e0ff2..28162db 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,5 +1,12 @@ Devise.setup do |config| + # Fallback: só é consultado se o `default from:` do mailer sumir. config.mailer_sender = ENV.fetch('SMTP_USERNAME', 'noreply@gade.com.br') + + # Faz Devise::Mailer herdar de ApplicationMailer para que o "esqueci minha + # senha" use o SMTP e o remetente gravados no banco (tela /admin/ + # configuracao_notificacao). Efeito colateral: os e-mails do Devise passam a + # usar o layout app/views/layouts/mailer.html.erb. + config.parent_mailer = 'ApplicationMailer' require 'devise/orm/active_record' config.case_insensitive_keys = [:email] config.strip_whitespace_keys = [:email] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index c9f330a..f6cc57e 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -3,4 +3,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.irregular 'consolidacao', 'consolidacoes' inflect.irregular 'configuracao', 'configuracoes' + # Sem esta linha, ConfiguracaoNotificacao procuraria a tabela + # "configuracao_notificacaos" (o irregular acima só casa no FIM da palavra). + inflect.irregular 'notificacao', 'notificacoes' end diff --git a/config/initializers/smtp.rb b/config/initializers/smtp.rb index 13bf6f5..6c8a132 100644 --- a/config/initializers/smtp.rb +++ b/config/initializers/smtp.rb @@ -1,5 +1,12 @@ # config/initializers/smtp.rb -# Configuração de e-mail 100% via ENV — só ativa se SMTP_USERNAME existir +# +# FALLBACK de e-mail via ENV — só ativa se SMTP_USERNAME existir. +# +# A fonte de verdade preferencial é o BANCO (ConfiguracaoNotificacao, editável +# em /admin/configuracao_notificacao). O ApplicationMailer passa as settings do +# banco em `delivery_method_options`, que o ActionMailer MERGEIA por cima do que +# está configurado aqui. Ou seja: banco > .env, e o que estiver só aqui continua +# valendo enquanto a tela não for preenchida. if ENV['SMTP_USERNAME'].present? Rails.application.config.action_mailer.delivery_method = :smtp Rails.application.config.action_mailer.smtp_settings = { diff --git a/config/routes.rb b/config/routes.rb index 2175d64..4cbb931 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -89,6 +89,12 @@ Rails.application.routes.draw do end resources :auditoria_logs, only: [:index] + # SMTP e WhatsApp (Twilio) gravados no banco — só admin. + # Sem rotas `testar_*`: os botões de teste são submits do próprio form + # (params[:acao]), para o teste usar exatamente o que está na tela. + resource :configuracao_notificacao, only: [:show, :update], + controller: 'configuracao_notificacoes' + # Edição de lançamento do SimpliRoute (correção de status/motivo/comentário) # — só admin. Grava direto na API do SimpliRoute + AuditoriaLog. resource :edicao_lancamento, only: [:show], controller: 'edicao_lancamentos' do diff --git a/db/migrate/20260811000001_create_configuracao_notificacoes.rb b/db/migrate/20260811000001_create_configuracao_notificacoes.rb new file mode 100644 index 0000000..1314192 --- /dev/null +++ b/db/migrate/20260811000001_create_configuracao_notificacoes.rb @@ -0,0 +1,62 @@ +# Configuração de SMTP e WhatsApp (Twilio) gravada no BANCO, editável pelo ADM +# em /admin/configuracao_notificacao — antes disso tudo vinha só do .env e mudar +# um servidor de e-mail exigia editar o arquivo no servidor e reiniciar. +# +# Tabela SINGLETON: uma linha só. `singleton_guard` com índice único é o que +# impede dois workers Puma criarem linhas concorrentes no primeiro acesso. +# +# As credenciais NÃO são importadas do ENV aqui de propósito: os campos nascem +# vazios e `smtp_ativo`/`whatsapp_ativo` respeitam as flags antigas, então +# `smtp_pronto?`/`whatsapp_pronto?` dão false e o app segue usando o .env +# exatamente como hoje até o admin preencher a tela. +class CreateConfiguracaoNotificacoes < ActiveRecord::Migration[7.1] + def up + create_table :configuracao_notificacoes do |t| + t.integer :singleton_guard, null: false, default: 0 + + # ── E-mail / SMTP ─────────────────────────────────────── + t.boolean :smtp_ativo, null: false, default: false + t.string :smtp_address + t.integer :smtp_port, null: false, default: 587 + t.string :smtp_username + t.text :smtp_password_cifrado # AES-256-GCM (concern AtributoCifrado) + t.string :smtp_domain + t.string :smtp_autenticacao, null: false, default: 'plain' + t.string :remetente_email + t.string :remetente_nome + + # ── Destinatários administrativos ─────────────────────── + t.string :email_admin + t.string :whatsapp_admin + + # ── Notificação aos motoristas ────────────────────────── + t.boolean :email_notificacoes_ativo, null: false, default: false + + # ── WhatsApp / Twilio ─────────────────────────────────── + t.boolean :whatsapp_ativo, null: false, default: false + t.string :twilio_account_sid + t.text :twilio_auth_token_cifrado # AES-256-GCM + t.string :twilio_from + + t.timestamps + end + + add_index :configuracao_notificacoes, :singleton_guard, unique: true + + # Herda as flags que hoje moram em `configuracoes` (chave/valor) e que nunca + # tiveram UI. SQL cru de propósito: migration não deve depender de model. + whats = select_value("SELECT valor FROM configuracoes WHERE chave = 'notificacao_whatsapp'") + email = select_value("SELECT valor FROM configuracoes WHERE chave = 'notificacao_email'") + + execute(<<~SQL.squish) + INSERT INTO configuracao_notificacoes + (singleton_guard, whatsapp_ativo, email_notificacoes_ativo, smtp_ativo, + smtp_port, smtp_autenticacao, created_at, updated_at) + VALUES (0, #{whats == 'true'}, #{email == 'true'}, false, 587, 'plain', NOW(), NOW()) + SQL + end + + def down + drop_table :configuracao_notificacoes + end +end diff --git a/spec/models/configuracao_notificacao_spec.rb b/spec/models/configuracao_notificacao_spec.rb new file mode 100644 index 0000000..5bce1ca --- /dev/null +++ b/spec/models/configuracao_notificacao_spec.rb @@ -0,0 +1,189 @@ +require 'rails_helper' + +RSpec.describe ConfiguracaoNotificacao do + # Preenchimento mínimo para os toggles passarem nas validações condicionais. + def config_smtp(extras = {}) + described_class.new({ + smtp_ativo: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, + smtp_username: 'sistema@reem.com.br', smtp_password: 'segredo123' + }.merge(extras)) + end + + def config_whats(extras = {}) + described_class.new({ + whatsapp_ativo: true, twilio_account_sid: 'AC123', + twilio_auth_token: 'tok123', twilio_from: '+14155238886' + }.merge(extras)) + end + + describe '.instancia' do + it 'devolve sempre a mesma linha' do + primeira = described_class.instancia + expect(described_class.instancia.id).to eq(primeira.id) + expect(described_class.count).to eq(1) + end + end + + describe '.normalizar_telefone' do + { + '11 920051157' => '+5511920051157', + '(11) 92005-1157' => '+5511920051157', + '011 92005-1157' => '+5511920051157', + '+55 11 92005-1157' => '+5511920051157', + 'whatsapp:+5511920051157' => '+5511920051157', + '1132224444' => '+551132224444', + # DDD 55 (Santa Maria/RS): o "55" inicial é o DDD, não o país. + '55991234567' => '+5555991234567' + }.each do |entrada, esperado| + it "converte #{entrada.inspect} em #{esperado}" do + expect(described_class.normalizar_telefone(entrada)).to eq(esperado) + end + end + + it 'devolve nil para vazio ou número curto demais' do + expect(described_class.normalizar_telefone(nil)).to be_nil + expect(described_class.normalizar_telefone('')).to be_nil + expect(described_class.normalizar_telefone('920051157')).to be_nil + end + end + + describe '.canal' do + it 'prefixa whatsapp:' do + expect(described_class.canal('11 920051157')).to eq('whatsapp:+5511920051157') + end + + it 'devolve nil quando o número é inválido' do + expect(described_class.canal('abc')).to be_nil + end + end + + describe 'atributos cifrados' do + # A tabela é singleton (índice único em singleton_guard) e a migration já + # cria a linha — por isso os testes que persistem usam `.instancia`. + it 'grava criptografado e lê de volta em claro' do + cfg = described_class.instancia + cfg.update!(smtp_password: 'minha-senha', twilio_auth_token: 'meu-token') + cfg.reload + + expect(cfg.smtp_password).to eq('minha-senha') + expect(cfg.twilio_auth_token).to eq('meu-token') + expect(cfg.smtp_password_cifrado).to be_present + expect(cfg.smtp_password_cifrado).not_to include('minha-senha') + end + + it 'não marca o registro como alterado ao reatribuir o mesmo valor' do + cfg = described_class.instancia + cfg.update!(smtp_password: 'igual') + + cfg.smtp_password = 'igual' + expect(cfg).not_to be_changed + end + + it 'trata segredo ilegível como ausente, sem estourar' do + cfg = described_class.instancia + cfg.update!(smtp_password: 'x') + cfg.update_column(:smtp_password_cifrado, 'lixo-que-nao-decifra') + + expect(cfg.reload.smtp_password).to be_nil + expect(cfg.smtp_password?).to be(false) + end + end + + describe 'validações' do + it 'não exige SMTP enquanto o envio de e-mail está desligado' do + expect(described_class.new).to be_valid + end + + it 'exige servidor, usuário e senha quando o e-mail é ativado' do + cfg = described_class.new(smtp_ativo: true) + expect(cfg).not_to be_valid + expect(cfg.errors.attribute_names).to include(:smtp_address, :smtp_username, :smtp_password) + end + + it 'exige credenciais do Twilio quando o WhatsApp é ativado' do + cfg = described_class.new(whatsapp_ativo: true) + expect(cfg).not_to be_valid + expect(cfg.errors.attribute_names).to include(:twilio_account_sid, :twilio_auth_token, :twilio_from) + end + + it 'rejeita e-mail administrativo malformado' do + expect(described_class.new(email_admin: 'sem-arroba')).not_to be_valid + end + + it 'rejeita porta fora da faixa' do + expect(described_class.new(smtp_port: 0)).not_to be_valid + expect(described_class.new(smtp_port: 70_000)).not_to be_valid + end + + it 'normaliza os telefones antes de salvar' do + cfg = described_class.instancia + cfg.update!(whatsapp_admin: '(11) 92005-1157') + expect(cfg.reload.whatsapp_admin).to eq('+5511920051157') + end + end + + describe '#smtp_settings' do + it 'devolve nil quando não está pronto, para o .env continuar valendo' do + expect(described_class.new.smtp_settings).to be_nil + expect(config_smtp(smtp_ativo: false).smtp_settings).to be_nil + end + + it 'usa STARTTLS na 587' do + settings = config_smtp.smtp_settings + expect(settings[:enable_starttls_auto]).to be(true) + expect(settings).not_to have_key(:ssl) + expect(settings[:password]).to eq('segredo123') + expect(settings[:authentication]).to eq(:plain) + end + + it 'usa SSL direto na 465' do + settings = config_smtp(smtp_port: 465).smtp_settings + expect(settings[:ssl]).to be(true) + expect(settings).not_to have_key(:enable_starttls_auto) + end + + it 'deriva o domínio do remetente quando não informado' do + expect(config_smtp.smtp_settings[:domain]).to eq('reem.com.br') + end + end + + describe '#remetente_formatado' do + it 'monta "Nome" quando há nome' do + cfg = config_smtp(remetente_email: 'nao-responda@reem.com.br', remetente_nome: 'Reem Transporte') + expect(cfg.remetente_formatado).to eq('"Reem Transporte" ') + end + + it 'cai no usuário do SMTP quando não há remetente próprio' do + expect(config_smtp.remetente_formatado).to eq('sistema@reem.com.br') + end + end + + describe '#credenciais_whatsapp' do + it 'usa o banco quando está completo' do + creds = config_whats.credenciais_whatsapp + expect(creds[:sid]).to eq('AC123') + expect(creds[:from]).to eq('whatsapp:+14155238886') + end + + it 'cai no .env quando o banco está incompleto' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('TWILIO_ACCOUNT_SID').and_return('AC_env') + allow(ENV).to receive(:[]).with('TWILIO_AUTH_TOKEN').and_return('tok_env') + allow(ENV).to receive(:[]).with('TWILIO_WHATSAPP_FROM').and_return('+14155238886') + + expect(described_class.new.credenciais_whatsapp[:sid]).to eq('AC_env') + end + + it 'não mistura SID do banco com token do .env' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('TWILIO_ACCOUNT_SID').and_return('AC_env') + allow(ENV).to receive(:[]).with('TWILIO_AUTH_TOKEN').and_return('tok_env') + allow(ENV).to receive(:[]).with('TWILIO_WHATSAPP_FROM').and_return('+14155238886') + + # SID no banco mas sem token → whatsapp_pronto? é false → vai inteiro pro ENV + cfg = described_class.new(whatsapp_ativo: true, twilio_account_sid: 'AC_banco') + expect(cfg.credenciais_whatsapp[:sid]).to eq('AC_env') + expect(cfg.credenciais_whatsapp[:token]).to eq('tok_env') + end + end +end diff --git a/spec/models/table_names_spec.rb b/spec/models/table_names_spec.rb index b0b9642..fb4faa5 100644 --- a/spec/models/table_names_spec.rb +++ b/spec/models/table_names_spec.rb @@ -8,7 +8,8 @@ RSpec.describe 'Nomes de tabela dos models' do Consolidacao => 'consolidacoes', ConsolidacaoMotorista => 'consolidacao_motoristas', ConsolidacaoEntrega => 'consolidacao_entregas', - Configuracao => 'configuracoes' + Configuracao => 'configuracoes', + ConfiguracaoNotificacao => 'configuracao_notificacoes' }.each do |model, tabela| it "#{model} aponta para #{tabela}" do expect(model.table_name).to eq(tabela) diff --git a/spec/policies/configuracao_notificacao_policy_spec.rb b/spec/policies/configuracao_notificacao_policy_spec.rb new file mode 100644 index 0000000..5f930fe --- /dev/null +++ b/spec/policies/configuracao_notificacao_policy_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe ConfiguracaoNotificacaoPolicy do + subject { described_class } + + # Mais restrito que ConfiguracaoPolicy de propósito: aqui ficam a senha do + # e-mail e o token do Twilio, então gerente não entra. + permissions :show?, :update? do + it 'libera só para admin' do + expect(subject).to permit(build(:admin), ConfiguracaoNotificacao) + expect(subject).not_to permit(build(:gerente), ConfiguracaoNotificacao) + expect(subject).not_to permit(build(:operador), ConfiguracaoNotificacao) + end + end +end diff --git a/spec/requests/admin/configuracao_notificacoes_spec.rb b/spec/requests/admin/configuracao_notificacoes_spec.rb new file mode 100644 index 0000000..ed88aab --- /dev/null +++ b/spec/requests/admin/configuracao_notificacoes_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe 'Admin::ConfiguracaoNotificacoes', type: :request do + let(:config) { ConfiguracaoNotificacao.instancia } + + def patch_config(atributos, acao: 'salvar') + patch admin_configuracao_notificacao_path, + params: { acao: acao, configuracao_notificacao: atributos } + end + + describe 'permissão' do + it 'exige login' do + get admin_configuracao_notificacao_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia gerente (só admin mexe em credenciais de envio)' do + sign_in create(:gerente) + get admin_configuracao_notificacao_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to match(/permissão/i) + end + + it 'bloqueia gerente também no update' do + sign_in create(:gerente) + patch_config(smtp_address: 'smtp.invasor.com') + + expect(response).to redirect_to(root_path) + expect(config.reload.smtp_address).not_to eq('smtp.invasor.com') + end + + it 'libera admin' do + sign_in create(:admin) + get admin_configuracao_notificacao_path + expect(response).to have_http_status(:ok) + end + end + + context 'como admin' do + let(:admin) { create(:admin) } + + before { sign_in admin } + + it 'salva as configurações' do + patch_config(smtp_address: 'smtp.gmail.com', smtp_port: 465, + email_admin: 'Chefe@Reem.com.br', whatsapp_admin: '(11) 92005-1157') + + expect(response).to redirect_to(admin_configuracao_notificacao_path) + config.reload + expect(config.smtp_address).to eq('smtp.gmail.com') + expect(config.smtp_port).to eq(465) + expect(config.email_admin).to eq('chefe@reem.com.br') + expect(config.whatsapp_admin).to eq('+5511920051157') + end + + it 'mantém a senha gravada quando o campo vem em branco' do + config.update!(smtp_password: 'senha-original') + + patch_config(smtp_address: 'smtp.outro.com', smtp_password: '') + + expect(config.reload.smtp_password).to eq('senha-original') + end + + it 'substitui a senha quando o campo é preenchido' do + config.update!(smtp_password: 'senha-original') + + patch_config(smtp_password: 'senha-nova') + + expect(config.reload.smtp_password).to eq('senha-nova') + end + + it 'reexibe o formulário quando ativa o e-mail sem credenciais' do + patch_config(smtp_ativo: '1', smtp_address: '', smtp_username: '') + + expect(response).to have_http_status(:unprocessable_entity) + expect(config.reload.smtp_ativo).to be(false) + end + + it 'registra auditoria sem vazar a senha nem o token' do + patch_config(smtp_address: 'smtp.gmail.com', smtp_password: 'super-secreta', + twilio_auth_token: 'token-secreto') + + log = AuditoriaLog.where(entidade: 'ConfiguracaoNotificacao').last + expect(log.acao).to eq('editar_notificacoes') + expect(log.user_id).to eq(admin.id) + + serializado = [log.dados_anteriores, log.dados_novos].to_json + expect(serializado).not_to include('super-secreta') + expect(serializado).not_to include('token-secreto') + expect(log.dados_novos['smtp_password_definida']).to be(true) + end + + describe 'botões de teste' do + it 'avisa quando o e-mail administrativo não foi preenchido' do + patch_config({ email_admin: '' }, acao: 'testar_email') + + expect(response).to redirect_to(admin_configuracao_notificacao_path) + expect(flash[:alert]).to match(/E-mail do administrador/i) + end + + it 'avisa quando o SMTP não está pronto' do + patch_config({ email_admin: 'chefe@reem.com.br' }, acao: 'testar_email') + + expect(flash[:alert]).to match(/Ativar envio de e-mail/i) + end + + it 'envia o e-mail de teste quando o SMTP está configurado' do + resultado = Notificacao::Resultado.sucesso('E-mail de teste enviado.') + expect(Notificacao::TesteEmail).to receive(:new).and_return(double(executar: resultado)) + + patch_config({ email_admin: 'chefe@reem.com.br' }, acao: 'testar_email') + + expect(flash[:notice]).to eq('E-mail de teste enviado.') + end + + it 'avisa quando o WhatsApp administrativo não foi preenchido' do + patch_config({ whatsapp_admin: '' }, acao: 'testar_whatsapp') + + expect(flash[:alert]).to match(/WhatsApp do administrador/i) + end + + it 'reporta o erro traduzido do Twilio no flash' do + resultado = Notificacao::Resultado.falha('Sandbox exige opt-in.', '[63015] detalhe') + expect(Notificacao::TesteWhatsapp).to receive(:new).and_return(double(executar: resultado)) + + patch_config({ whatsapp_admin: '11 920051157' }, acao: 'testar_whatsapp') + + expect(flash[:alert]).to include('Sandbox exige opt-in.') + expect(flash[:alert]).to include('63015') + end + end + end +end