#!/usr/bin/env ruby require_relative "../config/environment" require "pathname" require "optparse" class Import attr_reader :db_path, :untenanted_db_path attr_reader :account, :tenant, :mapping def initialize(db_path, untenanted_db_path) @db_path = Pathname(db_path) @untenanted_db_path = Pathname(untenanted_db_path) @mapping = nil end def import_database raise "The given database file doesn't exist" unless db_path.exist? @mapping = {} setup_account ActiveRecord::Base.no_touching do Current.with(account: account) do Webhook.skip_callback(:create, :after, :create_delinquency_tracker!) Comment.skip_callback(:commit, :after, :watch_card_by_creator) Mention.skip_callback(:commit, :after, :watch_source_by_mentionee) Notification.skip_callback(:commit, :after, :broadcast_unread) Notification.skip_callback(:create, :after, :bundle) Reaction.skip_callback(:create, :after, :register_card_activity) Card.skip_callback(:save, :before, :set_default_title) Card.skip_callback(:update, :after, :handle_board_change) Event.suppress do # copy_entropies copy_users copy_boards copy_accesses copy_columns copy_cards copy_steps copy_comments copy_mentions copy_reactions copy_tags copy_watches copy_pins copy_webhooks copy_push_subscriptions copy_filters end copy_events copy_notifications copy_notification_bundles fix_links end end @mapping end private def setup_account puts "⏩ Setting up account" oldest_admin = import.users.order(id: :asc).where(role: :admin, active: true).first raise "No admin user found in the database" unless oldest_admin membership = untenanted.memberships.find(oldest_admin.membership_id) account = import.accounts.sole new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address) new_membership = new_identity.memberships.find_or_create_by!(tenant: account.external_account_id.to_s) if Account.all.exists?(external_account_id: account.external_account_id) @account = Account.find_by!(external_account_id: account.external_account_id) @tenant = @account.external_account_id else @account = Account.create_with_admin_user( account: { external_account_id: account.external_account_id.to_s, name: account.name }, owner: { name: oldest_admin.name, membership_id: new_membership.id } ) @tenant = @account.external_account_id end old_join_code = import.account_join_codes.sole attributes = { usage_count: old_join_code.usage_count, usage_limit: old_join_code.usage_limit } attributes[:code] = old_join_code.code unless Account::JoinCode.all.exists?(code: old_join_code.code) @account.join_code.update_columns(**attributes) puts "✅ Account set up!" end def copy_users puts "⏩ Copying users" mapping[:users] ||= {} import.users.find_each do |old_user| new_membership = nil if old_user.active && old_user.membership_id membership = untenanted.memberships.find(old_user.membership_id) new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address) new_membership = new_identity.memberships.find_or_create_by!(tenant: tenant) end new_user = User.find_or_create_by!(account_id: account.id, membership_id: new_membership&.id) do |u| u.name = old_user.name u.role = old_user.role u.active = old_user.active end old_settings = old_user.settings if old_settings User::Settings.find_or_create_by!(user_id: new_user.id) do |s| s.bundle_email_frequency = old_settings.bundle_email_frequency s.timezone_name = old_settings.timezone_name end end mapping[:users][old_user.id] = new_user.id end puts "✅ Copied #{mapping[:users].size} users" end def copy_boards puts "⏩ Copying boards" mapping[:boards] ||= {} import.boards.find_each do |old_board| new_board = Board.create!( account_id: account.id, creator_id: mapping[:users][old_board.creator_id], name: old_board.name, all_access: old_board.all_access, created_at: old_board.created_at, updated_at: old_board.updated_at ) old_publication = old_board.publication if old_publication Board::Publication.create!( board_id: new_board.id, key: old_publication.key, created_at: old_publication.created_at, updated_at: old_publication.updated_at ) end mapping[:boards][old_board.id] = new_board.id end puts "✅ Copied #{mapping[:boards].size} boards" end def copy_columns puts "⏩ Copying columns" mapping[:columns] ||= {} import.columns.find_each do |old_column| new_column = Column.create!( account_id: account.id, board_id: mapping[:boards][old_column.board_id], name: old_column.name, color: old_column.color, position: old_column.position, created_at: old_column.created_at, updated_at: old_column.updated_at ) mapping[:columns][old_column.id] = new_column.id end puts "✅ Copied #{mapping[:columns].size} columns" end def copy_cards puts "⏩ Copying cards" mapping[:cards] ||= {} import.cards.find_each do |old_card| new_card = Card.create!( account_id: account.id, board_id: mapping[:boards][old_card.board_id], column_id: old_card.column_id ? mapping[:columns][old_card.column_id] : nil, creator_id: mapping[:users][old_card.creator_id], title: old_card.title, status: old_card.status, due_on: old_card.due_on, last_active_at: old_card.last_active_at, created_at: old_card.created_at, updated_at: old_card.updated_at ) copy_rich_text(old_card, new_card, "Card", "description") copy_attachment(old_card, new_card, "Card", "image") old_activity_spike = old_card.activity_spike if old_activity_spike Card::ActivitySpike.create!( card_id: new_card.id, created_at: old_activity_spike.created_at, updated_at: old_activity_spike.updated_at ) end old_engagement = old_card.engagement if old_engagement Card::Engagement.create!( card_id: new_card.id, status: old_engagement.status, created_at: old_engagement.created_at, updated_at: old_engagement.updated_at ) end old_goldness = old_card.goldness if old_goldness Card::Goldness.create!( card_id: new_card.id, created_at: old_goldness.created_at, updated_at: old_goldness.updated_at ) end old_not_now = old_card.not_now if old_not_now Card::NotNow.create!( card_id: new_card.id, user_id: old_not_now.user_id ? mapping[:users][old_not_now.user_id] : nil, created_at: old_not_now.created_at, updated_at: old_not_now.updated_at ) end old_card.assignments.each do |old_assignment| Assignment.create!( card_id: new_card.id, assignee_id: mapping[:users][old_assignment.assignee_id], assigner_id: mapping[:users][old_assignment.assigner_id], created_at: old_assignment.created_at, updated_at: old_assignment.updated_at ) end old_closure = old_card.closure if old_closure Closure.create!( card_id: new_card.id, user_id: old_closure.user_id ? mapping[:users][old_closure.user_id] : nil, created_at: old_closure.created_at, updated_at: old_closure.updated_at ) end mapping[:cards][old_card.id] = new_card.id end puts "✅ Copied #{mapping[:cards].size} cards" end def copy_steps puts "⏩ Copying steps" import.steps.find_each do |old_step| Step.create!( account_id: account.id, card_id: mapping[:cards][old_step.card_id], content: old_step.content, completed: old_step.completed, created_at: old_step.created_at, updated_at: old_step.updated_at ) end puts "✅ Copied steps" end def copy_comments puts "⏩ Copying comments" mapping[:comments] ||= {} import.comments.find_each do |old_comment| new_comment = Comment.create!( account_id: account.id, card_id: mapping[:cards][old_comment.card_id], creator_id: mapping[:users][old_comment.creator_id], created_at: old_comment.created_at, updated_at: old_comment.updated_at ) copy_rich_text(old_comment, new_comment, "Comment", "body") mapping[:comments][old_comment.id] = new_comment.id end puts "✅ Copied #{mapping[:comments].size} comments" end def copy_mentions puts "⏩ Copying mentions" mapping[:mentions] ||= {} import.mentions.find_each do |old_mention| new_mention = Mention.create!( source_type: old_mention.source_type, source_id: mapping[old_mention.source_type.tableize.to_sym][old_mention.source_id], mentioner_id: mapping[:users][old_mention.mentioner_id], mentionee_id: mapping[:users][old_mention.mentionee_id], created_at: old_mention.created_at, updated_at: old_mention.updated_at ) mapping[:mentions][old_mention.id] = new_mention.id end puts "✅ Copied #{mapping[:mentions].size} mentions" end def copy_accesses puts "⏩ Copying accesses" mapping[:accesses] ||= {} import.accesses.find_each do |old_access| new_access = Access.find_or_create_by!( board_id: mapping[:boards][old_access.board_id], user_id: mapping[:users][old_access.user_id] ) do |access| access.involvement = old_access.involvement access.accessed_at = old_access.accessed_at access.created_at = old_access.created_at access.updated_at = old_access.updated_at end mapping[:accesses][old_access.id] = new_access.id end puts "✅ Copied #{mapping[:accesses].size} accesses" end def copy_notifications puts "⏩ Copying notifications" mapping[:notifications] ||= {} import.notifications.find_each do |old_notification| new_notification = Notification.find_or_create_by!( account_id: account.id, user_id: mapping[:users][old_notification.user_id], creator_id: old_notification.creator_id ? mapping[:users][old_notification.creator_id] : nil, source_type: old_notification.source_type, source_id: mapping.fetch(old_notification.source_type.tableize.to_sym)[old_notification.source_id], read_at: old_notification.read_at, created_at: old_notification.created_at, updated_at: old_notification.updated_at ) mapping[:notifications][old_notification.id] = new_notification.id end puts "✅ Copied #{mapping[:notifications].size} notifications" end def copy_notification_bundles puts "⏩ Copying notification bundles" mapping[:notification_bundles] ||= {} import.notification_bundles.find_each do |old_bundle| new_bundle = Notification::Bundle.create!( account_id: account.id, user_id: mapping[:users][old_bundle.user_id], status: old_bundle.status, starts_at: old_bundle.starts_at, ends_at: old_bundle.ends_at, created_at: old_bundle.created_at, updated_at: old_bundle.updated_at ) mapping[:notification_bundles][old_bundle.id] = new_bundle.id end puts "✅ Copied #{mapping[:notification_bundles].size} notification bundles" end def copy_entropies puts "⏩ Copying entropies" import.entropies.find_each do |old_entropy| container_id = case old_entropy.container_type when "Account" then account.id when "Board" then mapping[:boards][old_entropy.container_id] when "Card" then mapping[:cards][old_entropy.container_id] else next end Entropy.create!( container_type: old_entropy.container_type, container_id: container_id, auto_postpone_period: old_entropy.auto_postpone_period, created_at: old_entropy.created_at, updated_at: old_entropy.updated_at ) end puts "✅ Copied entropies" end def copy_filters puts "⏩ Copying filters" mapping[:filters] ||= {} import.filters.find_each do |old_filter| new_filter = Filter.create!( account_id: account.id, creator_id: mapping[:users][old_filter.creator_id], fields: old_filter.fields, params_digest: old_filter.params_digest, created_at: old_filter.created_at, updated_at: old_filter.updated_at ) import.assignees_filters.where(filter_id: old_filter.id).each do |join| AssigneesFilter.find_or_create_by!(filter_id: new_filter.id, user_id: mapping[:users][join.user_id]) end import.assigners_filters.where(filter_id: old_filter.id).each do |join| AssignersFilter.find_or_create_by!(filter_id: new_filter.id, user_id: mapping[:users][join.user_id]) end import.boards_filters.where(filter_id: old_filter.id).each do |join| BoardsFilter.find_or_create_by!(filter_id: new_filter.id, board_id: mapping[:boards][join.board_id]) end import.closers_filters.where(filter_id: old_filter.id).each do |join| ClosersFilter.find_or_create_by!(filter_id: new_filter.id, user_id: mapping[:users][join.user_id]) end import.creators_filters.where(filter_id: old_filter.id).each do |join| CreatorsFilter.find_or_create_by!(filter_id: new_filter.id, user_id: mapping[:users][join.user_id]) end import.filters_tags.where(filter_id: old_filter.id).each do |join| FiltersTag.find_or_create_by!(filter_id: new_filter.id, tag_id: mapping[:tags][join.tag_id]) end mapping[:filters][old_filter.id] = new_filter.id end puts "✅ Copied #{mapping[:filters].size} filters" end def copy_events puts "⏩ Copying events" mapping[:events] ||= {} import.events.find_each do |old_event| new_event = Event.create!( account_id: account.id, board_id: mapping[:boards][old_event.board_id], creator_id: mapping[:users][old_event.creator_id], eventable_type: old_event.eventable_type, eventable_id: mapping[old_event.eventable_type.tableize.to_sym][old_event.eventable_id], action: old_event.action, particulars: old_event.particulars, created_at: old_event.created_at, updated_at: old_event.updated_at ) mapping[:events][old_event.id] = new_event.id end puts "✅ Copied #{mapping[:events].size} events" end def copy_rich_text(old_record, new_record, record_type, name) old_rich_text = import.rich_texts.find_by(record_type: record_type, record_id: old_record.id, name: name) return unless old_rich_text new_rich_text = ActionText::RichText.create!( record: new_record, name: name, body: old_rich_text.body, created_at: old_rich_text.created_at, updated_at: old_rich_text.updated_at ) mapping[:rich_text] ||= {} mapping[:rich_text][old_rich_text.id] = new_rich_text.id end def copy_attachment(old_record, new_record, record_type, name) old_attachment = import.attachments.find_by(record_type: record_type, record_id: old_record.id, name: name) return unless old_attachment old_blob = import.blobs.find(old_attachment.blob_id) new_blob = ActiveStorage::Blob.find_or_create_by!( key: old_blob.key, filename: old_blob.filename, content_type: old_blob.content_type, metadata: old_blob.metadata, service_name: old_blob.service_name, byte_size: old_blob.byte_size, checksum: old_blob.checksum, created_at: old_blob.created_at ) mapping[:blobs] ||= {} mapping[:blobs][old_blob.id] = new_blob.id new_attachment = ActiveStorage::Attachment.find_or_create_by!( name: name, record: new_record, blob: new_blob, created_at: old_attachment.created_at ) mapping[:attachments] ||= {} mapping[:attachments][old_attachment.id] = new_attachment.id end def copy_reactions puts "⏩ Copying reactions" mapping[:reactions] ||= {} import.reactions.find_each do |old_reaction| new_reaction = Reaction.create!( comment_id: mapping[:comments][old_reaction.comment_id], reacter_id: mapping[:users][old_reaction.reacter_id], content: old_reaction.content, created_at: old_reaction.created_at, updated_at: old_reaction.updated_at ) mapping[:reactions][old_reaction.id] = new_reaction.id end puts "✅ Copied #{mapping[:reactions].size} reactions" end def copy_tags puts "⏩ Copying tags" mapping[:tags] ||= {} mapping[:taggings] ||= {} import.tags.find_each do |old_tag| new_tag = Tag.find_or_create_by!(title: old_tag.title) do |t| t.account_id = account.id t.created_at = old_tag.created_at t.updated_at = old_tag.updated_at end mapping[:tags][old_tag.id] = new_tag.id end import.taggings.find_each do |old_tagging| new_tagging = Tagging.create!( tag_id: mapping[:tags][old_tagging.tag_id], card_id: mapping[:cards][old_tagging.card_id], created_at: old_tagging.created_at, updated_at: old_tagging.updated_at ) mapping[:taggings][old_tagging.id] = new_tagging.id end puts "✅ Copied #{mapping[:tags].size} tags and #{mapping[:taggings].size} taggings" end def copy_watches puts "⏩ Copying watches" mapping[:watches] ||= {} import.watches.find_each do |old_watch| new_watch = Watch.create!( user_id: mapping[:users][old_watch.user_id], card_id: mapping[:cards][old_watch.card_id], watching: old_watch.watching, created_at: old_watch.created_at, updated_at: old_watch.updated_at ) mapping[:watches][old_watch.id] = new_watch.id end puts "✅ Copied #{mapping[:watches].size} watches" end def copy_pins puts "⏩ Copying pins" mapping[:pins] ||= {} import.pins.find_each do |old_pin| new_pin = Pin.create!( user_id: mapping[:users][old_pin.user_id], card_id: mapping[:cards][old_pin.card_id], created_at: old_pin.created_at, updated_at: old_pin.updated_at ) mapping[:pins][old_pin.id] = new_pin.id end puts "✅ Copied #{mapping[:pins].size} pins" end def copy_webhooks puts "⏩ Copying webhooks" mapping[:webhooks] ||= {} mapping[:webhook_deliveries] ||= {} import.webhooks.find_each do |old_webhook| new_webhook = Webhook.create!( account_id: account.id, board_id: mapping[:boards][old_webhook.board_id], name: old_webhook.name, url: old_webhook.url, signing_secret: old_webhook.signing_secret, subscribed_actions: old_webhook.subscribed_actions, active: old_webhook.active, created_at: old_webhook.created_at, updated_at: old_webhook.updated_at ) mapping[:webhooks][old_webhook.id] = new_webhook.id old_tracker = import.webhook_delinquency_trackers.find_by(webhook_id: old_webhook.id) Webhook::DelinquencyTracker.create!( webhook_id: new_webhook.id, consecutive_failures_count: old_tracker.consecutive_failures_count, first_failure_at: old_tracker.first_failure_at, created_at: old_tracker.created_at, updated_at: old_tracker.updated_at ) end import.webhook_deliveries.find_each do |old_delivery| new_delivery = Webhook::Delivery.create!( webhook_id: mapping[:webhooks][old_delivery.webhook_id], event_id: mapping[:events][old_delivery.event_id], state: old_delivery.state, request: old_delivery.request, response: old_delivery.response, created_at: old_delivery.created_at, updated_at: old_delivery.updated_at ) mapping[:webhook_deliveries][old_delivery.id] = new_delivery.id end puts "✅ Copied #{mapping[:webhooks].size} webhooks and #{mapping[:webhook_deliveries].size} deliveries" end def copy_push_subscriptions puts "⏩ Copying push subscriptions" mapping[:push_subscriptions] ||= {} import.push_subscriptions.find_each do |old_subscription| new_subscription = Push::Subscription.create!( account_id: account.id, user_id: mapping[:users][old_subscription.user_id], endpoint: old_subscription.endpoint, p256dh_key: old_subscription.p256dh_key, auth_key: old_subscription.auth_key, user_agent: old_subscription.user_agent, created_at: old_subscription.created_at, updated_at: old_subscription.updated_at ) mapping[:push_subscriptions][old_subscription.id] = new_subscription.id end puts "✅ Copied #{mapping[:push_subscriptions].size} push subscriptions" end def fix_links puts "⏩ Fixing links" puts "✅ Fixed #{mapping[:cards].size} links" end def import @import ||= Models.new(db_path) rescue => e $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] raise "Couldn't open the given database: #{e}" end def untenanted @untenanted ||= Models.new(untenanted_db_path) rescue => e $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] raise "Couldn't open the given untenanted database: #{e}" end end class Models attr_reader :application_record def initialize(db_path) const_name = "ImportBase#{db_path.hash.abs}" if self.class.const_defined?(const_name) @application_record = self.class.const_get(const_name) else @application_record = Class.new(ActiveRecord::Base) do self.abstract_class = true def self.models const_get("MODELS") end delegate :models, to: :class end self.class.const_set(const_name, @application_record) end @application_record.establish_connection adapter: "sqlite3", database: db_path @application_record.const_set("MODELS", self) end def identities @identities ||= Class.new(application_record) do self.table_name = "identities" end end def memberships @memberships ||= begin models = self Class.new(application_record) do self.table_name = "memberships" def identity @identity ||= models.identities.find_by(id: identity_id) end end end end def accounts @accounts ||= Class.new(application_record) do self.table_name = "accounts" end end def account_join_codes @account_join_codes ||= Class.new(application_record) do self.table_name = "account_join_codes" end end def users @users ||= begin models = self Class.new(application_record) do self.table_name = "users" def settings @settings ||= models.user_settings.find_by(user_id: id) end end end end def boards @boards ||= begin models = self Class.new(application_record) do self.table_name = "boards" def publication @publication ||= models.board_publications.find_by(board_id: id) end end end end def columns @columns ||= Class.new(application_record) do self.table_name = "columns" end end def cards @cards ||= begin models = self Class.new(application_record) do self.table_name = "cards" def activity_spike @activity_spike ||= models.card_activity_spikes.find_by(card_id: id) end def engagement @engagement ||= models.card_engagements.find_by(card_id: id) end def goldness @goldness ||= models.card_goldnesses.find_by(card_id: id) end def not_now @not_now ||= models.card_not_nows.find_by(card_id: id) end def assignments models.assignments.where(card_id: id) end def closure @closure ||= models.closures.find_by(card_id: id) end end end end def comments @comments ||= Class.new(application_record) do self.table_name = "comments" end end def steps @steps ||= Class.new(application_record) do self.table_name = "steps" end end def reactions @reactions ||= Class.new(application_record) do self.table_name = "reactions" end end def tags @tags ||= Class.new(application_record) do self.table_name = "tags" end end def taggings @taggings ||= Class.new(application_record) do self.table_name = "taggings" end end def watches @watches ||= Class.new(application_record) do self.table_name = "watches" end end def pins @pins ||= Class.new(application_record) do self.table_name = "pins" end end def webhooks @webhooks ||= Class.new(application_record) do self.table_name = "webhooks" end end def webhook_deliveries @webhook_deliveries ||= Class.new(application_record) do self.table_name = "webhook_deliveries" end end def webhook_delinquency_trackers @webhook_delinquency_trackers ||= Class.new(application_record) do self.table_name = "webhook_delinquency_trackers" end end def push_subscriptions @push_subscriptions ||= Class.new(application_record) do self.table_name = "push_subscriptions" end end def assignments @assignments ||= Class.new(application_record) do self.table_name = "assignments" end end def closures @closures ||= Class.new(application_record) do self.table_name = "closures" end end def accesses @accesses ||= Class.new(application_record) do self.table_name = "accesses" end end def events @events ||= Class.new(application_record) do self.table_name = "events" end end def rich_texts @rich_texts ||= Class.new(application_record) do self.table_name = "action_text_rich_texts" end end def attachments @attachments ||= Class.new(application_record) do self.table_name = "active_storage_attachments" end end def blobs @blobs ||= Class.new(application_record) do self.table_name = "active_storage_blobs" end end def user_settings @user_settings ||= Class.new(application_record) do self.table_name = "user_settings" end end def board_publications @board_publications ||= Class.new(application_record) do self.table_name = "board_publications" end end def card_activity_spikes @card_activity_spikes ||= Class.new(application_record) do self.table_name = "card_activity_spikes" end end def card_engagements @card_engagements ||= Class.new(application_record) do self.table_name = "card_engagements" end end def card_goldnesses @card_goldnesses ||= Class.new(application_record) do self.table_name = "card_goldnesses" end end def card_not_nows @card_not_nows ||= Class.new(application_record) do self.table_name = "card_not_nows" end end def mentions @mentions ||= Class.new(application_record) do self.table_name = "mentions" end end def notifications @notifications ||= Class.new(application_record) do self.table_name = "notifications" end end def notification_bundles @notification_bundles ||= Class.new(application_record) do self.table_name = "notification_bundles" end end def entropies @entropies ||= Class.new(application_record) do self.table_name = "entropies" end end def filters @filters ||= Class.new(application_record) do self.table_name = "filters" end end def assignees_filters @assignees_filters ||= Class.new(application_record) do self.table_name = "assignees_filters" end end def assigners_filters @assigners_filters ||= Class.new(application_record) do self.table_name = "assigners_filters" end end def boards_filters @boards_filters ||= Class.new(application_record) do self.table_name = "boards_filters" end end def closers_filters @closers_filters ||= Class.new(application_record) do self.table_name = "closers_filters" end end def creators_filters @creators_filters ||= Class.new(application_record) do self.table_name = "creators_filters" end end def filters_tags @filters_tags ||= Class.new(application_record) do self.table_name = "filters_tags" end end end options = {} parser = OptionParser.new do |parser| parser.banner = "Usage: #{$PROGRAM_NAME} " parser.on("-h", "--help", "Show this help message") do puts parser exit end end parser.parse! db_path = ARGV[0] untenanted_db_path = ARGV[1] if db_path.nil? || untenanted_db_path.nil? $stderr.puts "Error: both db_path and untenanted_db_path are required" $stderr.puts $stderr.puts parser exit 1 end begin Import.new(db_path, untenanted_db_path).import_database rescue => e $stderr.puts "Error: #{e.message}" $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] exit 1 end