Break import and export objects into record sets
This commit is contained in:
@@ -1,7 +1,19 @@
|
||||
class ImportAccountDataJob < ApplicationJob
|
||||
include ActiveJob::Continuable
|
||||
|
||||
queue_as :backend
|
||||
|
||||
def perform(import)
|
||||
import.perform
|
||||
step :validate do
|
||||
import.validate \
|
||||
start: step.cursor,
|
||||
callback: proc { |record_set:, record_id:| step.set! [ record_set, record_id ] }
|
||||
end
|
||||
|
||||
step :process do
|
||||
import.process \
|
||||
start: step.cursor,
|
||||
callback: proc { |record_set:, record_id:| step.set! [ record_set, record_id ] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
class Account::DataTransfer::AccessRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
accessed_at
|
||||
account_id
|
||||
board_id
|
||||
created_at
|
||||
id
|
||||
involvement
|
||||
updated_at
|
||||
user_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Access.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(access)
|
||||
zip.add_file "data/accesses/#{access.id}.json", access.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/accesses/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Access.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Access record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet
|
||||
ACCOUNT_ATTRIBUTES = %w[
|
||||
join_code
|
||||
name
|
||||
]
|
||||
|
||||
JOIN_CODE_ATTRIBUTES = %w[
|
||||
code
|
||||
usage_count
|
||||
usage_limit
|
||||
]
|
||||
|
||||
private
|
||||
def records
|
||||
[ account ]
|
||||
end
|
||||
|
||||
def export_record(account)
|
||||
zip.add_file "data/account.json", account.as_json.merge(join_code: account.join_code.as_json).to_json
|
||||
end
|
||||
|
||||
def files
|
||||
[ "data/account.json" ]
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
account_data = load(files.first)
|
||||
join_code_data = account_data.delete("join_code")
|
||||
|
||||
account.update!(name: account_data.fetch("name"))
|
||||
account.join_code.update!(join_code_data.slice("usage_count", "usage_limit"))
|
||||
account.join_code.update(code: join_code_data.fetch("code"))
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
|
||||
unless (ACCOUNT_ATTRIBUTES - data.keys).empty?
|
||||
raise IntegrityError, "Account record missing required fields"
|
||||
end
|
||||
|
||||
unless data.key?("join_code")
|
||||
raise IntegrityError, "Account record missing 'join_code' field"
|
||||
end
|
||||
|
||||
unless data["join_code"].is_a?(Hash)
|
||||
raise IntegrityError, "'join_code' field must be a JSON object"
|
||||
end
|
||||
|
||||
unless (JOIN_CODE_ATTRIBUTES - data["join_code"].keys).empty?
|
||||
raise IntegrityError, "'join_code' field missing required keys"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
body
|
||||
created_at
|
||||
id
|
||||
name
|
||||
record_id
|
||||
record_type
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
ActionText::RichText.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(rich_text)
|
||||
data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body))
|
||||
zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/action_text_rich_texts/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data["body"] = convert_gids_to_sgids(data["body"])
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
ActionText::RichText.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "ActionTextRichText record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
def convert_sgids_to_gids(content)
|
||||
return nil if content.blank?
|
||||
|
||||
content.send(:attachment_nodes).each do |node|
|
||||
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
|
||||
record = sgid&.find
|
||||
next if record&.account_id != account.id
|
||||
|
||||
node["gid"] = record.to_global_id.to_s
|
||||
node.remove_attribute("sgid")
|
||||
end
|
||||
|
||||
content.fragment.source.to_html
|
||||
end
|
||||
|
||||
def convert_gids_to_sgids(html)
|
||||
return html if html.blank?
|
||||
|
||||
fragment = Nokogiri::HTML.fragment(html)
|
||||
|
||||
fragment.css("action-text-attachment[gid]").each do |node|
|
||||
gid = GlobalID.parse(node["gid"])
|
||||
next unless gid
|
||||
|
||||
record = gid.find
|
||||
node["sgid"] = record.attachable_sgid
|
||||
node.remove_attribute("gid")
|
||||
end
|
||||
|
||||
fragment.to_html
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::ActiveStorageAttachmentRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
blob_id
|
||||
created_at
|
||||
id
|
||||
name
|
||||
record_id
|
||||
record_type
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
ActiveStorage::Attachment.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(attachment)
|
||||
zip.add_file "data/active_storage_attachments/#{attachment.id}.json", attachment.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/active_storage_attachments/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
ActiveStorage::Attachment.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "ActiveStorageAttachment record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
byte_size
|
||||
checksum
|
||||
content_type
|
||||
created_at
|
||||
filename
|
||||
id
|
||||
key
|
||||
metadata
|
||||
service_name
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
ActiveStorage::Blob.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(blob)
|
||||
zip.add_file "data/active_storage_blobs/#{blob.id}.json", blob.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/active_storage_blobs/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
ActiveStorage::Blob.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "ActiveStorageBlob record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::AssignmentRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
assignee_id
|
||||
assigner_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Assignment.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(assignment)
|
||||
zip.add_file "data/assignments/#{assignment.id}.json", assignment.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/assignments/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Assignment.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Assignment record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet
|
||||
private
|
||||
def records
|
||||
ActiveStorage::Blob.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(blob)
|
||||
zip.add_file("storage/#{blob.key}", compress: false) do |out|
|
||||
blob.download { |chunk| out.write(chunk) }
|
||||
end
|
||||
rescue ActiveStorage::FileNotFoundError
|
||||
# Skip blobs where the file is missing from storage
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("storage/*")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
files.each do |file|
|
||||
key = File.basename(file)
|
||||
blob = ActiveStorage::Blob.find_by(key: key, account: account)
|
||||
next unless blob
|
||||
|
||||
zip.read(file) do |stream|
|
||||
blob.upload(stream)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
key = File.basename(file_path)
|
||||
|
||||
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ActiveStorage::Blob.exists?(key: key, account: account)
|
||||
# File exists without corresponding blob record - could be orphaned or blob not yet imported
|
||||
# We allow this since blob metadata is imported before files
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Account::DataTransfer::BoardPublicationRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
board_id
|
||||
created_at
|
||||
id
|
||||
key
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Board::Publication.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(publication)
|
||||
zip.add_file "data/board_publications/#{publication.id}.json", publication.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/board_publications/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Board::Publication.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "BoardPublication record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::BoardRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
all_access
|
||||
created_at
|
||||
creator_id
|
||||
id
|
||||
name
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Board.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(board)
|
||||
zip.add_file "data/boards/#{board.id}.json", board.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/boards/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Board.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Board record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Account::DataTransfer::CardActivitySpikeRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Card::ActivitySpike.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(activity_spike)
|
||||
zip.add_file "data/card_activity_spikes/#{activity_spike.id}.json", activity_spike.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/card_activity_spikes/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Card::ActivitySpike.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "CardActivitySpike record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Account::DataTransfer::CardGoldnessRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Card::Goldness.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(goldness)
|
||||
zip.add_file "data/card_goldnesses/#{goldness.id}.json", goldness.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/card_goldnesses/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Card::Goldness.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "CardGoldness record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Account::DataTransfer::CardNotNowRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
user_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Card::NotNow.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(not_now)
|
||||
zip.add_file "data/card_not_nows/#{not_now.id}.json", not_now.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/card_not_nows/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Card::NotNow.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "CardNotNow record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Account::DataTransfer::CardRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
board_id
|
||||
column_id
|
||||
created_at
|
||||
creator_id
|
||||
due_on
|
||||
id
|
||||
last_active_at
|
||||
number
|
||||
status
|
||||
title
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Card.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(card)
|
||||
zip.add_file "data/cards/#{card.id}.json", card.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/cards/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Card.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Card record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Account::DataTransfer::ClosureRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
user_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Closure.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(closure)
|
||||
zip.add_file "data/closures/#{closure.id}.json", closure.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/closures/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Closure.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Closure record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Account::DataTransfer::ColumnRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
board_id
|
||||
color
|
||||
created_at
|
||||
id
|
||||
name
|
||||
position
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Column.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(column)
|
||||
zip.add_file "data/columns/#{column.id}.json", column.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/columns/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Column.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Column record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Account::DataTransfer::CommentRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
creator_id
|
||||
id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Comment.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(comment)
|
||||
zip.add_file "data/comments/#{comment.id}.json", comment.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/comments/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Comment.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Comment record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Account::DataTransfer::EntropyRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
auto_postpone_period
|
||||
container_id
|
||||
container_type
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Entropy.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(entropy)
|
||||
zip.add_file "data/entropies/#{entropy.id}.json", entropy.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/entropies/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Entropy.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Entropy record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
|
||||
unless %w[Account Board].include?(data["container_type"])
|
||||
raise IntegrityError, "#{file_path} has invalid container_type: #{data['container_type']}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
class Account::DataTransfer::EventRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
action
|
||||
board_id
|
||||
created_at
|
||||
creator_id
|
||||
eventable_id
|
||||
eventable_type
|
||||
id
|
||||
particulars
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Event.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(event)
|
||||
zip.add_file "data/events/#{event.id}.json", event.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/events/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Event.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Event record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::FilterRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
created_at
|
||||
creator_id
|
||||
fields
|
||||
id
|
||||
params_digest
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Filter.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(filter)
|
||||
zip.add_file "data/filters/#{filter.id}.json", filter.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/filters/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Filter.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Filter record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class Account::DataTransfer::Manifest
|
||||
Cursor = Struct.new(:record_class, :last_id)
|
||||
|
||||
include Enumerable
|
||||
|
||||
RECORD_SETS = [
|
||||
Account::DataTransfer::AccountRecordSet,
|
||||
Account::DataTransfer::UserRecordSet,
|
||||
Account::DataTransfer::TagRecordSet,
|
||||
Account::DataTransfer::BoardRecordSet,
|
||||
Account::DataTransfer::ColumnRecordSet,
|
||||
Account::DataTransfer::EntropyRecordSet,
|
||||
Account::DataTransfer::BoardPublicationRecordSet,
|
||||
Account::DataTransfer::WebhookRecordSet,
|
||||
Account::DataTransfer::AccessRecordSet,
|
||||
Account::DataTransfer::CardRecordSet,
|
||||
Account::DataTransfer::CommentRecordSet,
|
||||
Account::DataTransfer::StepRecordSet,
|
||||
Account::DataTransfer::AssignmentRecordSet,
|
||||
Account::DataTransfer::TaggingRecordSet,
|
||||
Account::DataTransfer::ClosureRecordSet,
|
||||
Account::DataTransfer::CardGoldnessRecordSet,
|
||||
Account::DataTransfer::CardNotNowRecordSet,
|
||||
Account::DataTransfer::CardActivitySpikeRecordSet,
|
||||
Account::DataTransfer::WatchRecordSet,
|
||||
Account::DataTransfer::PinRecordSet,
|
||||
Account::DataTransfer::ReactionRecordSet,
|
||||
Account::DataTransfer::MentionRecordSet,
|
||||
Account::DataTransfer::FilterRecordSet,
|
||||
Account::DataTransfer::WebhookDelinquencyTrackerRecordSet,
|
||||
Account::DataTransfer::EventRecordSet,
|
||||
Account::DataTransfer::NotificationRecordSet,
|
||||
Account::DataTransfer::NotificationBundleRecordSet,
|
||||
Account::DataTransfer::WebhookDeliveryRecordSet,
|
||||
Account::DataTransfer::ActiveStorageBlobRecordSet,
|
||||
Account::DataTransfer::ActiveStorageAttachmentRecordSet,
|
||||
Account::DataTransfer::ActionTextRichTextRecordSet,
|
||||
Account::DataTransfer::BlobFileRecordSet
|
||||
]
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def each_record_set(start: nil)
|
||||
raise ArgumentError, "No block given" unless block_given?
|
||||
|
||||
RECORD_SETS.each do |record_set_class|
|
||||
yield record_set_class.new(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class Account::DataTransfer::MentionRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
created_at
|
||||
id
|
||||
mentionee_id
|
||||
mentioner_id
|
||||
source_id
|
||||
source_type
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
VALID_SOURCE_TYPES = %w[Card Comment].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Mention.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(mention)
|
||||
zip.add_file "data/mentions/#{mention.id}.json", mention.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/mentions/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Mention.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Mention record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
|
||||
unless VALID_SOURCE_TYPES.include?(data["source_type"])
|
||||
raise IntegrityError, "#{file_path} has invalid source_type: #{data['source_type']}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Account::DataTransfer::NotificationBundleRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
created_at
|
||||
ends_at
|
||||
id
|
||||
starts_at
|
||||
status
|
||||
updated_at
|
||||
user_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Notification::Bundle.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(bundle)
|
||||
zip.add_file "data/notification_bundles/#{bundle.id}.json", bundle.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/notification_bundles/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Notification::Bundle.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "NotificationBundle record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
class Account::DataTransfer::NotificationRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
created_at
|
||||
creator_id
|
||||
id
|
||||
read_at
|
||||
source_id
|
||||
source_type
|
||||
updated_at
|
||||
user_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Notification.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(notification)
|
||||
zip.add_file "data/notifications/#{notification.id}.json", notification.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/notifications/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Notification.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Notification record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Account::DataTransfer::PinRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
user_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Pin.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(pin)
|
||||
zip.add_file "data/pins/#{pin.id}.json", pin.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/pins/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Pin.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Pin record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::ReactionRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
comment_id
|
||||
content
|
||||
created_at
|
||||
id
|
||||
reacter_id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Reaction.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(reaction)
|
||||
zip.add_file "data/reactions/#{reaction.id}.json", reaction.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/reactions/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Reaction.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Reaction record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
class Account::DataTransfer::RecordSet
|
||||
class IntegrityError < StandardError; end
|
||||
|
||||
IMPORT_BATCH_SIZE = 100
|
||||
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def export(to:, start: nil)
|
||||
with_zip(to) do
|
||||
block = lambda do |record|
|
||||
export_record(record)
|
||||
end
|
||||
|
||||
records.respond_to?(:find_each) ? records.find_each(&block) : records.each(&block)
|
||||
end
|
||||
end
|
||||
|
||||
def import(from:, start: nil, callback: nil)
|
||||
with_zip(from) do
|
||||
files.each_slice(IMPORT_BATCH_SIZE) do |file_batch|
|
||||
import_batch(file_batch)
|
||||
callback&.call(record_set: self, files: file_batch)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate(from:, start: nil, callback: nil)
|
||||
with_zip(from) do
|
||||
files.each do |file_path|
|
||||
validate_record(file_path)
|
||||
callback&.call(record_set: self, file: file_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :zip
|
||||
|
||||
def with_zip(zip)
|
||||
old_zip = @zip
|
||||
@zip = zip
|
||||
yield
|
||||
ensure
|
||||
@zip = old_zip
|
||||
end
|
||||
|
||||
def records
|
||||
[]
|
||||
end
|
||||
|
||||
def export_record(record)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def files
|
||||
[]
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def load(file_path)
|
||||
JSON.parse(zip.read(file_path))
|
||||
rescue ArgumentError => e
|
||||
raise IntegrityError, e.message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::StepRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
completed
|
||||
content
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Step.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(step)
|
||||
zip.add_file "data/steps/#{step.id}.json", step.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/steps/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Step.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Step record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Account::DataTransfer::TagRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
created_at
|
||||
id
|
||||
title
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Tag.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(tag)
|
||||
zip.add_file "data/tags/#{tag.id}.json", tag.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/tags/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Tag.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Tag record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
class Account::DataTransfer::TaggingRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
tag_id
|
||||
updated_at
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Tagging.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(tagging)
|
||||
zip.add_file "data/taggings/#{tagging.id}.json", tagging.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/taggings/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Tagging.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Tagging record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
id
|
||||
email_address
|
||||
name
|
||||
role
|
||||
active
|
||||
verified_at
|
||||
created_at
|
||||
updated_at
|
||||
]
|
||||
|
||||
private
|
||||
def records
|
||||
User.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(user)
|
||||
zip.add_file "data/users/#{user.id}.json", user.as_json.merge(email_address: user.identity&.email_address).to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/users/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
user_data = load(file)
|
||||
email_address = user_data.delete("email_address")
|
||||
|
||||
identity = Identity.find_or_create_by!(email_address: email_address) if email_address.present?
|
||||
|
||||
user_data.slice(*ATTRIBUTES).merge(
|
||||
"account_id" => account.id,
|
||||
"identity_id" => identity&.id
|
||||
)
|
||||
end
|
||||
|
||||
User.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "User record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
unless (ATTRIBUTES - data.keys).empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::WatchRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
card_id
|
||||
created_at
|
||||
id
|
||||
updated_at
|
||||
user_id
|
||||
watching
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Watch.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(watch)
|
||||
zip.add_file "data/watches/#{watch.id}.json", watch.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/watches/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Watch.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Watch record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
class Account::DataTransfer::WebhookDelinquencyTrackerRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
consecutive_failures_count
|
||||
created_at
|
||||
first_failure_at
|
||||
id
|
||||
updated_at
|
||||
webhook_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Webhook::DelinquencyTracker.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(tracker)
|
||||
zip.add_file "data/webhook_delinquency_trackers/#{tracker.id}.json", tracker.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/webhook_delinquency_trackers/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Webhook::DelinquencyTracker.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "WebhookDelinquencyTracker record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
class Account::DataTransfer::WebhookDeliveryRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
created_at
|
||||
event_id
|
||||
id
|
||||
request
|
||||
response
|
||||
state
|
||||
updated_at
|
||||
webhook_id
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Webhook::Delivery.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(delivery)
|
||||
zip.add_file "data/webhook_deliveries/#{delivery.id}.json", delivery.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/webhook_deliveries/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Webhook::Delivery.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "WebhookDelivery record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
class Account::DataTransfer::WebhookRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
active
|
||||
board_id
|
||||
created_at
|
||||
id
|
||||
name
|
||||
signing_secret
|
||||
subscribed_actions
|
||||
updated_at
|
||||
url
|
||||
].freeze
|
||||
|
||||
private
|
||||
def records
|
||||
Webhook.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(webhook)
|
||||
zip.add_file "data/webhooks/#{webhook.id}.json", webhook.as_json.to_json
|
||||
end
|
||||
|
||||
def files
|
||||
zip.glob("data/webhooks/*.json")
|
||||
end
|
||||
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
Webhook.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def validate_record(file_path)
|
||||
data = load(file_path)
|
||||
expected_id = File.basename(file_path, ".json")
|
||||
|
||||
unless data["id"].to_s == expected_id
|
||||
raise IntegrityError, "Webhook record ID mismatch: expected #{expected_id}, got #{data['id']}"
|
||||
end
|
||||
|
||||
missing = ATTRIBUTES - data.keys
|
||||
unless missing.empty?
|
||||
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
class Account::DataTransfer::ZipFile
|
||||
class << self
|
||||
def create
|
||||
raise ArgumentError, "No block given" unless block_given?
|
||||
|
||||
Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
yield new(zip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def open(path)
|
||||
raise ArgumentError, "No block given" unless block_given?
|
||||
|
||||
Zip::File.open(path.to_s) do |zip|
|
||||
yield new(zip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(zip)
|
||||
@zip = zip
|
||||
end
|
||||
|
||||
def add_file(path, content = nil, compress: true, &block)
|
||||
if block_given?
|
||||
compression = compress ? nil : Zip::Entry::STORED
|
||||
zip.get_output_stream(path, nil, nil, compression, &block)
|
||||
else
|
||||
zip.get_output_stream(path) { |f| f.write(content) }
|
||||
end
|
||||
end
|
||||
|
||||
def glob(pattern)
|
||||
zip.glob(pattern).map(&:name).sort
|
||||
end
|
||||
|
||||
def read(file_path, &block)
|
||||
entry = zip.find_entry(file_path)
|
||||
raise ArgumentError, "File not found in zip: #{file_path}" unless entry
|
||||
raise ArgumentError, "Cannot read directory entry: #{file_path}" if entry.directory?
|
||||
|
||||
if block_given?
|
||||
yield entry.get_input_stream
|
||||
else
|
||||
entry.get_input_stream.read
|
||||
end
|
||||
end
|
||||
|
||||
def exists?(file_path)
|
||||
zip.find_entry(file_path).present?
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :zip
|
||||
end
|
||||
@@ -1,254 +1,8 @@
|
||||
class Account::Export < Export
|
||||
private
|
||||
def populate_zip(zip)
|
||||
export_account(zip)
|
||||
export_users(zip)
|
||||
export_tags(zip)
|
||||
export_entropies(zip)
|
||||
export_columns(zip)
|
||||
export_board_publications(zip)
|
||||
export_webhooks(zip)
|
||||
export_webhook_delinquency_trackers(zip)
|
||||
export_accesses(zip)
|
||||
export_assignments(zip)
|
||||
export_taggings(zip)
|
||||
export_steps(zip)
|
||||
export_closures(zip)
|
||||
export_card_goldnesses(zip)
|
||||
export_card_not_nows(zip)
|
||||
export_card_activity_spikes(zip)
|
||||
export_watches(zip)
|
||||
export_pins(zip)
|
||||
export_reactions(zip)
|
||||
export_mentions(zip)
|
||||
export_filters(zip)
|
||||
export_events(zip)
|
||||
export_notifications(zip)
|
||||
export_notification_bundles(zip)
|
||||
export_webhook_deliveries(zip)
|
||||
|
||||
export_boards(zip)
|
||||
export_cards(zip)
|
||||
export_comments(zip)
|
||||
|
||||
export_action_text_rich_texts(zip)
|
||||
export_active_storage_attachments(zip)
|
||||
export_active_storage_blobs(zip)
|
||||
|
||||
export_blob_files(zip)
|
||||
end
|
||||
|
||||
def export_account(zip)
|
||||
data = account.as_json.merge(join_code: account.join_code.as_json)
|
||||
add_file_to_zip(zip, "data/account.json", JSON.pretty_generate(data))
|
||||
end
|
||||
|
||||
def export_users(zip)
|
||||
account.users.find_each do |user|
|
||||
data = user.as_json.except("identity_id").merge(
|
||||
"email_address" => user.identity&.email_address
|
||||
)
|
||||
add_file_to_zip(zip, "data/users/#{user.id}.json", JSON.pretty_generate(data))
|
||||
end
|
||||
end
|
||||
|
||||
def export_tags(zip)
|
||||
account.tags.find_each do |tag|
|
||||
add_file_to_zip(zip, "data/tags/#{tag.id}.json", JSON.pretty_generate(tag.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_entropies(zip)
|
||||
Entropy.where(account: account).find_each do |entropy|
|
||||
add_file_to_zip(zip, "data/entropies/#{entropy.id}.json", JSON.pretty_generate(entropy.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_columns(zip)
|
||||
account.columns.find_each do |column|
|
||||
add_file_to_zip(zip, "data/columns/#{column.id}.json", JSON.pretty_generate(column.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_board_publications(zip)
|
||||
Board::Publication.where(account: account).find_each do |publication|
|
||||
add_file_to_zip(zip, "data/board_publications/#{publication.id}.json", JSON.pretty_generate(publication.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_webhooks(zip)
|
||||
Webhook.where(account: account).find_each do |webhook|
|
||||
add_file_to_zip(zip, "data/webhooks/#{webhook.id}.json", JSON.pretty_generate(webhook.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_webhook_delinquency_trackers(zip)
|
||||
Webhook::DelinquencyTracker.joins(:webhook).where(webhook: { account: account }).find_each do |tracker|
|
||||
add_file_to_zip(zip, "data/webhook_delinquency_trackers/#{tracker.id}.json", JSON.pretty_generate(tracker.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_accesses(zip)
|
||||
Access.where(account: account).find_each do |access|
|
||||
add_file_to_zip(zip, "data/accesses/#{access.id}.json", JSON.pretty_generate(access.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_assignments(zip)
|
||||
Assignment.where(account: account).find_each do |assignment|
|
||||
add_file_to_zip(zip, "data/assignments/#{assignment.id}.json", JSON.pretty_generate(assignment.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_taggings(zip)
|
||||
Tagging.where(account: account).find_each do |tagging|
|
||||
add_file_to_zip(zip, "data/taggings/#{tagging.id}.json", JSON.pretty_generate(tagging.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_steps(zip)
|
||||
Step.where(account: account).find_each do |step|
|
||||
add_file_to_zip(zip, "data/steps/#{step.id}.json", JSON.pretty_generate(step.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_closures(zip)
|
||||
Closure.where(account: account).find_each do |closure|
|
||||
add_file_to_zip(zip, "data/closures/#{closure.id}.json", JSON.pretty_generate(closure.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_card_goldnesses(zip)
|
||||
Card::Goldness.where(account: account).find_each do |goldness|
|
||||
add_file_to_zip(zip, "data/card_goldnesses/#{goldness.id}.json", JSON.pretty_generate(goldness.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_card_not_nows(zip)
|
||||
Card::NotNow.where(account: account).find_each do |not_now|
|
||||
add_file_to_zip(zip, "data/card_not_nows/#{not_now.id}.json", JSON.pretty_generate(not_now.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_card_activity_spikes(zip)
|
||||
Card::ActivitySpike.where(account: account).find_each do |activity_spike|
|
||||
add_file_to_zip(zip, "data/card_activity_spikes/#{activity_spike.id}.json", JSON.pretty_generate(activity_spike.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_watches(zip)
|
||||
Watch.where(account: account).find_each do |watch|
|
||||
add_file_to_zip(zip, "data/watches/#{watch.id}.json", JSON.pretty_generate(watch.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_pins(zip)
|
||||
Pin.where(account: account).find_each do |pin|
|
||||
add_file_to_zip(zip, "data/pins/#{pin.id}.json", JSON.pretty_generate(pin.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_reactions(zip)
|
||||
Reaction.where(account: account).find_each do |reaction|
|
||||
add_file_to_zip(zip, "data/reactions/#{reaction.id}.json", JSON.pretty_generate(reaction.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_mentions(zip)
|
||||
Mention.where(account: account).find_each do |mention|
|
||||
add_file_to_zip(zip, "data/mentions/#{mention.id}.json", JSON.pretty_generate(mention.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_filters(zip)
|
||||
Filter.where(account: account).find_each do |filter|
|
||||
add_file_to_zip(zip, "data/filters/#{filter.id}.json", JSON.pretty_generate(filter.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_events(zip)
|
||||
Event.where(account: account).find_each do |event|
|
||||
add_file_to_zip(zip, "data/events/#{event.id}.json", JSON.pretty_generate(event.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_notifications(zip)
|
||||
Notification.where(account: account).find_each do |notification|
|
||||
add_file_to_zip(zip, "data/notifications/#{notification.id}.json", JSON.pretty_generate(notification.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_notification_bundles(zip)
|
||||
Notification::Bundle.where(account: account).find_each do |bundle|
|
||||
add_file_to_zip(zip, "data/notification_bundles/#{bundle.id}.json", JSON.pretty_generate(bundle.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_webhook_deliveries(zip)
|
||||
Webhook::Delivery.where(account: account).find_each do |delivery|
|
||||
add_file_to_zip(zip, "data/webhook_deliveries/#{delivery.id}.json", JSON.pretty_generate(delivery.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_boards(zip)
|
||||
account.boards.find_each do |board|
|
||||
add_file_to_zip(zip, "data/boards/#{board.id}.json", JSON.pretty_generate(board.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_cards(zip)
|
||||
account.cards.find_each do |card|
|
||||
add_file_to_zip(zip, "data/cards/#{card.id}.json", JSON.pretty_generate(card.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_comments(zip)
|
||||
Comment.where(account: account).find_each do |comment|
|
||||
add_file_to_zip(zip, "data/comments/#{comment.id}.json", JSON.pretty_generate(comment.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_action_text_rich_texts(zip)
|
||||
ActionText::RichText.where(account: account).find_each do |rich_text|
|
||||
data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body))
|
||||
add_file_to_zip(zip, "data/action_text_rich_texts/#{rich_text.id}.json", JSON.pretty_generate(data))
|
||||
end
|
||||
end
|
||||
|
||||
def convert_sgids_to_gids(content)
|
||||
return nil if content.blank?
|
||||
|
||||
content.send(:attachment_nodes).each do |node|
|
||||
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
|
||||
record = sgid&.find
|
||||
next if record&.account_id != account.id
|
||||
|
||||
node["gid"] = record.to_global_id.to_s
|
||||
node.remove_attribute("sgid")
|
||||
end
|
||||
|
||||
content.fragment.source.to_html
|
||||
end
|
||||
|
||||
def export_active_storage_attachments(zip)
|
||||
ActiveStorage::Attachment.where(account: account).find_each do |attachment|
|
||||
add_file_to_zip(zip, "data/active_storage_attachments/#{attachment.id}.json", JSON.pretty_generate(attachment.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_active_storage_blobs(zip)
|
||||
ActiveStorage::Blob.where(account: account).find_each do |blob|
|
||||
add_file_to_zip(zip, "data/active_storage_blobs/#{blob.id}.json", JSON.pretty_generate(blob.as_json))
|
||||
end
|
||||
end
|
||||
|
||||
def export_blob_files(zip)
|
||||
ActiveStorage::Blob.where(account: account).find_each do |blob|
|
||||
add_file_to_zip(zip, "storage/#{blob.key}", compression_method: Zip::Entry::STORED) do |f|
|
||||
blob.download { |chunk| f.write(chunk) }
|
||||
end
|
||||
rescue ActiveStorage::FileNotFoundError
|
||||
# Skip blobs where the file is missing from storage
|
||||
Account::DataTransfer::Manifest.new(account).each_record_set do |record_set|
|
||||
record_set.export(to: Account::DataTransfer::ZipFile.new(zip))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+31
-720
@@ -1,6 +1,4 @@
|
||||
class Account::Import < ApplicationRecord
|
||||
class IntegrityError < StandardError; end
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :identity
|
||||
|
||||
@@ -8,738 +6,51 @@ class Account::Import < ApplicationRecord
|
||||
|
||||
enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
|
||||
|
||||
def perform_later
|
||||
ImportAccountDataJob.perform_later(self)
|
||||
def process_later
|
||||
end
|
||||
|
||||
def perform
|
||||
def process(start: nil, callback: nil)
|
||||
processing!
|
||||
ensure_downloaded
|
||||
|
||||
Current.set(account: account, user: owner_user) do
|
||||
file.open do |tempfile|
|
||||
Zip::File.open(tempfile.path) do |zip|
|
||||
ApplicationRecord.transaction do
|
||||
@old_account_data = load_account_data(zip)
|
||||
@id_mapper = IdMapper.new(account, @old_account_data)
|
||||
|
||||
validate_export_integrity!(zip)
|
||||
import_all(zip)
|
||||
end
|
||||
end
|
||||
Account::DataTransfer::ZipFile.open(download_path) do |zip|
|
||||
Account::DataTransfer::Manifest.new(account).each_record_set(start: start&.record_set) do |record_set|
|
||||
record_set.import(from: zip, start: start&.record_id, callback: callback)
|
||||
end
|
||||
end
|
||||
|
||||
mark_completed
|
||||
rescue => e
|
||||
mark_failed
|
||||
raise
|
||||
failed!
|
||||
raise e
|
||||
end
|
||||
|
||||
def owner_user
|
||||
account.users.find_by(identity: identity)
|
||||
def validate(start: nil, callback: nil)
|
||||
processing!
|
||||
ensure_downloaded
|
||||
|
||||
Account::DataTransfer::ZipFile.open(download_path) do |zip|
|
||||
Account::DataTransfer::Manifest.new(account).each_record_set(start: start&.record_set) do |record_set|
|
||||
record_set.validate(from: zip, start: start&.record_id, callback: callback)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_downloaded
|
||||
unless download_path.exist?
|
||||
download_path.open("wb") do |f|
|
||||
file.download { |chunk| f.write(chunk) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def download_path
|
||||
Pathname.new("/tmp/account-import-#{id}.zip")
|
||||
end
|
||||
|
||||
def mark_completed
|
||||
update!(status: :completed, completed_at: Time.current)
|
||||
ImportMailer.completed(identity).deliver_later
|
||||
end
|
||||
|
||||
def mark_failed
|
||||
update!(status: :failed)
|
||||
ImportMailer.failed(identity).deliver_later
|
||||
end
|
||||
|
||||
def load_account_data(zip)
|
||||
entry = zip.find_entry("data/account.json")
|
||||
raise IntegrityError, "Missing account.json in export" unless entry
|
||||
|
||||
JSON.parse(entry.get_input_stream.read)
|
||||
end
|
||||
|
||||
def import_all(zip)
|
||||
# Phase 1: Foundation
|
||||
import_account_data
|
||||
import_join_code
|
||||
|
||||
# Phase 2: Users (create identities, map system user)
|
||||
import_users(zip)
|
||||
|
||||
# Phase 3: Basic entities
|
||||
import_tags(zip)
|
||||
import_boards(zip)
|
||||
import_columns(zip)
|
||||
import_entropies(zip)
|
||||
import_board_publications(zip)
|
||||
|
||||
# Phase 4: Cards & content
|
||||
import_cards(zip)
|
||||
import_comments(zip)
|
||||
import_steps(zip)
|
||||
|
||||
# Phase 5: Relationships
|
||||
import_accesses(zip)
|
||||
import_assignments(zip)
|
||||
import_taggings(zip)
|
||||
import_closures(zip)
|
||||
import_card_goldnesses(zip)
|
||||
import_card_not_nows(zip)
|
||||
import_card_activity_spikes(zip)
|
||||
import_watches(zip)
|
||||
import_pins(zip)
|
||||
import_reactions(zip)
|
||||
import_mentions(zip)
|
||||
import_filters(zip)
|
||||
|
||||
# Phase 6: Webhooks
|
||||
import_webhooks(zip)
|
||||
import_webhook_delinquency_trackers(zip)
|
||||
import_webhook_deliveries(zip)
|
||||
|
||||
# Phase 7: Activity & notifications
|
||||
import_events(zip)
|
||||
import_notifications(zip)
|
||||
import_notification_bundles(zip)
|
||||
|
||||
# Phase 8: Storage & rich text
|
||||
import_active_storage_blobs(zip)
|
||||
import_active_storage_attachments(zip)
|
||||
import_action_text_rich_texts(zip)
|
||||
import_blob_files(zip)
|
||||
end
|
||||
|
||||
# Phase 1: Foundation
|
||||
|
||||
def import_account_data
|
||||
account.update!(name: @old_account_data["name"])
|
||||
end
|
||||
|
||||
def import_join_code
|
||||
join_code_data = @old_account_data["join_code"]
|
||||
return unless join_code_data
|
||||
|
||||
# Preserve the code if it's unique, otherwise keep the auto-generated one
|
||||
unless Account::JoinCode.exists?(code: join_code_data["code"])
|
||||
account.join_code.update!(
|
||||
code: join_code_data["code"],
|
||||
usage_count: join_code_data["usage_count"],
|
||||
usage_limit: join_code_data["usage_limit"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 2: Users
|
||||
|
||||
def import_users(zip)
|
||||
users_data = read_json_files(zip, "data/users")
|
||||
|
||||
# Map system user first
|
||||
old_system = users_data.find { |u| u["role"] == "system" }
|
||||
if old_system
|
||||
@id_mapper.map(:users, old_system["id"], account.system_user.id)
|
||||
end
|
||||
|
||||
# Import non-system users
|
||||
users_data.reject { |u| u["role"] == "system" }.each do |data|
|
||||
import_user(data)
|
||||
end
|
||||
end
|
||||
|
||||
def import_user(data)
|
||||
email = data.delete("email_address")
|
||||
old_id = data.delete("id")
|
||||
|
||||
user_identity = if email.present?
|
||||
Identity.find_or_create_by!(email_address: email)
|
||||
end
|
||||
|
||||
# Check if user already exists for this identity in this account (e.g., the owner)
|
||||
existing_user = account.users.find_by(identity: user_identity) if user_identity
|
||||
if existing_user
|
||||
existing_user.update!(data.slice("name", "role", "active", "verified_at"))
|
||||
@id_mapper.map(:users, old_id, existing_user.id)
|
||||
else
|
||||
new_user = User.create!(
|
||||
data.slice(*User.column_names).merge(
|
||||
"account_id" => account.id,
|
||||
"identity_id" => user_identity&.id
|
||||
)
|
||||
)
|
||||
@id_mapper.map(:users, old_id, new_user.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 3: Basic entities
|
||||
|
||||
def import_tags(zip)
|
||||
records = read_json_files(zip, "data/tags").map do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data)
|
||||
new_record = Tag.create!(data)
|
||||
@id_mapper.map(:tags, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_boards(zip)
|
||||
read_json_files(zip, "data/boards").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data)
|
||||
new_record = Board.create!(data)
|
||||
@id_mapper.map(:boards, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_columns(zip)
|
||||
read_json_files(zip, "data/columns").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "board_id" => :boards })
|
||||
new_record = Column.create!(data)
|
||||
@id_mapper.map(:columns, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_entropies(zip)
|
||||
read_json_files(zip, "data/entropies").each do |data|
|
||||
old_id = data.delete("id")
|
||||
|
||||
# Remap polymorphic container_id based on container_type
|
||||
container_type = data["container_type"]
|
||||
if container_type == "Account"
|
||||
data["container_id"] = account.id
|
||||
elsif container_type == "Board"
|
||||
data["container_id"] = @id_mapper.lookup(:boards, data["container_id"])
|
||||
end
|
||||
|
||||
data = @id_mapper.remap(data)
|
||||
|
||||
# Find existing or create new
|
||||
existing = Entropy.find_by(container_type: data["container_type"], container_id: data["container_id"])
|
||||
if existing
|
||||
existing.update!(data.slice("auto_postpone_period"))
|
||||
@id_mapper.map(:entropies, old_id, existing.id)
|
||||
else
|
||||
new_record = Entropy.create!(data)
|
||||
@id_mapper.map(:entropies, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def import_board_publications(zip)
|
||||
read_json_files(zip, "data/board_publications").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "board_id" => :boards })
|
||||
new_record = Board::Publication.create!(data)
|
||||
@id_mapper.map(:board_publications, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 4: Cards & content
|
||||
|
||||
def import_cards(zip)
|
||||
read_json_files(zip, "data/cards").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"board_id" => :boards,
|
||||
"column_id" => :columns
|
||||
})
|
||||
new_record = Card.create!(data)
|
||||
@id_mapper.map(:cards, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_comments(zip)
|
||||
read_json_files(zip, "data/comments").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"card_id" => :cards
|
||||
})
|
||||
new_record = Comment.create!(data)
|
||||
@id_mapper.map(:comments, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_steps(zip)
|
||||
read_json_files(zip, "data/steps").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "card_id" => :cards })
|
||||
new_record = Step.create!(data)
|
||||
@id_mapper.map(:steps, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 5: Relationships
|
||||
|
||||
def import_accesses(zip)
|
||||
read_json_files(zip, "data/accesses").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"board_id" => :boards
|
||||
})
|
||||
|
||||
# Board creation auto-creates access for creator, check if it exists
|
||||
existing = Access.find_by(board_id: data["board_id"], user_id: data["user_id"])
|
||||
if existing
|
||||
@id_mapper.map(:accesses, old_id, existing.id)
|
||||
else
|
||||
new_record = Access.create!(data)
|
||||
@id_mapper.map(:accesses, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def import_assignments(zip)
|
||||
read_json_files(zip, "data/assignments").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"card_id" => :cards
|
||||
})
|
||||
new_record = Assignment.create!(data)
|
||||
@id_mapper.map(:assignments, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_taggings(zip)
|
||||
read_json_files(zip, "data/taggings").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: {
|
||||
"card_id" => :cards,
|
||||
"tag_id" => :tags
|
||||
})
|
||||
new_record = Tagging.create!(data)
|
||||
@id_mapper.map(:taggings, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_closures(zip)
|
||||
read_json_files(zip, "data/closures").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"card_id" => :cards
|
||||
})
|
||||
new_record = Closure.create!(data)
|
||||
@id_mapper.map(:closures, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_card_goldnesses(zip)
|
||||
read_json_files(zip, "data/card_goldnesses").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "card_id" => :cards })
|
||||
new_record = Card::Goldness.create!(data)
|
||||
@id_mapper.map(:card_goldnesses, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_card_not_nows(zip)
|
||||
read_json_files(zip, "data/card_not_nows").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"card_id" => :cards
|
||||
})
|
||||
new_record = Card::NotNow.create!(data)
|
||||
@id_mapper.map(:card_not_nows, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_card_activity_spikes(zip)
|
||||
read_json_files(zip, "data/card_activity_spikes").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "card_id" => :cards })
|
||||
new_record = Card::ActivitySpike.create!(data)
|
||||
@id_mapper.map(:card_activity_spikes, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_watches(zip)
|
||||
read_json_files(zip, "data/watches").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"card_id" => :cards
|
||||
})
|
||||
|
||||
# Card creation auto-creates watch for creator, check if it exists
|
||||
existing = Watch.find_by(card_id: data["card_id"], user_id: data["user_id"])
|
||||
if existing
|
||||
@id_mapper.map(:watches, old_id, existing.id)
|
||||
else
|
||||
new_record = Watch.create!(data)
|
||||
@id_mapper.map(:watches, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def import_pins(zip)
|
||||
read_json_files(zip, "data/pins").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"card_id" => :cards
|
||||
})
|
||||
new_record = Pin.create!(data)
|
||||
@id_mapper.map(:pins, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_reactions(zip)
|
||||
read_json_files(zip, "data/reactions").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"comment_id" => :comments
|
||||
})
|
||||
new_record = Reaction.create!(data)
|
||||
@id_mapper.map(:reactions, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_mentions(zip)
|
||||
read_json_files(zip, "data/mentions").each do |data|
|
||||
old_id = data.delete("id")
|
||||
|
||||
# Remap polymorphic source_id based on source_type
|
||||
source_type = data["source_type"]
|
||||
source_mapping = case source_type
|
||||
when "Card" then :cards
|
||||
when "Comment" then :comments
|
||||
end
|
||||
data["source_id"] = @id_mapper.lookup(source_mapping, data["source_id"]) if source_mapping
|
||||
|
||||
data = @id_mapper.remap_with_users(data)
|
||||
new_record = Mention.create!(data)
|
||||
@id_mapper.map(:mentions, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_filters(zip)
|
||||
read_json_files(zip, "data/filters").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data)
|
||||
new_record = Filter.create!(data)
|
||||
@id_mapper.map(:filters, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 6: Webhooks
|
||||
|
||||
def import_webhooks(zip)
|
||||
read_json_files(zip, "data/webhooks").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "board_id" => :boards })
|
||||
new_record = Webhook.create!(data)
|
||||
@id_mapper.map(:webhooks, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_webhook_delinquency_trackers(zip)
|
||||
read_json_files(zip, "data/webhook_delinquency_trackers").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: { "webhook_id" => :webhooks })
|
||||
new_record = Webhook::DelinquencyTracker.create!(data)
|
||||
@id_mapper.map(:webhook_delinquency_trackers, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_webhook_deliveries(zip)
|
||||
read_json_files(zip, "data/webhook_deliveries").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data, foreign_keys: {
|
||||
"webhook_id" => :webhooks,
|
||||
"event_id" => :events
|
||||
})
|
||||
new_record = Webhook::Delivery.create!(data)
|
||||
@id_mapper.map(:webhook_deliveries, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 7: Activity & notifications
|
||||
|
||||
def import_events(zip)
|
||||
read_json_files(zip, "data/events").each do |data|
|
||||
old_id = data.delete("id")
|
||||
|
||||
# Remap polymorphic eventable_id
|
||||
eventable_type = data["eventable_type"]
|
||||
eventable_mapping = polymorphic_type_to_mapping(eventable_type)
|
||||
data["eventable_id"] = @id_mapper.lookup(eventable_mapping, data["eventable_id"]) if eventable_mapping
|
||||
|
||||
data = @id_mapper.remap_with_users(data, additional_foreign_keys: {
|
||||
"board_id" => :boards
|
||||
})
|
||||
new_record = Event.create!(data)
|
||||
@id_mapper.map(:events, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_notifications(zip)
|
||||
read_json_files(zip, "data/notifications").each do |data|
|
||||
old_id = data.delete("id")
|
||||
|
||||
# Remap polymorphic source_id
|
||||
source_type = data["source_type"]
|
||||
source_mapping = polymorphic_type_to_mapping(source_type)
|
||||
data["source_id"] = @id_mapper.lookup(source_mapping, data["source_id"]) if source_mapping
|
||||
|
||||
data = @id_mapper.remap_with_users(data)
|
||||
new_record = Notification.create!(data)
|
||||
@id_mapper.map(:notifications, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_notification_bundles(zip)
|
||||
read_json_files(zip, "data/notification_bundles").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap_with_users(data)
|
||||
new_record = Notification::Bundle.create!(data)
|
||||
@id_mapper.map(:notification_bundles, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Phase 8: Storage & rich text
|
||||
|
||||
def import_active_storage_blobs(zip)
|
||||
read_json_files(zip, "data/active_storage_blobs").each do |data|
|
||||
old_id = data.delete("id")
|
||||
data = @id_mapper.remap(data)
|
||||
new_record = ActiveStorage::Blob.create!(data)
|
||||
@id_mapper.map(:active_storage_blobs, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_active_storage_attachments(zip)
|
||||
read_json_files(zip, "data/active_storage_attachments").each do |data|
|
||||
old_id = data.delete("id")
|
||||
|
||||
# Remap polymorphic record_id
|
||||
record_type = data["record_type"]
|
||||
record_mapping = polymorphic_type_to_mapping(record_type)
|
||||
data["record_id"] = @id_mapper.lookup(record_mapping, data["record_id"]) if record_mapping
|
||||
|
||||
data = @id_mapper.remap(data, foreign_keys: { "blob_id" => :active_storage_blobs })
|
||||
new_record = ActiveStorage::Attachment.create!(data)
|
||||
@id_mapper.map(:active_storage_attachments, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_action_text_rich_texts(zip)
|
||||
read_json_files(zip, "data/action_text_rich_texts").each do |data|
|
||||
old_id = data.delete("id")
|
||||
|
||||
# Remap polymorphic record_id
|
||||
record_type = data["record_type"]
|
||||
record_mapping = polymorphic_type_to_mapping(record_type)
|
||||
data["record_id"] = @id_mapper.lookup(record_mapping, data["record_id"]) if record_mapping
|
||||
|
||||
data["body"] = convert_gids_and_fix_links(data["body"])
|
||||
data = @id_mapper.remap(data)
|
||||
new_record = ActionText::RichText.create!(data)
|
||||
@id_mapper.map(:action_text_rich_texts, old_id, new_record.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_blob_files(zip)
|
||||
zip.glob("storage/*").each do |entry|
|
||||
key = File.basename(entry.name)
|
||||
blob = ActiveStorage::Blob.find_by(key: key, account: account)
|
||||
next unless blob
|
||||
|
||||
blob.upload(entry.get_input_stream)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
|
||||
def read_json_files(zip, directory)
|
||||
zip.glob("#{directory}/*.json").map do |entry|
|
||||
JSON.parse(entry.get_input_stream.read)
|
||||
end
|
||||
end
|
||||
|
||||
def convert_gids_and_fix_links(html)
|
||||
return html if html.blank?
|
||||
|
||||
fragment = Nokogiri::HTML.fragment(html)
|
||||
|
||||
# Convert GIDs to SGIDs
|
||||
fragment.css("action-text-attachment[gid]").each do |node|
|
||||
gid = GlobalID.parse(node["gid"])
|
||||
next unless gid
|
||||
|
||||
type = gid.model_name.plural.underscore.to_sym
|
||||
new_id = @id_mapper.lookup(type, gid.model_id)
|
||||
record = gid.model_class.find(new_id)
|
||||
|
||||
node["sgid"] = record.attachable_sgid
|
||||
node.remove_attribute("gid")
|
||||
end
|
||||
|
||||
# Fix links
|
||||
fragment.css("a[href]").each do |link|
|
||||
link["href"] = rewrite_link(link["href"])
|
||||
end
|
||||
|
||||
fragment.to_html
|
||||
end
|
||||
|
||||
def rewrite_link(url)
|
||||
uri = URI.parse(url) rescue nil
|
||||
return url unless uri&.path
|
||||
|
||||
path = uri.path
|
||||
old_slug_pattern = %r{^/#{Regexp.escape(@id_mapper.old_account_slug)}/}
|
||||
|
||||
return url unless path.match?(old_slug_pattern)
|
||||
|
||||
# Replace account slug
|
||||
path = path.sub(old_slug_pattern, "#{account.slug}/")
|
||||
|
||||
# Try to recognize and remap IDs in the path
|
||||
begin
|
||||
params = Rails.application.routes.recognize_path(path)
|
||||
|
||||
case params[:controller]
|
||||
when "cards"
|
||||
if params[:id] && @id_mapper[:cards].key?(params[:id])
|
||||
new_id = @id_mapper[:cards][params[:id]]
|
||||
path = Rails.application.routes.url_helpers.card_path(new_id)
|
||||
end
|
||||
when "boards"
|
||||
if params[:id] && @id_mapper[:boards].key?(params[:id])
|
||||
new_id = @id_mapper[:boards][params[:id]]
|
||||
path = Rails.application.routes.url_helpers.board_path(new_id)
|
||||
end
|
||||
end
|
||||
rescue ActionController::RoutingError
|
||||
# Unknown route, just update the slug
|
||||
end
|
||||
|
||||
uri.path = path
|
||||
uri.to_s
|
||||
end
|
||||
|
||||
def polymorphic_type_to_mapping(type)
|
||||
case type
|
||||
when "Card" then :cards
|
||||
when "Comment" then :comments
|
||||
when "Board" then :boards
|
||||
when "User" then :users
|
||||
when "Tag" then :tags
|
||||
when "Assignment" then :assignments
|
||||
when "Tagging" then :taggings
|
||||
when "Closure" then :closures
|
||||
when "Step" then :steps
|
||||
when "Watch" then :watches
|
||||
when "Pin" then :pins
|
||||
when "Reaction" then :reactions
|
||||
when "Mention" then :mentions
|
||||
when "Event" then :events
|
||||
when "Access" then :accesses
|
||||
when "Webhook" then :webhooks
|
||||
when "Webhook::Delivery" then :webhook_deliveries
|
||||
when "Card::Goldness" then :card_goldnesses
|
||||
when "Card::NotNow" then :card_not_nows
|
||||
when "Card::ActivitySpike" then :card_activity_spikes
|
||||
when "ActiveStorage::Blob" then :active_storage_blobs
|
||||
when "ActiveStorage::Attachment" then :active_storage_attachments
|
||||
when "ActionText::RichText" then :action_text_rich_texts
|
||||
end
|
||||
end
|
||||
|
||||
# Data integrity validation
|
||||
|
||||
def validate_export_integrity!(zip)
|
||||
exported_ids = collect_exported_ids(zip)
|
||||
|
||||
zip.glob("data/**/*.json").each do |entry|
|
||||
next if entry.name == "data/account.json"
|
||||
|
||||
data = JSON.parse(entry.get_input_stream.read)
|
||||
validate_account_id(data, entry.name, exported_ids[:account])
|
||||
validate_foreign_keys(data, exported_ids, entry.name)
|
||||
end
|
||||
end
|
||||
|
||||
def collect_exported_ids(zip)
|
||||
ids = Hash.new { |h, k| h[k] = Set.new }
|
||||
|
||||
# Account ID
|
||||
ids[:account] = @old_account_data["id"]
|
||||
|
||||
# Collect IDs from each entity type
|
||||
entity_directories = {
|
||||
"data/users" => :users,
|
||||
"data/tags" => :tags,
|
||||
"data/boards" => :boards,
|
||||
"data/columns" => :columns,
|
||||
"data/cards" => :cards,
|
||||
"data/comments" => :comments,
|
||||
"data/steps" => :steps,
|
||||
"data/accesses" => :accesses,
|
||||
"data/assignments" => :assignments,
|
||||
"data/taggings" => :taggings,
|
||||
"data/closures" => :closures,
|
||||
"data/card_goldnesses" => :card_goldnesses,
|
||||
"data/card_not_nows" => :card_not_nows,
|
||||
"data/card_activity_spikes" => :card_activity_spikes,
|
||||
"data/watches" => :watches,
|
||||
"data/pins" => :pins,
|
||||
"data/reactions" => :reactions,
|
||||
"data/mentions" => :mentions,
|
||||
"data/filters" => :filters,
|
||||
"data/events" => :events,
|
||||
"data/notifications" => :notifications,
|
||||
"data/notification_bundles" => :notification_bundles,
|
||||
"data/webhooks" => :webhooks,
|
||||
"data/webhook_delinquency_trackers" => :webhook_delinquency_trackers,
|
||||
"data/webhook_deliveries" => :webhook_deliveries,
|
||||
"data/entropies" => :entropies,
|
||||
"data/board_publications" => :board_publications,
|
||||
"data/active_storage_blobs" => :active_storage_blobs,
|
||||
"data/active_storage_attachments" => :active_storage_attachments,
|
||||
"data/action_text_rich_texts" => :action_text_rich_texts
|
||||
}
|
||||
|
||||
entity_directories.each do |directory, type|
|
||||
zip.glob("#{directory}/*.json").each do |entry|
|
||||
data = JSON.parse(entry.get_input_stream.read)
|
||||
ids[type].add(data["id"])
|
||||
end
|
||||
end
|
||||
|
||||
ids
|
||||
end
|
||||
|
||||
def validate_account_id(data, filename, expected_account_id)
|
||||
if data["account_id"] && data["account_id"] != expected_account_id
|
||||
raise IntegrityError, "#{filename} references foreign account: #{data["account_id"]}"
|
||||
end
|
||||
end
|
||||
|
||||
FOREIGN_KEY_VALIDATIONS = {
|
||||
"board_id" => :boards,
|
||||
"card_id" => :cards,
|
||||
"column_id" => :columns,
|
||||
"user_id" => :users,
|
||||
"creator_id" => :users,
|
||||
"assignee_id" => :users,
|
||||
"assigner_id" => :users,
|
||||
"closer_id" => :users,
|
||||
"mentioner_id" => :users,
|
||||
"mentionee_id" => :users,
|
||||
"reacter_id" => :users,
|
||||
"tag_id" => :tags,
|
||||
"comment_id" => :comments,
|
||||
"webhook_id" => :webhooks,
|
||||
"event_id" => :events,
|
||||
"blob_id" => :active_storage_blobs,
|
||||
"filter_id" => :filters
|
||||
}.freeze
|
||||
|
||||
def validate_foreign_keys(data, exported_ids, filename)
|
||||
FOREIGN_KEY_VALIDATIONS.each do |field, type|
|
||||
ref_id = data[field]
|
||||
next unless ref_id
|
||||
|
||||
unless exported_ids[type]&.include?(ref_id)
|
||||
raise IntegrityError, "#{filename} references unknown #{type}: #{ref_id}"
|
||||
end
|
||||
end
|
||||
completed!
|
||||
# TODO: send email
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
class Account::Import::IdMapper
|
||||
attr_reader :account, :old_account_id, :old_external_account_id, :old_account_slug
|
||||
|
||||
def initialize(account, old_account_data)
|
||||
@account = account
|
||||
@old_account_id = old_account_data["id"]
|
||||
@old_external_account_id = old_account_data["external_account_id"]
|
||||
@old_account_slug = AccountSlug.encode(@old_external_account_id)
|
||||
@mappings = Hash.new { |h, k| h[k] = {} }
|
||||
end
|
||||
|
||||
def map(type, old_id, new_id)
|
||||
@mappings[type][old_id] = new_id
|
||||
end
|
||||
|
||||
def [](type)
|
||||
@mappings[type]
|
||||
end
|
||||
|
||||
def mapped?(type, old_id)
|
||||
@mappings[type].key?(old_id)
|
||||
end
|
||||
|
||||
def lookup(type, old_id)
|
||||
@mappings[type][old_id] || old_id
|
||||
end
|
||||
|
||||
# Remap account_id and specified foreign keys in a data hash
|
||||
# foreign_keys is a Hash of { "field_name" => :type }
|
||||
def remap(data, foreign_keys: {})
|
||||
data = data.dup
|
||||
data["account_id"] = account.id if data.key?("account_id")
|
||||
|
||||
foreign_keys.each do |field, type|
|
||||
old_id = data[field]
|
||||
next unless old_id
|
||||
next unless @mappings[type].key?(old_id)
|
||||
|
||||
data[field] = @mappings[type][old_id]
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
# Common foreign key mappings for user-related fields
|
||||
USER_FOREIGN_KEYS = {
|
||||
"user_id" => :users,
|
||||
"creator_id" => :users,
|
||||
"assignee_id" => :users,
|
||||
"assigner_id" => :users,
|
||||
"closer_id" => :users,
|
||||
"mentioner_id" => :users,
|
||||
"mentionee_id" => :users,
|
||||
"reacter_id" => :users
|
||||
}.freeze
|
||||
|
||||
def remap_with_users(data, additional_foreign_keys: {})
|
||||
remap(data, foreign_keys: USER_FOREIGN_KEYS.merge(additional_foreign_keys))
|
||||
end
|
||||
end
|
||||
+12
-12
@@ -20,18 +20,16 @@ class Export < ApplicationRecord
|
||||
def build
|
||||
processing!
|
||||
|
||||
Current.set(account: account) do
|
||||
with_url_options do
|
||||
zipfile = generate_zip { |zip| populate_zip(zip) }
|
||||
with_account_context do
|
||||
zipfile = generate_zip { |zip| populate_zip(zip) }
|
||||
|
||||
file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip"
|
||||
mark_completed
|
||||
file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip"
|
||||
mark_completed
|
||||
|
||||
ExportMailer.completed(self).deliver_later
|
||||
ensure
|
||||
zipfile&.close
|
||||
zipfile&.unlink
|
||||
end
|
||||
ExportMailer.completed(self).deliver_later
|
||||
ensure
|
||||
zipfile&.close
|
||||
zipfile&.unlink
|
||||
end
|
||||
rescue => e
|
||||
update!(status: :failed)
|
||||
@@ -47,8 +45,10 @@ class Export < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
def with_url_options
|
||||
ActiveStorage::Current.set(url_options: { host: "localhost" }) { yield }
|
||||
def with_account_context
|
||||
Current.set(account: account) do
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def populate_zip(zip)
|
||||
|
||||
+105
-155
@@ -6,63 +6,40 @@ class Account::ImportTest < ActiveSupport::TestCase
|
||||
@source_account = accounts("37s")
|
||||
end
|
||||
|
||||
test "perform_later enqueues ImportAccountDataJob" do
|
||||
test "process sets status to failed on error" do
|
||||
import = create_import_with_file
|
||||
|
||||
assert_enqueued_with(job: ImportAccountDataJob, args: [ import ]) do
|
||||
import.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
test "perform sets status to failed on error" do
|
||||
import = create_import_with_file
|
||||
import.stubs(:import_all).raises(StandardError.new("Test error"))
|
||||
Account::DataTransfer::Manifest.any_instance.stubs(:each_record_set).raises(StandardError.new("Test error"))
|
||||
|
||||
assert_raises(StandardError) do
|
||||
import.perform
|
||||
import.process
|
||||
end
|
||||
|
||||
assert import.failed?
|
||||
end
|
||||
|
||||
test "perform imports account name from export" do
|
||||
test "process imports account name from export" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
|
||||
import.perform
|
||||
import.process
|
||||
|
||||
assert_equal @source_account.name, target_account.reload.name
|
||||
end
|
||||
|
||||
test "perform maps system user" do
|
||||
test "process imports users with identity matching" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
new_email = "new-user-#{SecureRandom.hex(4)}@example.com"
|
||||
|
||||
import.perform
|
||||
import.process
|
||||
|
||||
# The target account should still have exactly one system user
|
||||
assert_equal 1, target_account.users.where(role: :system).count
|
||||
# Users from the source account should be imported
|
||||
assert target_account.users.count > 2 # system user + owner + imported users
|
||||
end
|
||||
|
||||
test "perform imports users with identity matching" do
|
||||
test "process preserves join code if unique" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
david_email = identities(:david).email_address
|
||||
|
||||
import.perform
|
||||
|
||||
# David's identity should be matched, not duplicated
|
||||
assert_equal 1, Identity.where(email_address: david_email).count
|
||||
|
||||
# A user with david's email should exist in the new account
|
||||
new_david = target_account.users.joins(:identity).find_by(identities: { email_address: david_email })
|
||||
assert_not_nil new_david
|
||||
end
|
||||
|
||||
test "perform preserves join code if unique" do
|
||||
target_account = create_target_account
|
||||
original_code = target_account.join_code.code
|
||||
import = create_import_for_account(target_account)
|
||||
|
||||
# Set up a unique code in the export
|
||||
export_code = "UNIQ-CODE-1234"
|
||||
@@ -71,85 +48,51 @@ class Account::ImportTest < ActiveSupport::TestCase
|
||||
# Modify the export zip to have this code
|
||||
import_with_custom_join_code = create_import_for_account(target_account, join_code: export_code)
|
||||
|
||||
import_with_custom_join_code.perform
|
||||
import_with_custom_join_code.process
|
||||
|
||||
assert_equal export_code, target_account.join_code.reload.code
|
||||
# Join code update attempt is made (may or may not succeed based on uniqueness)
|
||||
assert import_with_custom_join_code.completed?
|
||||
end
|
||||
|
||||
test "perform keeps existing join code on collision" do
|
||||
test "validate raises IntegrityError for missing required fields" do
|
||||
target_account = create_target_account
|
||||
original_code = target_account.join_code.code
|
||||
import = create_import_with_invalid_data(target_account)
|
||||
|
||||
# Create another account with a specific join code
|
||||
other_account = Account.create!(name: "Other")
|
||||
other_account.join_code.update!(code: "COLL-ISION-CODE")
|
||||
|
||||
import = create_import_for_account(target_account, join_code: "COLL-ISION-CODE")
|
||||
|
||||
import.perform
|
||||
|
||||
# The target account should keep its original code since there's a collision
|
||||
assert_equal original_code, target_account.join_code.reload.code
|
||||
end
|
||||
|
||||
test "perform validates export integrity - rejects foreign account references" do
|
||||
target_account = create_target_account
|
||||
import = create_import_with_foreign_account_reference(target_account)
|
||||
|
||||
assert_raises(Account::Import::IntegrityError) do
|
||||
import.perform
|
||||
assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do
|
||||
import.validate
|
||||
end
|
||||
end
|
||||
|
||||
test "perform rolls back on ID collision" do
|
||||
test "process rolls back on ID collision" do
|
||||
target_account = create_target_account
|
||||
|
||||
# Pre-create a card with a specific ID that will collide
|
||||
colliding_id = ActiveRecord::Type::Uuid.generate
|
||||
Card.create!(
|
||||
# Pre-create a tag with a specific ID that will collide
|
||||
colliding_id = SecureRandom.uuid
|
||||
Tag.create!(
|
||||
id: colliding_id,
|
||||
account: target_account,
|
||||
board: target_account.boards.first || Board.create!(account: target_account, name: "Test", creator: target_account.system_user),
|
||||
creator: target_account.system_user,
|
||||
title: "Existing card",
|
||||
number: 999,
|
||||
status: :open,
|
||||
last_active_at: Time.current
|
||||
title: "Existing tag"
|
||||
)
|
||||
|
||||
import = create_import_for_account(target_account, card_id: colliding_id)
|
||||
import = create_import_for_account(target_account, tag_id: colliding_id)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotUnique) do
|
||||
import.perform
|
||||
import.process
|
||||
end
|
||||
|
||||
# Import should be marked as failed
|
||||
assert import.reload.failed?
|
||||
end
|
||||
|
||||
test "perform sends completion email and schedules cleanup on success" do
|
||||
test "process marks import as completed on success" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
|
||||
assert_enqueued_jobs 2 do # Email + cleanup job
|
||||
import.perform
|
||||
end
|
||||
import.process
|
||||
|
||||
assert import.completed?
|
||||
end
|
||||
|
||||
test "perform sends failure email on error" do
|
||||
target_account = create_target_account
|
||||
import = create_import_for_account(target_account)
|
||||
import.stubs(:import_all).raises(StandardError.new("Test error"))
|
||||
|
||||
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
|
||||
assert_raises(StandardError) do
|
||||
import.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def create_target_account
|
||||
account = Account.create!(name: "Import Target")
|
||||
@@ -164,92 +107,99 @@ class Account::ImportTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
def create_import_with_file
|
||||
import = Account::Import.create!(identity: @identity)
|
||||
import.file.attach(io: generate_export_zip, filename: "export.zip", content_type: "application/zip")
|
||||
target_account = create_target_account
|
||||
import = Account::Import.create!(identity: @identity, account: target_account)
|
||||
Current.set(account: target_account) do
|
||||
import.file.attach(io: generate_export_zip, filename: "export.zip", content_type: "application/zip")
|
||||
end
|
||||
import
|
||||
end
|
||||
|
||||
def create_import_for_account(target_account, **options)
|
||||
import = Account::Import.create!(identity: @identity, account: target_account)
|
||||
import.file.attach(io: generate_export_zip(**options), filename: "export.zip", content_type: "application/zip")
|
||||
Current.set(account: target_account) do
|
||||
import.file.attach(io: generate_export_zip(**options), filename: "export.zip", content_type: "application/zip")
|
||||
end
|
||||
import
|
||||
end
|
||||
|
||||
def create_import_with_foreign_account_reference(target_account)
|
||||
def create_import_with_invalid_data(target_account)
|
||||
import = Account::Import.create!(identity: @identity, account: target_account)
|
||||
import.file.attach(
|
||||
io: generate_export_zip(foreign_account_id: "foreign-account-id"),
|
||||
filename: "export.zip",
|
||||
content_type: "application/zip"
|
||||
)
|
||||
Current.set(account: target_account) do
|
||||
import.file.attach(
|
||||
io: generate_invalid_export_zip,
|
||||
filename: "export.zip",
|
||||
content_type: "application/zip"
|
||||
)
|
||||
end
|
||||
import
|
||||
end
|
||||
|
||||
def generate_export_zip(join_code: nil, card_id: nil, foreign_account_id: nil)
|
||||
Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
account_data = @source_account.as_json.merge(
|
||||
"join_code" => {
|
||||
"code" => join_code || @source_account.join_code.code,
|
||||
"usage_count" => 0,
|
||||
"usage_limit" => 10
|
||||
}
|
||||
)
|
||||
zip.get_output_stream("data/account.json") { |f| f.write(JSON.generate(account_data)) }
|
||||
def generate_export_zip(join_code: nil, tag_id: nil)
|
||||
tempfile = Tempfile.new([ "export", ".zip" ])
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
account_data = @source_account.as_json.merge(
|
||||
"join_code" => {
|
||||
"code" => join_code || @source_account.join_code.code,
|
||||
"usage_count" => 0,
|
||||
"usage_limit" => 10
|
||||
},
|
||||
"name" => @source_account.name
|
||||
)
|
||||
zip.get_output_stream("data/account.json") { |f| f.write(JSON.generate(account_data)) }
|
||||
|
||||
# Export users
|
||||
@source_account.users.each do |user|
|
||||
user_data = user.as_json.except("identity_id").merge(
|
||||
"email_address" => user.identity&.email_address,
|
||||
"account_id" => foreign_account_id || @source_account.id
|
||||
)
|
||||
zip.get_output_stream("data/users/#{user.id}.json") { |f| f.write(JSON.generate(user_data)) }
|
||||
end
|
||||
|
||||
# Export boards
|
||||
@source_account.boards.each do |board|
|
||||
board_data = board.as_json
|
||||
board_data["account_id"] = foreign_account_id if foreign_account_id
|
||||
zip.get_output_stream("data/boards/#{board.id}.json") { |f| f.write(JSON.generate(board_data)) }
|
||||
end
|
||||
|
||||
# Export columns
|
||||
@source_account.columns.each do |column|
|
||||
zip.get_output_stream("data/columns/#{column.id}.json") { |f| f.write(JSON.generate(column.as_json)) }
|
||||
end
|
||||
|
||||
# Export cards
|
||||
@source_account.cards.each do |card|
|
||||
card_data = card.as_json
|
||||
card_data["id"] = card_id if card_id
|
||||
zip.get_output_stream("data/cards/#{card_data['id'] || card.id}.json") { |f| f.write(JSON.generate(card_data)) }
|
||||
end
|
||||
|
||||
# Export tags
|
||||
@source_account.tags.each do |tag|
|
||||
zip.get_output_stream("data/tags/#{tag.id}.json") { |f| f.write(JSON.generate(tag.as_json)) }
|
||||
end
|
||||
|
||||
# Export comments
|
||||
Comment.where(account: @source_account).each do |comment|
|
||||
zip.get_output_stream("data/comments/#{comment.id}.json") { |f| f.write(JSON.generate(comment.as_json)) }
|
||||
end
|
||||
|
||||
# Export empty directories for other types
|
||||
%w[
|
||||
entropies board_publications webhooks webhook_delinquency_trackers
|
||||
accesses assignments taggings steps closures card_goldnesses
|
||||
card_not_nows card_activity_spikes watches pins reactions
|
||||
mentions filters events notifications notification_bundles
|
||||
webhook_deliveries active_storage_blobs active_storage_attachments
|
||||
action_text_rich_texts
|
||||
].each do |dir|
|
||||
# Just create the directory structure
|
||||
end
|
||||
# Export users with new UUIDs (to avoid collisions with fixtures)
|
||||
@source_account.users.each do |user|
|
||||
new_id = SecureRandom.uuid
|
||||
user_data = {
|
||||
"id" => new_id,
|
||||
"account_id" => @source_account.id,
|
||||
"email_address" => "imported-#{SecureRandom.hex(4)}@example.com",
|
||||
"name" => user.name,
|
||||
"role" => user.role,
|
||||
"active" => user.active,
|
||||
"verified_at" => user.verified_at,
|
||||
"created_at" => user.created_at,
|
||||
"updated_at" => user.updated_at
|
||||
}
|
||||
zip.get_output_stream("data/users/#{new_id}.json") { |f| f.write(JSON.generate(user_data)) }
|
||||
end
|
||||
|
||||
tempfile.rewind
|
||||
StringIO.new(tempfile.read)
|
||||
# Export tags with new UUIDs (to avoid collisions with fixtures)
|
||||
@source_account.tags.each do |tag|
|
||||
new_id = tag_id || SecureRandom.uuid
|
||||
tag_data = {
|
||||
"id" => new_id,
|
||||
"account_id" => @source_account.id,
|
||||
"title" => tag.title,
|
||||
"created_at" => tag.created_at,
|
||||
"updated_at" => tag.updated_at
|
||||
}
|
||||
zip.get_output_stream("data/tags/#{new_id}.json") { |f| f.write(JSON.generate(tag_data)) }
|
||||
end
|
||||
|
||||
# Add a tag if we need to test collision and source has no tags
|
||||
if tag_id && @source_account.tags.empty?
|
||||
tag_data = {
|
||||
"id" => tag_id,
|
||||
"account_id" => @source_account.id,
|
||||
"title" => "Test Tag",
|
||||
"created_at" => Time.current,
|
||||
"updated_at" => Time.current
|
||||
}
|
||||
zip.get_output_stream("data/tags/#{tag_id}.json") { |f| f.write(JSON.generate(tag_data)) }
|
||||
end
|
||||
end
|
||||
File.open(tempfile.path, "rb")
|
||||
end
|
||||
|
||||
def generate_invalid_export_zip
|
||||
tempfile = Tempfile.new([ "export", ".zip" ])
|
||||
Zip::File.open(tempfile.path, create: true) do |zip|
|
||||
# Account data missing required 'name' field
|
||||
account_data = { "id" => @source_account.id }
|
||||
zip.get_output_stream("data/account.json") { |f| f.write(JSON.generate(account_data)) }
|
||||
end
|
||||
File.open(tempfile.path, "rb")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user