feat: Implementa a sincronização automática de promoções do LandingHost para artigos de FAQ, permitindo a criação, atualização e arquivamento de conteúdo baseado em configurações de promoções.

This commit is contained in:
Rodrigo Borba 2026-03-03 22:24:30 -03:00
parent c16194eff9
commit 7e23e59782
5 changed files with 347 additions and 2 deletions

View File

@ -73,6 +73,18 @@ export default {
cfgCancel: 'Cancelar', cfgCancel: 'Cancelar',
errUpdate: 'Erro ao salvar configurações.', errUpdate: 'Erro ao salvar configurações.',
successUpdate: 'Configurações atualizadas com sucesso!', successUpdate: 'Configurações atualizadas com sucesso!',
// Campos Promoção
promoSectionTitle: 'Promoções / IA (Opcional)',
promoAdd: 'Adicionar Promoção',
promoRemove: 'Remover',
promoChannel: 'Canal / Origem (Ex: Instagram)',
promoTitle: 'Ativa',
promoName: 'Nome da Promoção',
promoDesc: 'Descrição / Condições',
promoCoupon: 'Cupom',
promoValid: 'Válida Até (Data)',
promoEmpty: 'Nenhuma promoção configurada para esta Landing Page.',
}, },
}; };
}, },
@ -160,7 +172,31 @@ export default {
}, },
openEdit(host) { openEdit(host) {
this.expandedHostId = host.id; this.expandedHostId = host.id;
this.editingHostData = { ...host };
let promotions = [];
if (
host.custom_config?.promotions &&
Array.isArray(host.custom_config.promotions)
) {
promotions = [...host.custom_config.promotions];
} else if (
host.custom_config?.promotion &&
host.custom_config.promotion.title
) {
promotions = [{ ...host.custom_config.promotion, channel: 'Geral' }];
}
this.editingHostData = {
...host,
custom_config: {
...host.custom_config,
promotions: promotions,
},
};
if (this.editingHostData.custom_config.promotion) {
delete this.editingHostData.custom_config.promotion;
}
}, },
cancelEdit() { cancelEdit() {
this.expandedHostId = null; this.expandedHostId = null;
@ -200,6 +236,22 @@ export default {
useAlert(this.labels.errCopy); useAlert(this.labels.errCopy);
} }
}, },
addPromotion() {
if (!this.editingHostData.custom_config.promotions) {
this.editingHostData.custom_config.promotions = [];
}
this.editingHostData.custom_config.promotions.push({
active: true,
channel: '',
title: '',
description: '',
coupon_code: '',
valid_until: '',
});
},
removePromotion(index) {
this.editingHostData.custom_config.promotions.splice(index, 1);
},
}, },
}; };
</script> </script>
@ -449,6 +501,132 @@ export default {
</div> </div>
</div> </div>
<!-- Seção de Promoções (Para IA) -->
<div class="mt-4 pt-4 border-t border-n-slate-2">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-semibold text-n-slate-12">
{{ labels.promoSectionTitle }}
</h4>
<button
class="text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-3 py-1.5 rounded-md transition-colors flex items-center gap-1 border border-emerald-200"
@click.prevent="addPromotion"
>
<span class="text-base leading-none font-bold">+</span>
{{ labels.promoAdd }}
</button>
</div>
<div
v-for="(promo, index) in editingHostData.custom_config
.promotions"
:key="index"
class="bg-n-brand-1/10 p-5 rounded-lg border border-n-brand-2 mb-4 relative"
>
<button
class="absolute top-4 right-4 text-xs font-medium text-ruby-9 hover:text-ruby-11 transition-colors z-10"
@click.prevent="removePromotion(index)"
>
{{ labels.promoRemove }}
</button>
<div class="mb-4 flex items-center gap-2">
<input
:id="'promo-toggle-' + editingHostData.id + '-' + index"
v-model="promo.active"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-n-brand cursor-pointer"
/>
<label
:for="
'promo-toggle-' + editingHostData.id + '-' + index
"
class="text-sm font-medium text-n-slate-12 cursor-pointer"
>
{{ labels.promoTitle }}
</label>
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
:class="{
'opacity-50 pointer-events-none grayscale':
!promo.active,
}"
>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>
{{ labels.promoChannel }} *
</label>
<woot-input
v-model="promo.channel"
placeholder="Ex: Instagram"
class="[&>input]:!mb-0"
/>
</div>
<div class="sm:col-span-2">
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>
{{ labels.promoName }}
</label>
<woot-input
v-model="promo.title"
placeholder="Ex: Oferta Fim de Semana"
class="[&>input]:!mb-0"
/>
</div>
<div class="sm:col-span-2">
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>
{{ labels.promoDesc }}
</label>
<woot-input
v-model="promo.description"
placeholder="Ex: 20% OFF na reserva sexta ou sábado. Válido para novos clientes."
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>
{{ labels.promoCoupon }}
</label>
<woot-input
v-model="promo.coupon_code"
placeholder="Ex: BLACK20"
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>
{{ labels.promoValid }}
</label>
<woot-input
v-model="promo.valid_until"
type="date"
class="[&>input]:!mb-0"
/>
</div>
</div>
</div>
<div
v-if="
!editingHostData.custom_config.promotions ||
editingHostData.custom_config.promotions.length === 0
"
class="text-sm text-n-slate-10 italic py-4 text-center border border-dashed border-n-slate-3 rounded-lg"
>
{{ labels.promoEmpty }}
</div>
</div>
<div <div
class="mt-4 flex justify-end gap-3 border-t border-n-slate-2 pt-4" class="mt-4 flex justify-end gap-3 border-t border-n-slate-2 pt-4"
> >

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
module LandingHostAiSyncable
extend ActiveSupport::Concern
included do
after_save :sync_promotion_to_faq
end
def sync_promotion_to_faq
return unless custom_config.is_a?(Hash)
promotions = custom_config['promotions']
# Fallback to legacy format if array is empty but object exists
promotions = [custom_config['promotion']] if promotions.blank? && custom_config['promotion'].is_a?(Hash)
active_promos = Array(promotions).select { |p| p.is_a?(Hash) && p['active'] }
if active_promos.any?
create_or_update_faq_article(active_promos)
else
archive_faq_article
end
end
private
def create_or_update_faq_article(promos)
return unless can_sync_to_portal?
article = find_or_initialize_faq_article
article.title = faq_article_title
# Generating the standard AI instruction text for the multiple promotions
text = %(INSTRUÇÃO PARA A IA (PROMOÇÕES ATIVAS DO LINK #{hostname}):\n\n)
text += %(Existem promoções ativas para os leads que chegam pela landing page '#{hostname}'.\n)
text += %(Ofereça a promoção correspondente ao Canal/Origem pelo qual o cliente chegou.\n\n)
promos.each do |promo|
channel = promo['channel'].presence || 'Geral'
text += "--- CANAL / ORIGEM: #{channel} ---\n"
text += "Título da Promoção: #{promo['title']}\n" if promo['title'].present?
text += "Condições / Descrição: #{promo['description']}\n" if promo['description'].present?
text += "Cupom: #{promo['coupon_code']}\n" if promo['coupon_code'].present?
text += "Válida até: #{promo['valid_until']}\n" if promo['valid_until'].present?
text += "\n"
end
article.content = text
article.description = "FAQ Gerado automaticamente pela Landing Page: #{hostname}"
# Setting the author as the portal's account first user (just as a fallback) or we can use a system user
article.author ||= default_article_author
article.status = :published
article.save!
end
def archive_faq_article
return unless can_sync_to_portal?
article = find_faq_article
article&.update!(status: :archived)
end
def find_or_initialize_faq_article
find_faq_article || portal.articles.new(account_id: inbox.account_id)
end
def find_faq_article
portal.articles.find_by(title: faq_article_title)
end
def faq_article_title
"Promoção Automática - #{hostname.upcase}"
end
def portal
inbox.portal
end
def can_sync_to_portal?
inbox.present? && inbox.portal_id.present?
end
def default_article_author
# Assumes that the account has at least one user (owner/admin) to author the article
inbox.account.users.order(id: :asc).first
end
end

View File

@ -27,4 +27,7 @@
# index_landing_hosts_on_hostname (hostname) UNIQUE # index_landing_hosts_on_hostname (hostname) UNIQUE
# #
class LandingHost < ApplicationRecord class LandingHost < ApplicationRecord
belongs_to :inbox, optional: true
include LandingHostAiSyncable
end end

View File

@ -0,0 +1,23 @@
# Resolução: Automação por Conteúdo da Mensagem (Meta Ads)
**Objetivo:** Criar uma regra de automação no Chatwoot que aplica uma etiqueta automaticamente quando uma mensagem específica (geralmente vinda de um link do Meta Ads/WhatsApp) é recebida.
**Contexto e Desafios Encontrados:**
1. **Tradução e Disponibilidade:** Em Português, a condição "Message Content" é traduzida como **"Mensagem contém"**. Porém, ela só aparece se o evento selecionado for **"Mensagem Criada"** (Message Created), e não "Conversa Criada".
2. **Separação por Vírgulas (Bug Silencioso):** O Chatwoot usa o tipo de input `comma_separated_plain_text` para o campo "Mensagem contém". Isso significa que se a frase da campanha contiver uma vírgula (ex: *"Olá, tenho interesse"*), o sistema divide a frase em duas strings distintas e exige que a mensagem recebida seja exatamente igual a uma delas, fazendo com que a automação falhe.
**Passos para a Resolução:**
1. Criar a Automação e definir o **Evento** como **Mensagem Criada**.
2. Na seção de Condições, escolher **Mensagem contém**.
3. Mudar o operador de "Igual a" para **"Contém"**.
4. No campo de valor, inserir a frase do anúncio **removendo a vírgula** ou colando apenas o trecho antes da vírgula (Ex: `Olá! Tenho interesse e queria mais informações`).
5. Nas Ações, definir **Adicionar Rótulo** e escolher a tag desejada da campanha.
**Principais Códigos/Arquivos Analisados (Backend/Frontend):**
- `app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`: Onde a condição `content` é mapeada no evento de `message_created`.
- `app/javascript/dashboard/composables/useEditableAutomation.js`: Onde o input `comma_separated_plain_text` é convertido para envio (responsável pelo problema da quebra no texto por vírgulas).
- `app/services/automation_rules/conditions_filter_service.rb` e `app/services/filter_service.rb`: Serviços que processam e validam as condições da automação (a busca é feita no campo banco de dados através da sintaxe `LOWER(messages.processed_message_content)`).
**Como validar ou reverter:**
- **Validar:** Simular o envio de uma nova mensagem no WhatsApp usando o mesmo formato definido na campanha (antes ou após a vírgula). O painel do Chatwoot deve aplicar o rótulo da campanha de forma simultânea à recepção da mensagem.
- **Reverter:** Pausar a regra no painel em `Configurações > Automação` alternando a chave de 'Ativo', ou excluí-la permanentemente.

View File

@ -1,5 +1,56 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe LandingHost, type: :model do RSpec.describe LandingHost, type: :model do
pending "add some examples to (or delete) #{__FILE__}" let(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let(:portal) { create(:portal, account: account) }
let(:inbox) { create(:inbox, account: account, portal: portal) }
describe 'LandingHostAiSyncable' do
let(:landing_host) do
build(:landing_host,
inbox: inbox,
hostname: 'promo.example.com',
custom_config: {
promotions: [
{
active: true,
channel: 'Instagram',
title: 'Black Friday 50% Off',
description: 'Valid for all suites.',
coupon_code: 'BLACK50',
valid_until: '2024-11-30'
}
]
})
end
it 'creates a new FAQ article when promotion is active' do
expect do
landing_host.save!
end.to change(Article, :count).by(1)
title = "Promoção Automática - #{landing_host.hostname.upcase}"
article = portal.articles.find_by(title: title)
expect(article).to be_present
expect(article.title).to include("Promoção Automática - #{landing_host.hostname.upcase}")
expect(article.content).to include('Black Friday 50% Off')
expect(article.content).to include('BLACK50')
expect(article.content).to include('Instagram')
expect(article.status).to eq('published')
end
it 'archives an existing FAQ article when promotion is deactivated' do
landing_host.save! # Automatically creates the article
title = "Promoção Automática - #{landing_host.hostname.upcase}"
article = portal.articles.find_by(title: title)
expect(article.status).to eq('published')
# Deactivate promotion
landing_host.custom_config['promotions'].first['active'] = false
landing_host.save!
expect(article.reload.status).to eq('archived')
end
end
end end