iachat/app/models/concerns/landing_host_ai_syncable.rb
Rodribm10 1c21b8d815 fix: guard landing host sync when inbox has no portal
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>
2026-04-17 22:49:35 -03:00

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