Inboxes without portal_id were crashing with NoMethodError on save, blocking landing host creation via UI for any inbox without a portal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
265 lines
8.4 KiB
Ruby
265 lines
8.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'digest'
|
|
|
|
module LandingHostAiSyncable
|
|
extend ActiveSupport::Concern
|
|
SYNC_METADATA_KEY = 'landing_promotion_sync'
|
|
|
|
included do
|
|
after_save :sync_promotion_to_faq
|
|
end
|
|
|
|
def sync_promotion_to_faq
|
|
return unless custom_config.is_a?(Hash)
|
|
return cleanup_all_synced_knowledge unless can_sync_to_portal?
|
|
|
|
active_entries = extract_promotions.each_with_index.filter_map do |promotion, index|
|
|
next unless promotion_active?(promotion)
|
|
|
|
{
|
|
promotion: promotion,
|
|
signature: promotion_signature(promotion, index)
|
|
}
|
|
end
|
|
|
|
sync_active_promotions(active_entries)
|
|
cleanup_stale_synced_knowledge(active_entries.pluck(:signature))
|
|
cleanup_legacy_aggregated_sync
|
|
end
|
|
|
|
private
|
|
|
|
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 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)
|
|
|
|
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 cleanup_stale_synced_knowledge(active_signatures)
|
|
return unless can_sync_to_portal?
|
|
|
|
synced_articles.find_each do |article|
|
|
signature = article.meta&.dig(SYNC_METADATA_KEY, 'promotion_signature')
|
|
next if signature.present? && active_signatures.include?(signature)
|
|
|
|
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 cleanup_legacy_aggregated_sync
|
|
return unless can_sync_to_portal?
|
|
|
|
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 cleanup_all_synced_knowledge
|
|
cleanup_stale_synced_knowledge([])
|
|
cleanup_legacy_aggregated_sync
|
|
end
|
|
|
|
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)
|
|
if signature.present?
|
|
document ||= captain_assistant.documents.find_by(
|
|
"metadata -> '#{SYNC_METADATA_KEY}' ->> 'landing_host_id' = ? AND metadata -> '#{SYNC_METADATA_KEY}' ->> 'promotion_signature' = ?",
|
|
id.to_s,
|
|
signature
|
|
)
|
|
end
|
|
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
|
|
inbox.portal
|
|
end
|
|
|
|
def can_sync_to_portal?
|
|
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
|
|
end
|
|
end
|