diff --git a/app/jobs/landing_hosts/promotion_sync_scheduler_job.rb b/app/jobs/landing_hosts/promotion_sync_scheduler_job.rb new file mode 100644 index 000000000..5b72d1ad9 --- /dev/null +++ b/app/jobs/landing_hosts/promotion_sync_scheduler_job.rb @@ -0,0 +1,7 @@ +class LandingHosts::PromotionSyncSchedulerJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + LandingHost.find_each(&:sync_promotion_to_faq) + end +end diff --git a/app/models/concerns/landing_host_ai_syncable.rb b/app/models/concerns/landing_host_ai_syncable.rb index 65330a77b..3f364030a 100644 --- a/app/models/concerns/landing_host_ai_syncable.rb +++ b/app/models/concerns/landing_host_ai_syncable.rb @@ -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 diff --git a/config/schedule.yml b/config/schedule.yml index 0818a761e..689a48d82 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -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 diff --git a/spec/jobs/landing_hosts/promotion_sync_scheduler_job_spec.rb b/spec/jobs/landing_hosts/promotion_sync_scheduler_job_spec.rb new file mode 100644 index 000000000..a435ba9f5 --- /dev/null +++ b/spec/jobs/landing_hosts/promotion_sync_scheduler_job_spec.rb @@ -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 diff --git a/spec/models/landing_host_spec.rb b/spec/models/landing_host_spec.rb index c45ef2a6a..4f8be9798 100644 --- a/spec/models/landing_host_spec.rb +++ b/spec/models/landing_host_spec.rb @@ -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