Automate landing promotion sync to captain docs/faqs with cascade cleanup

This commit is contained in:
Rodrigo Borba 2026-03-04 19:30:05 -03:00
parent 46806fa635
commit cdc5149866
5 changed files with 301 additions and 65 deletions

View File

@ -0,0 +1,7 @@
class LandingHosts::PromotionSyncSchedulerJob < ApplicationJob
queue_as :scheduled_jobs
def perform
LandingHost.find_each(&:sync_promotion_to_faq)
end
end

View File

@ -1,7 +1,9 @@
# frozen_string_literal: true
require 'digest'
module LandingHostAiSyncable
extend ActiveSupport::Concern
SYNC_METADATA_KEY = 'landing_promotion_sync'.freeze
included do
after_save :sync_promotion_to_faq
@ -9,72 +11,173 @@ module LandingHostAiSyncable
def sync_promotion_to_faq
return unless custom_config.is_a?(Hash)
return cleanup_all_synced_knowledge unless can_sync_to_portal?
promotions = custom_config['promotions']
active_entries = extract_promotions.each_with_index.filter_map do |promotion, index|
next unless promotion_active?(promotion)
# 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
{
promotion: promotion,
signature: promotion_signature(promotion, index)
}
end
sync_active_promotions(active_entries)
cleanup_stale_synced_knowledge(active_entries.map { |entry| entry[:signature] })
cleanup_legacy_aggregated_sync
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
article.content = generate_promotions_text(promos)
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!
def extract_promotions
promotions = custom_config['promotions']
promotions = [custom_config['promotion']] if promotions.blank? && custom_config['promotion'].is_a?(Hash)
Array(promotions).select { |promotion| promotion.is_a?(Hash) }
end
def generate_promotions_text(promos)
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)
def sync_active_promotions(active_entries)
active_entries.each do |entry|
article = create_or_update_promotion_article(entry)
sync_captain_document(article, entry)
end
end
def create_or_update_promotion_article(entry)
article = find_synced_article(entry[:signature]) || portal.articles.new(account_id: inbox.account_id)
article.title = promotion_article_title(entry[:promotion], entry[:signature])
article.content = generate_promotion_text(entry[:promotion])
article.description = "FAQ Gerado automaticamente pela Landing Page: #{hostname}"
article.meta = (article.meta || {}).merge(sync_metadata_for(signature: entry[:signature]))
article.author ||= default_article_author
article.status = :published
article.save!
article
end
def generate_promotion_text(promo)
text = %(INSTRUÇÃO PARA A IA (PROMOÇÃO ATIVA DO LINK #{hostname}):\n\n)
text += %(Existe uma promoção ativa 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
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
end
def archive_faq_article
return unless can_sync_to_portal?
def cleanup_stale_synced_knowledge(active_signatures)
synced_articles.find_each do |article|
signature = article.meta&.dig(SYNC_METADATA_KEY, 'promotion_signature')
next if signature.present? && active_signatures.include?(signature)
article = find_faq_article
article&.update!(status: :archived)
delete_synced_captain_document(article)
article.destroy!
end
return unless captain_assistant.present? && defined?(Captain::Document)
synced_documents.find_each do |document|
signature = document.metadata&.dig(SYNC_METADATA_KEY, 'promotion_signature')
next if signature.present? && active_signatures.include?(signature)
document.destroy!
end
end
def find_or_initialize_faq_article
find_faq_article || portal.articles.new(account_id: inbox.account_id)
def cleanup_legacy_aggregated_sync
legacy_article = portal.articles.find_by(
"meta -> '#{SYNC_METADATA_KEY}' ->> 'landing_host_id' = ? AND (meta -> '#{SYNC_METADATA_KEY}' ->> 'promotion_signature') IS NULL",
id.to_s
)
return if legacy_article.blank?
delete_synced_captain_document(legacy_article)
legacy_article.destroy!
end
def find_faq_article
portal.articles.find_by(title: faq_article_title)
def cleanup_all_synced_knowledge
cleanup_stale_synced_knowledge([])
cleanup_legacy_aggregated_sync
end
def faq_article_title
"Promoção Automática - #{hostname.upcase}"
def find_synced_article(signature)
portal.articles.find_by(
"meta -> '#{SYNC_METADATA_KEY}' ->> 'landing_host_id' = ? AND meta -> '#{SYNC_METADATA_KEY}' ->> 'promotion_signature' = ?",
id.to_s,
signature
)
end
def synced_articles
portal.articles.where("meta -> '#{SYNC_METADATA_KEY}' ->> 'landing_host_id' = ?", id.to_s)
end
def sync_captain_document(article, entry)
return unless captain_assistant.present? && defined?(Captain::Document)
publication_url = article_public_url(article)
document = find_synced_document(entry[:signature], article)
document ||= captain_assistant.documents.new(external_link: article_public_url(article))
document.external_link = publication_url
document.name = article.title
document.content = article.content
document.status = :available
document.metadata = (document.metadata || {})
.merge(sync_metadata_for(signature: entry[:signature]))
.merge('article_id' => article.id)
document.save!
end
def find_synced_document(signature, article)
by_article = captain_assistant.documents.find_by("metadata ->> 'article_id' = ?", article.id.to_s)
return by_article if by_article.present?
captain_assistant.documents.find_by(external_link: article_public_url(article))
end
def delete_synced_captain_document(article)
return unless captain_assistant.present? && defined?(Captain::Document)
signature = article.meta&.dig(SYNC_METADATA_KEY, 'promotion_signature')
document = captain_assistant.documents.find_by("metadata ->> 'article_id' = ?", article.id.to_s)
document ||= captain_assistant.documents.find_by(
"metadata -> '#{SYNC_METADATA_KEY}' ->> 'landing_host_id' = ? AND metadata -> '#{SYNC_METADATA_KEY}' ->> 'promotion_signature' = ?",
id.to_s,
signature
) if signature.present?
document ||= captain_assistant.documents.find_by(external_link: article_public_url(article))
document&.destroy!
end
def synced_documents
return Captain::Document.none unless captain_assistant.present? && defined?(Captain::Document)
captain_assistant.documents.where("metadata -> '#{SYNC_METADATA_KEY}' ->> 'landing_host_id' = ?", id.to_s)
end
def promotion_article_title(promo, signature)
promo_title = promo['title'].to_s.strip
promo_title = "Promoção #{signature[0, 8].upcase}" if promo_title.blank?
"Promoção Automática - #{hostname.upcase} | #{promo_title}".truncate(220)
end
def promotion_signature(promotion, index)
payload = {
channel: promotion['channel'].to_s.strip,
title: promotion['title'].to_s.strip,
description: promotion['description'].to_s.strip,
coupon_code: promotion['coupon_code'].to_s.strip,
valid_until: promotion['valid_until'].to_s.strip,
position: index
}
Digest::SHA256.hexdigest(payload.to_json)
end
def portal
@ -85,6 +188,68 @@ module LandingHostAiSyncable
inbox.present? && inbox.portal_id.present?
end
def promotion_active?(promotion)
return false unless promotion.is_a?(Hash)
return false unless promotion['active']
return false if promotion_expired?(promotion['valid_until'])
true
end
def promotion_expired?(raw_date)
parsed_date = parse_valid_until(raw_date)
parsed_date.present? && parsed_date < Time.zone.today
end
def parse_valid_until(raw_date)
return if raw_date.blank?
value = raw_date.to_s.strip
Date.strptime(value, '%d/%m/%Y')
rescue ArgumentError
Date.iso8601(value)
rescue ArgumentError
Date.parse(value)
rescue ArgumentError
nil
end
def captain_assistant
return unless inbox.respond_to?(:captain_assistant)
inbox.captain_assistant
end
def sync_metadata_for(signature:)
{
SYNC_METADATA_KEY => {
'source' => 'landing_host_promotions',
'landing_host_id' => id,
'inbox_id' => inbox_id,
'hostname' => hostname,
'promotion_signature' => signature
}
}
end
def article_public_url(article)
base_url = portal.custom_domain.present? ? custom_domain_url_base : frontend_url_base
"#{base_url}/hc/#{portal.slug}/articles/#{article.slug}"
end
def custom_domain_url_base
frontend_uri = URI.parse(ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'))
"#{frontend_uri.scheme}://#{portal.custom_domain}"
rescue URI::InvalidURIError
"https://#{portal.custom_domain}"
end
def frontend_url_base
base = ENV.fetch('HELPCENTER_URL', '').presence || ENV.fetch('FRONTEND_URL', '')
base.delete_suffix('/')
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

View File

@ -94,3 +94,10 @@ captain_notification_scanner_job:
cron: '*/5 * * * *'
class: 'Captain::Notifications::NotificationScannerJob'
queue: scheduled_jobs
# executed every hour
# keeps landing page promotions and Captain knowledge in sync (including expired promotions cleanup)
landing_hosts_promotion_sync_scheduler_job:
cron: '0 * * * *'
class: 'LandingHosts::PromotionSyncSchedulerJob'
queue: scheduled_jobs

View File

@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe LandingHosts::PromotionSyncSchedulerJob do
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class).on_queue('scheduled_jobs')
end
it 'triggers sync on all landing hosts' do
landing_host_one = build_stubbed(:landing_host, hostname: 'promo-1.example.com')
landing_host_two = build_stubbed(:landing_host, hostname: 'promo-2.example.com')
allow(landing_host_one).to receive(:sync_promotion_to_faq)
allow(landing_host_two).to receive(:sync_promotion_to_faq)
allow(LandingHost).to receive(:find_each).and_yield(landing_host_one).and_yield(landing_host_two)
described_class.perform_now
expect(landing_host_one).to have_received(:sync_promotion_to_faq)
expect(landing_host_two).to have_received(:sync_promotion_to_faq)
end
end

View File

@ -4,6 +4,14 @@ RSpec.describe LandingHost, type: :model do
let(:account) { create(:account) }
let(:portal) { create(:portal, account: account) }
let(:inbox) { create(:inbox, account: account, portal: portal) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', '').and_return('https://app.example.com')
allow(ENV).to receive(:fetch).with('FRONTEND_URL', 'https://app.chatwoot.com').and_return('https://app.example.com')
end
describe 'LandingHostAiSyncable' do
let(:landing_host) do
@ -18,38 +26,65 @@ RSpec.describe LandingHost, type: :model do
title: 'Black Friday 50% Off',
description: 'Valid for all suites.',
coupon_code: 'BLACK50',
valid_until: '2024-11-30'
valid_until: '2099-11-30'
},
{
active: true,
channel: 'Facebook',
title: 'Pernoite 79,90',
description: 'Somente esta semana.',
coupon_code: 'FACE79',
valid_until: '2099-12-31'
}
]
})
end
it 'creates a new FAQ article when promotion is active' do
it 'creates one article and one document per active promotion' do
expect do
landing_host.save!
end.to change(Article, :count).by(1)
end.to change(Article, :count).by(2)
.and change(Captain::Document, :count).by(2)
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')
synced_articles = portal.articles.where("meta -> 'landing_promotion_sync' ->> 'landing_host_id' = ?", landing_host.id.to_s)
expect(synced_articles.count).to eq(2)
expect(synced_articles.pluck(:title).join(' | ')).to include('Black Friday 50% Off')
expect(synced_articles.pluck(:title).join(' | ')).to include('Pernoite 79,90')
synced_documents = assistant.documents.where("metadata -> 'landing_promotion_sync' ->> 'landing_host_id' = ?", landing_host.id.to_s)
expect(synced_documents.count).to eq(2)
expect(synced_documents.map(&:content).join(' ')).to include('Black Friday 50% Off')
expect(synced_documents.map(&:content).join(' ')).to include('Pernoite 79,90')
expect(synced_documents).to all(be_available)
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
it 'removes article, document and FAQs for a promotion removed from config' do
landing_host.save!
expect(article.reload.status).to eq('archived')
synced_documents = assistant.documents.where("metadata -> 'landing_promotion_sync' ->> 'landing_host_id' = ?", landing_host.id.to_s)
synced_documents.each do |document|
create(:captain_assistant_response, assistant: assistant, account: account, documentable: document)
end
# keep only one promotion active
landing_host.custom_config['promotions'] = [landing_host.custom_config['promotions'].first]
expect do
landing_host.save!
end.to change(Article, :count).by(-1)
.and change(Captain::Document, :count).by(-1)
.and change(Captain::AssistantResponse, :count).by(-1)
end
it 'treats expired promotions as inactive and cleans synced knowledge' do
landing_host.save!
landing_host.custom_config['promotions'].first['valid_until'] = 1.day.ago.strftime('%d/%m/%Y')
landing_host.custom_config['promotions'].second['valid_until'] = 1.day.ago.strftime('%d/%m/%Y')
expect do
landing_host.save!
end.to change(Article, :count).by(-2)
.and change(Captain::Document, :count).by(-2)
end
end
end