Implantação da geração de mensagem de forma livre e mudança na engine de mensagem

This commit is contained in:
2026-08-24 17:32:16 -03:00
parent 94bbe7556f
commit 88f19dd8f5
77 changed files with 4650 additions and 25 deletions

View File

@@ -0,0 +1,41 @@
# Lista de destinatários das notificações, cadastrada à mão pelo ADM.
#
# Antes, "quem recebe" era implícito: o NotificacaoService procurava o User
# motorista pelo NOME da consolidação. Quem não é usuário do sistema (diretoria,
# cliente, terceiro) não tinha como ser avisado, e não havia tela nenhuma para
# controlar isso.
#
# O contato pertence a UM grupo, e é o GRUPO que assina os eventos (ver
# 20260824000002) — assim cadastrar alguém novo é escolher o grupo, não repetir
# a configuração pessoa por pessoa.
class CreateNotificacaoContatos < ActiveRecord::Migration[7.1]
def change
create_table :grupos_contato do |t|
t.string :nome, null: false
t.string :descricao
t.boolean :ativo, null: false, default: true
t.timestamps
end
add_index :grupos_contato, 'LOWER(nome)', unique: true, name: 'index_grupos_contato_nome_unico'
create_table :contatos do |t|
t.references :grupo_contato, foreign_key: true, index: true
t.string :nome, null: false
# E.164 normalizado (+5511920051157). Vazio = contato só de e-mail.
t.string :telefone
t.string :email
t.boolean :ativo, null: false, default: true
t.string :observacao
# Quando o contato É um usuário do sistema (motorista), guardar o vínculo
# permite o disparo "avisar o envolvido" achar a pessoa certa sem depender
# de casar nome por string, que é o que se faz hoje.
t.references :user, foreign_key: true, index: true
t.timestamps
end
add_index :contatos, :telefone
add_index :contatos, :email
end
end

View File

