diff --git a/lib/tasks/clone_portal.rake b/lib/tasks/clone_portal.rake new file mode 100644 index 000000000..2a4639353 --- /dev/null +++ b/lib/tasks/clone_portal.rake @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +namespace :portal do # rubocop:disable Metrics/BlockLength + desc 'Clone a help center portal with all its categories, folders, and articles. Optionally clone to another account.' + task :clone, %i[portal_id target_account_id] => :environment do |_task, args| # rubocop:disable Metrics/BlockLength + portal_id = args[:portal_id] + target_account_id = args[:target_account_id] + + if portal_id.blank? + puts 'Usage: rails portal:clone[] or rails portal:clone[,]' + next + end + + source_portal = Portal.find_by(id: portal_id) + unless source_portal + puts "ERROR: Portal with ID #{portal_id} not found." + next + end + + target_account = if target_account_id.present? + Account.find_by(id: target_account_id).tap do |account| + unless account + puts "ERROR: Account with ID #{target_account_id} not found." + next + end + end + else + source_portal.account + end + next unless target_account + + cross_account = target_account.id != source_portal.account_id + if cross_account + default_author = target_account.administrators.first || target_account.agents.first + unless default_author + puts "ERROR: Target account #{target_account.id} has no administrators or agents to assign as article author." + next + end + puts "Cross-account clone: articles will be authored by #{default_author.name} (ID: #{default_author.id})" + end + + puts "Cloning portal '#{source_portal.name}' (ID: #{source_portal.id}) to account #{target_account.id}..." + + old_to_new_category = {} + old_to_new_folder = {} + old_to_new_article = {} + + ActiveRecord::Base.transaction do # rubocop:disable Metrics/BlockLength + # 1. Clone the portal + new_slug = "#{source_portal.slug}-copy-#{Time.now.utc.to_i}" + new_portal = source_portal.dup + new_portal.slug = new_slug + new_portal.custom_domain = nil + new_portal.channel_web_widget_id = nil + new_portal.account_id = target_account.id + new_portal.name = "#{source_portal.name} (Copy)" + new_portal.save! + puts "Created portal '#{new_portal.name}' (ID: #{new_portal.id}, slug: #{new_portal.slug})" + + # Copy logo if present + if source_portal.logo.attached? + new_portal.logo.attach(source_portal.logo.blob) + puts 'Copied portal logo.' + end + + # 2. Clone categories (without self-referential FKs first) + puts 'Cloning categories...' + source_portal.categories.find_each do |category| + new_category = category.dup + new_category.portal_id = new_portal.id + new_category.parent_category_id = nil + new_category.associated_category_id = nil + new_category.save! + old_to_new_category[category.id] = new_category.id + end + puts "Cloned #{old_to_new_category.count} categories." + + # 3. Remap category self-referential relationships + source_portal.categories.where.not(parent_category_id: nil).find_each do |category| + new_parent_id = old_to_new_category[category.parent_category_id] + next unless new_parent_id + + new_category = Category.find(old_to_new_category[category.id]) + new_category.update!(parent_category_id: new_parent_id) + end + + source_portal.categories.where.not(associated_category_id: nil).find_each do |category| + new_assoc_id = old_to_new_category[category.associated_category_id] + next unless new_assoc_id + + new_category = Category.find(old_to_new_category[category.id]) + new_category.update!(associated_category_id: new_assoc_id) + end + + # 4. Clone related categories (junction table) + related_count = 0 + source_portal.categories.find_each do |category| + category.category_related_categories.find_each do |rc| + new_category_id = old_to_new_category[rc.category_id] + new_related_id = old_to_new_category[rc.related_category_id] + next unless new_category_id && new_related_id + + RelatedCategory.create!(category_id: new_category_id, related_category_id: new_related_id) + related_count += 1 + end + end + puts "Cloned #{related_count} related category links." if related_count.positive? + + # 5. Clone folders + puts 'Cloning folders...' + source_portal.folders.find_each do |folder| + new_folder = folder.dup + new_folder.category_id = old_to_new_category[folder.category_id] + new_folder.save! + old_to_new_folder[folder.id] = new_folder.id + end + puts "Cloned #{old_to_new_folder.count} folders." + + # 6. Clone articles (without associated_article_id first) + puts 'Cloning articles...' + source_portal.articles.find_each do |article| + new_article = article.dup + new_article.portal_id = new_portal.id + new_article.category_id = old_to_new_category[article.category_id] if article.category_id + new_article.folder_id = old_to_new_folder[article.folder_id] if article.folder_id + new_article.author_id = default_author.id if cross_account + new_article.associated_article_id = nil + new_article.slug = "#{Time.now.utc.to_i}-#{SecureRandom.hex(4)}-#{article.slug.last(100)}" + new_article.views = 0 + new_article.save! + old_to_new_article[article.id] = new_article.id + end + puts "Cloned #{old_to_new_article.count} articles." + + # 7. Remap article self-referential relationships + source_portal.articles.where.not(associated_article_id: nil).find_each do |article| + new_assoc_id = old_to_new_article[article.associated_article_id] + next unless new_assoc_id + + new_article = Article.find(old_to_new_article[article.id]) + new_article.update!(associated_article_id: new_assoc_id) + end + + puts "\nCloning complete!" + puts "New portal ID: #{new_portal.id}" + puts "New portal slug: #{new_portal.slug}" + puts "Summary: #{old_to_new_category.count} categories, #{old_to_new_folder.count} folders, #{old_to_new_article.count} articles" + end + rescue ActiveRecord::RecordNotFound + puts "ERROR: Portal with ID #{portal_id} not found." + rescue ActiveRecord::RecordInvalid => e + puts "ERROR: Failed to clone portal. Validation error: #{e.message}" + end +end