@@ -0,0 +1,70 @@
# Eventos de notificação + assinatura por grupo.
#
# O ADM cadastra quantos eventos quiser, mas o GATILHO sai de uma lista fixa:
# gatilho é código. `manual` cobre o aviso pontual (botão "disparar agora") e
# `agendado` cobre o resumo periódico; os demais são pontos que o sistema já
# conhece (consolidação finalizada, pagamento efetuado, valor alterado, dados da
# operação mudaram).
#
# `sistema: true` marca os eventos que nascem com o app: podem ser editados e
# desligados, mas não apagados — apagar deixaria o código disparando no vazio.
class CreateNotificacaoEventos < ActiveRecord::Migration[7.1]
GATILHOS = %w[manual consolidacao_finalizada pagamento_efetuado
valor_alterado operacao_alterada agendado].freeze
def up
create_table :eventos_notificacao do |t|
t.string :nome, null: false
t.string :chave, null: false # slug estável usado pelo código
t.string :gatilho, null: false
t.string :descricao
t.boolean :ativo, null: false, default: true
t.boolean :sistema, null: false, default: false
# Avisar também a pessoa diretamente envolvida no fato (o motorista do
# pagamento), além dos grupos assinantes. É o comportamento de hoje.
t.boolean :notificar_envolvido, null: false, default: true
# Só para gatilho `agendado`.
t.string :frequencia # diaria | semanal
t.integer :hora, default: 8 # 0..23
t.integer :dia_semana # 0..6 (domingo=0), só na semanal
t.datetime :ultimo_disparo_em
t.timestamps
end
add_index :eventos_notificacao, :chave, unique: true
create_table :grupo_evento_assinaturas do |t|
t.references :grupo_contato, null: false, foreign_key: true
t.references :evento_notificacao, null: false, foreign_key: true
t.string :canal, null: false, default: 'ambos' # ambos | whatsapp | email
t.boolean :ativo, null: false, default: true
t.timestamps
end
add_index :grupo_evento_assinaturas, %i[grupo_contato_id evento_notificacao_id],
unique: true, name: 'index_assinatura_grupo_evento_unico'
# Os dois eventos que o código JÁ dispara hoje, para nada parar de funcionar
# no deploy. SQL cru de propósito: migration não deve depender de model.
agora = 'NOW()'
[
['Pagamento fechado', 'consolidacao_finalizada', 'consolidacao_finalizada',
'Disparado quando uma consolidação é finalizada.'],
['Pagamento efetuado', 'pagamento_efetuado', 'pagamento_efetuado',
'Disparado quando o pagamento de um motorista é marcado como pago.']
].each do |nome, chave, gatilho, descricao|
execute(<<~SQL.squish)
INSERT INTO eventos_notificacao
(nome, chave, gatilho, descricao, ativo, sistema, notificar_envolvido, hora, created_at, updated_at)
VALUES (#{quote(nome)}, #{quote(chave)}, #{quote(gatilho)}, #{quote(descricao)},
true, true, true, 8, #{agora}, #{agora})
SQL
end
end
def down
drop_table :grupo_evento_assinaturas
drop_table :eventos_notificacao
end
end

View File

@@ -0,0 +1,31 @@
# Log de cada mensagem disparada — quem recebeu, por qual canal, com que texto e
# se deu certo.
#
# Hoje uma falha de envio só vira linha no log do Rails (NotificacaoService
# engole a exceção de propósito, para um SMTP fora do ar não travar o
# fechamento). Isso é certo, mas deixa o operador sem resposta para "o motorista
# recebeu?". Com o WhatsApp por sessão QR — que cai sozinho e precisa
# repareamento — essa pergunta passa a ser rotina.
class CreateNotificacaoEnvios < ActiveRecord::Migration[7.1]
def change
create_table :notificacao_envios do |t|
t.references :evento_notificacao, foreign_key: true, index: true
t.references :contato, foreign_key: true, index: true
t.references :user, foreign_key: true, index: true
t.string :canal, null: false # whatsapp | email
t.string :destino, null: false # telefone E.164 ou e-mail
t.string :assunto
t.text :corpo
t.string :status, null: false, default: 'pendente' # pendente|enviado|falhou
t.text :erro
t.integer :tentativas, null: false, default: 0
t.datetime :enviado_em
t.timestamps
end
add_index :notificacao_envios, %i[status created_at]
add_index :notificacao_envios, :created_at
end
end

View File

@@ -0,0 +1,30 @@
# WhatsApp por sessão pareada com QR code (Baileys), no lugar do Twilio.
#
# `whatsapp_provedor` decide quem envia. Nasce com o valor que a instalação já
# usa ('twilio' se havia credencial configurada), então o deploy não muda o
# comportamento até o ADM parear o QR e trocar o provedor na tela.
#
# ⚠️ O envio por QR é a porta do WhatsApp Web (engenharia reversa), fora dos
# Termos do WhatsApp: a Meta PODE banir o número. Por isso o intervalo entre
# mensagens é configurável e nasce em 5s — disparo em rajada para dezenas de
# contatos é a forma mais rápida de perder o número.
class AddBaileysToConfiguracaoNotificacoes < ActiveRecord::Migration[7.1]
def up
change_table :configuracao_notificacoes, bulk: true do |t|
t.string :whatsapp_provedor, null: false, default: 'twilio' # twilio | baileys
t.string :baileys_url
t.text :baileys_token_cifrado
t.string :whatsapp_numero_conectado
t.datetime :whatsapp_conectado_em
t.integer :whatsapp_intervalo_segundos, null: false, default: 5
end
end
def down
change_table :configuracao_notificacoes, bulk: true do |t|
t.remove :whatsapp_provedor, :baileys_url, :baileys_token_cifrado,
:whatsapp_numero_conectado, :whatsapp_conectado_em,
:whatsapp_intervalo_segundos
end
end
end

View File

@@ -0,0 +1,29 @@
# Corpo das mensagens montado pelo ADM, um template por EVENTO e por CANAL.
#
# Antes, o texto era string interpolada dentro do NotificacaoService: mudar uma
# palavra exigia deploy. Agora o ADM empilha blocos (cabeçalho, texto, tabela de
# valores, aviso, botão, rodapé) na tela.
#
# `blocos` é jsonb e não tabela filha de propósito: a ordem faz parte do dado
# (é uma lista, não um conjunto), o conteúdo de cada tipo de bloco é diferente, e
# salvar o template inteiro numa transação evita estado meio-salvo. A validação
# da forma acontece no model (MensagemTemplate#normalizar_blocos), não no banco.
class CreateMensagemTemplates < ActiveRecord::Migration[7.1]
def change
create_table :mensagem_templates do |t|
t.references :evento_notificacao, null: false, foreign_key: true
t.string :canal, null: false # whatsapp | email
t.string :assunto # só e-mail
t.jsonb :blocos, null: false, default: []
t.boolean :ativo, null: false, default: true
t.timestamps
end
# Um template por evento+canal: dois templates ativos para o mesmo par
# deixariam o envio escolhendo em silêncio.
add_index :mensagem_templates, %i[evento_notificacao_id canal],
unique: true, name: 'index_template_evento_canal_unico'
end
end

View File

@@ -0,0 +1,27 @@
# Retrato dos números de cada operação, para detectar o que MUDOU entre uma
# varredura e a seguinte.
#
# A maior parte das mudanças da operação não passa pelo nosso código: elas
# acontecem no SimpliRoute e chegam pelo sync do espelho (que é read-only aqui).
# Sem guardar o retrato anterior não há como dizer "3 NFs entraram, 1 saiu" —
# só dá para mostrar o número de agora, que é o que os dashboards já fazem.
#
# Uma linha por tabela de operação. É snapshot, não histórico: a linha é
# ATUALIZADA a cada varredura. Guardar a série inteira seria outra feature (e a
# tabela cresceria por operação × varredura sem ninguém consultar).
class CreateOperacaoSnapshots < ActiveRecord::Migration[7.1]
def change
create_table :operacao_snapshots do |t|
t.string :tabela, null: false
t.integer :total_notas, null: false, default: 0
t.integer :entregues, null: false, default: 0
t.integer :nao_entregues, null: false, default: 0
t.integer :pendentes, null: false, default: 0
t.datetime :capturado_em, null: false
t.timestamps
end
add_index :operacao_snapshots, :tabela, unique: true
end
end

View File

@@ -0,0 +1,35 @@
# Cria os eventos de sistema dos gatilhos ligados na etapa 3.
#
# Sem isso, `Despachante.disparar(chave: 'valor_alterado')` não acharia evento
# nenhum e o gatilho ficaria mudo — e o ADM teria de adivinhar que precisa criar
# um evento cujo nome gere exatamente aquela `chave`.
#
# Nascem ATIVOS e isso é seguro: sem grupo assinante não há destinatário, então
# nada é enviado até o ADM marcar um grupo na tela.
class SemearEventosDeGatilho < ActiveRecord::Migration[7.1]
EVENTOS = [
['Valor alterado no fechamento', 'valor_alterado', 'valor_alterado',
'Disparado quando o valor de um motorista muda depois da consolidação finalizada.'],
['Dados da operação mudaram', 'operacao_alterada', 'operacao_alterada',
'NF que entrou ou saiu, entrega que trocou de status, lançamento corrigido no SimpliRoute.']
].freeze
def up
EVENTOS.each do |nome, chave, gatilho, descricao|
# Idempotente: rodar de novo (ou num banco que já tenha o evento criado à
# mão) não pode estourar índice único.
next if select_value("SELECT 1 FROM eventos_notificacao WHERE chave = #{quote(chave)}")
execute(<<~SQL.squish)
INSERT INTO eventos_notificacao
(nome, chave, gatilho, descricao, ativo, sistema, notificar_envolvido, hora, created_at, updated_at)
VALUES (#{quote(nome)}, #{quote(chave)}, #{quote(gatilho)}, #{quote(descricao)},
true, true, true, 8, NOW(), NOW())
SQL
end
end
def down
EVENTOS.each { |_, chave, _, _| execute("DELETE FROM eventos_notificacao WHERE chave = #{quote(chave)}") }
end
end