From b4a7b6300f50940552f239e4bfba3372b879b8ad Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Dec 2025 17:55:25 +0100 Subject: [PATCH 01/25] Split Export model into user and whole-account export --- app/models/account/export.rb | 39 +- app/models/account/single_user_export.rb | 29 ++ app/models/account/whole_account_export.rb | 21 ++ ...51212164543_add_type_to_account_exports.rb | 5 + db/schema.rb | 357 +++++++++--------- .../accounts/exports_controller_test.rb | 4 +- test/models/account/export_test.rb | 71 +--- .../models/account/single_user_export_test.rb | 62 +++ .../account/whole_account_export_test.rb | 7 + 9 files changed, 326 insertions(+), 269 deletions(-) create mode 100644 app/models/account/single_user_export.rb create mode 100644 app/models/account/whole_account_export.rb create mode 100644 db/migrate/20251212164543_add_type_to_account_exports.rb create mode 100644 test/models/account/single_user_export_test.rb create mode 100644 test/models/account/whole_account_export_test.rb diff --git a/app/models/account/export.rb b/app/models/account/export.rb index 1fd2689d2c..97d6e995db 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -19,7 +19,7 @@ def build_later def build processing! - zipfile = generate_zip + 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 @@ -42,36 +42,29 @@ def accessible_to?(accessor) end private + def populate_zip(zip) + raise NotImplementedError, "Subclasses must implement populate_zip" + end + def generate_zip + raise ArgumentError, "Block is required" unless block_given? + Tempfile.new([ "export", ".zip" ]).tap do |tempfile| Zip::File.open(tempfile.path, create: true) do |zip| - exportable_cards.find_each do |card| - add_card_to_zip(zip, card) - end + yield zip end end end - def exportable_cards - user.accessible_cards.includes( - :board, - creator: :identity, - comments: { creator: :identity }, - rich_text_description: { embeds_attachments: :blob } - ) - end - - def add_card_to_zip(zip, card) - zip.get_output_stream("#{card.number}.json") do |f| - f.write(card.export_json) - end - - card.export_attachments.each do |attachment| - zip.get_output_stream(attachment[:path], compression_method: Zip::Entry::STORED) do |f| - attachment[:blob].download { |chunk| f.write(chunk) } + def add_file_to_zip(zip, path, content = nil, **options) + zip.get_output_stream(path, **options) do |f| + if block_given? + yield f + elsif content + f.write(content) + else + raise ArgumentError, "Either content or a block must be provided" end - rescue ActiveStorage::FileNotFoundError - # Skip attachments where the file is missing from storage end end end diff --git a/app/models/account/single_user_export.rb b/app/models/account/single_user_export.rb new file mode 100644 index 0000000000..d5fbcfb7bc --- /dev/null +++ b/app/models/account/single_user_export.rb @@ -0,0 +1,29 @@ +class Account::SingleUserExport < Account::Export + private + def populate_zip(zip) + exportable_cards.find_each do |card| + add_card_to_zip(zip, card) + end + end + + def exportable_cards + user.accessible_cards.includes( + :board, + creator: :identity, + comments: { creator: :identity }, + rich_text_description: { embeds_attachments: :blob } + ) + end + + def add_card_to_zip(zip, card) + add_file_to_zip(zip, "#{card.number}.json", card.export_json) + + card.export_attachments.each do |attachment| + add_file_to_zip(zip, attachment[:path], compression_method: Zip::Entry::STORED) do |f| + attachment[:blob].download { |chunk| f.write(chunk) } + end + rescue ActiveStorage::FileNotFoundError + # Skip attachments where the file is missing from storage + end + end +end diff --git a/app/models/account/whole_account_export.rb b/app/models/account/whole_account_export.rb new file mode 100644 index 0000000000..f8e22b5c77 --- /dev/null +++ b/app/models/account/whole_account_export.rb @@ -0,0 +1,21 @@ +class Account::WholeAccountExport < Account::Export + private + def populate_zip(zip) + export_account(zip) + export_users(zip) + end + + def export_account(zip) + data = account.as_json.merge( + join_code: account.join_code.as_json, + ) + + add_file_to_zip(zip, "account.json", JSON.pretty_generate(data)) + end + + def export_users(zip) + account.users.find_each do |user| + add_file_to_zip(zip, "users/#{user.id}.json", user.export_json) + end + end +end diff --git a/db/migrate/20251212164543_add_type_to_account_exports.rb b/db/migrate/20251212164543_add_type_to_account_exports.rb new file mode 100644 index 0000000000..3931380c65 --- /dev/null +++ b/db/migrate/20251212164543_add_type_to_account_exports.rb @@ -0,0 +1,5 @@ +class AddTypeToAccountExports < ActiveRecord::Migration[8.2] + def change + add_column :account_exports, :type, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 86b9f29375..3440a25f44 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,10 +19,10 @@ t.string "involvement", default: "access_only", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id", "accessed_at"], name: "index_accesses_on_account_id_and_accessed_at" - t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true - t.index ["board_id"], name: "index_accesses_on_board_id" - t.index ["user_id"], name: "index_accesses_on_user_id" + t.index [ "account_id", "accessed_at" ], name: "index_accesses_on_account_id_and_accessed_at" + t.index [ "board_id", "user_id" ], name: "index_accesses_on_board_id_and_user_id", unique: true + t.index [ "board_id" ], name: "index_accesses_on_board_id" + t.index [ "user_id" ], name: "index_accesses_on_user_id" end create_table "account_cancellations", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -38,15 +38,16 @@ t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", default: "pending", null: false + t.string "type" t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id"], name: "index_account_exports_on_account_id" - t.index ["user_id"], name: "index_account_exports_on_user_id" + t.index [ "account_id" ], name: "index_account_exports_on_account_id" + t.index [ "user_id" ], name: "index_account_exports_on_user_id" end create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false - t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true + t.index [ "value" ], name: "index_account_external_id_sequences_on_value", unique: true end create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -56,7 +57,7 @@ t.datetime "updated_at", null: false t.bigint "usage_count", default: 0, null: false t.bigint "usage_limit", default: 10, null: false - t.index ["account_id", "code"], name: "index_account_join_codes_on_account_id_and_code", unique: true + t.index [ "account_id", "code" ], name: "index_account_join_codes_on_account_id_and_code", unique: true end create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -65,7 +66,7 @@ t.bigint "external_account_id" t.string "name", null: false t.datetime "updated_at", null: false - t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true + t.index [ "external_account_id" ], name: "index_accounts_on_external_account_id", unique: true end create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -76,8 +77,8 @@ t.uuid "record_id", null: false t.string "record_type", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_action_text_rich_texts_on_account_id" - t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true + t.index [ "account_id" ], name: "index_action_text_rich_texts_on_account_id" + t.index [ "record_type", "record_id", "name" ], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -87,9 +88,9 @@ t.string "name", null: false t.uuid "record_id", null: false t.string "record_type", null: false - t.index ["account_id"], name: "index_active_storage_attachments_on_account_id" - t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" - t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + t.index [ "account_id" ], name: "index_active_storage_attachments_on_account_id" + t.index [ "blob_id" ], name: "index_active_storage_attachments_on_blob_id" + t.index [ "record_type", "record_id", "name", "blob_id" ], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -102,30 +103,30 @@ t.string "key", null: false t.text "metadata" t.string "service_name", null: false - t.index ["account_id"], name: "index_active_storage_blobs_on_account_id" - t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + t.index [ "account_id" ], name: "index_active_storage_blobs_on_account_id" + t.index [ "key" ], name: "index_active_storage_blobs_on_key", unique: true end create_table "active_storage_variant_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id", null: false t.string "variation_digest", null: false - t.index ["account_id"], name: "index_active_storage_variant_records_on_account_id" - t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + t.index [ "account_id" ], name: "index_active_storage_variant_records_on_account_id" + t.index [ "blob_id", "variation_digest" ], name: "index_active_storage_variant_records_uniqueness", unique: true end create_table "assignees_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "assignee_id", null: false t.uuid "filter_id", null: false - t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" - t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" + t.index [ "assignee_id" ], name: "index_assignees_filters_on_assignee_id" + t.index [ "filter_id" ], name: "index_assignees_filters_on_filter_id" end create_table "assigners_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "assigner_id", null: false t.uuid "filter_id", null: false - t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" - t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" + t.index [ "assigner_id" ], name: "index_assigners_filters_on_assigner_id" + t.index [ "filter_id" ], name: "index_assigners_filters_on_filter_id" end create_table "assignments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -135,9 +136,9 @@ t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_assignments_on_account_id" - t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true - t.index ["card_id"], name: "index_assignments_on_card_id" + t.index [ "account_id" ], name: "index_assignments_on_account_id" + t.index [ "assignee_id", "card_id" ], name: "index_assignments_on_assignee_id_and_card_id", unique: true + t.index [ "card_id" ], name: "index_assignments_on_card_id" end create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -146,8 +147,8 @@ t.datetime "created_at", null: false t.string "key" t.datetime "updated_at", null: false - t.index ["account_id", "key"], name: "index_board_publications_on_account_id_and_key" - t.index ["board_id"], name: "index_board_publications_on_board_id" + t.index [ "account_id", "key" ], name: "index_board_publications_on_account_id_and_key" + t.index [ "board_id" ], name: "index_board_publications_on_board_id" end create_table "boards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -157,15 +158,15 @@ t.uuid "creator_id", null: false t.string "name", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_boards_on_account_id" - t.index ["creator_id"], name: "index_boards_on_creator_id" + t.index [ "account_id" ], name: "index_boards_on_account_id" + t.index [ "creator_id" ], name: "index_boards_on_creator_id" end create_table "boards_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "board_id", null: false t.uuid "filter_id", null: false - t.index ["board_id"], name: "index_boards_filters_on_board_id" - t.index ["filter_id"], name: "index_boards_filters_on_filter_id" + t.index [ "board_id" ], name: "index_boards_filters_on_board_id" + t.index [ "filter_id" ], name: "index_boards_filters_on_filter_id" end create_table "card_activity_spikes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -173,8 +174,8 @@ t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_card_activity_spikes_on_account_id" - t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true + t.index [ "account_id" ], name: "index_card_activity_spikes_on_account_id" + t.index [ "card_id" ], name: "index_card_activity_spikes_on_card_id", unique: true end create_table "card_goldnesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -182,8 +183,8 @@ t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_card_goldnesses_on_account_id" - t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true + t.index [ "account_id" ], name: "index_card_goldnesses_on_account_id" + t.index [ "card_id" ], name: "index_card_goldnesses_on_card_id", unique: true end create_table "card_not_nows", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -192,9 +193,9 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" - t.index ["account_id"], name: "index_card_not_nows_on_account_id" - t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true - t.index ["user_id"], name: "index_card_not_nows_on_user_id" + t.index [ "account_id" ], name: "index_card_not_nows_on_account_id" + t.index [ "card_id" ], name: "index_card_not_nows_on_card_id", unique: true + t.index [ "user_id" ], name: "index_card_not_nows_on_user_id" end create_table "cards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -209,17 +210,17 @@ t.string "status", default: "drafted", null: false t.string "title" t.datetime "updated_at", null: false - t.index ["account_id", "last_active_at", "status"], name: "index_cards_on_account_id_and_last_active_at_and_status" - t.index ["account_id", "number"], name: "index_cards_on_account_id_and_number", unique: true - t.index ["board_id"], name: "index_cards_on_board_id" - t.index ["column_id"], name: "index_cards_on_column_id" + t.index [ "account_id", "last_active_at", "status" ], name: "index_cards_on_account_id_and_last_active_at_and_status" + t.index [ "account_id", "number" ], name: "index_cards_on_account_id_and_number", unique: true + t.index [ "board_id" ], name: "index_cards_on_board_id" + t.index [ "column_id" ], name: "index_cards_on_column_id" end create_table "closers_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "closer_id", null: false t.uuid "filter_id", null: false - t.index ["closer_id"], name: "index_closers_filters_on_closer_id" - t.index ["filter_id"], name: "index_closers_filters_on_filter_id" + t.index [ "closer_id" ], name: "index_closers_filters_on_closer_id" + t.index [ "filter_id" ], name: "index_closers_filters_on_filter_id" end create_table "closures", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -228,10 +229,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" - t.index ["account_id"], name: "index_closures_on_account_id" - t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" - t.index ["card_id"], name: "index_closures_on_card_id", unique: true - t.index ["user_id"], name: "index_closures_on_user_id" + t.index [ "account_id" ], name: "index_closures_on_account_id" + t.index [ "card_id", "created_at" ], name: "index_closures_on_card_id_and_created_at" + t.index [ "card_id" ], name: "index_closures_on_card_id", unique: true + t.index [ "user_id" ], name: "index_closures_on_user_id" end create_table "columns", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -242,9 +243,9 @@ t.string "name", null: false t.integer "position", default: 0, null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_columns_on_account_id" - t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" - t.index ["board_id"], name: "index_columns_on_board_id" + t.index [ "account_id" ], name: "index_columns_on_account_id" + t.index [ "board_id", "position" ], name: "index_columns_on_board_id_and_position" + t.index [ "board_id" ], name: "index_columns_on_board_id" end create_table "comments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -253,15 +254,15 @@ t.datetime "created_at", null: false t.uuid "creator_id", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_comments_on_account_id" - t.index ["card_id"], name: "index_comments_on_card_id" + t.index [ "account_id" ], name: "index_comments_on_account_id" + t.index [ "card_id" ], name: "index_comments_on_card_id" end create_table "creators_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "creator_id", null: false t.uuid "filter_id", null: false - t.index ["creator_id"], name: "index_creators_filters_on_creator_id" - t.index ["filter_id"], name: "index_creators_filters_on_filter_id" + t.index [ "creator_id" ], name: "index_creators_filters_on_creator_id" + t.index [ "filter_id" ], name: "index_creators_filters_on_filter_id" end create_table "entropies", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -271,9 +272,9 @@ t.string "container_type", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_entropies_on_account_id" - t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" - t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true + t.index [ "account_id" ], name: "index_entropies_on_account_id" + t.index [ "container_type", "container_id", "auto_postpone_period" ], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" + t.index [ "container_type", "container_id" ], name: "index_entropy_configurations_on_container", unique: true end create_table "events", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -286,11 +287,11 @@ t.string "eventable_type", null: false t.json "particulars", default: -> { "(json_object())" } t.datetime "updated_at", null: false - t.index ["account_id", "action"], name: "index_events_on_account_id_and_action" - t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" - t.index ["board_id"], name: "index_events_on_board_id" - t.index ["creator_id"], name: "index_events_on_creator_id" - t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" + t.index [ "account_id", "action" ], name: "index_events_on_account_id_and_action" + t.index [ "board_id", "action", "created_at" ], name: "index_events_on_board_id_and_action_and_created_at" + t.index [ "board_id" ], name: "index_events_on_board_id" + t.index [ "creator_id" ], name: "index_events_on_creator_id" + t.index [ "eventable_type", "eventable_id" ], name: "index_events_on_eventable" end create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -300,15 +301,15 @@ t.json "fields", default: -> { "(json_object())" }, null: false t.string "params_digest", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_filters_on_account_id" - t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true + t.index [ "account_id" ], name: "index_filters_on_account_id" + t.index [ "creator_id", "params_digest" ], name: "index_filters_on_creator_id_and_params_digest", unique: true end create_table "filters_tags", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "filter_id", null: false t.uuid "tag_id", null: false - t.index ["filter_id"], name: "index_filters_tags_on_filter_id" - t.index ["tag_id"], name: "index_filters_tags_on_tag_id" + t.index [ "filter_id" ], name: "index_filters_tags_on_filter_id" + t.index [ "tag_id" ], name: "index_filters_tags_on_tag_id" end create_table "identities", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -316,7 +317,7 @@ t.string "email_address", null: false t.boolean "staff", default: false, null: false t.datetime "updated_at", null: false - t.index ["email_address"], name: "index_identities_on_email_address", unique: true + t.index [ "email_address" ], name: "index_identities_on_email_address", unique: true end create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -326,7 +327,7 @@ t.string "permission" t.string "token" t.datetime "updated_at", null: false - t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index [ "identity_id" ], name: "index_access_token_on_identity_id" end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -336,9 +337,9 @@ t.uuid "identity_id" t.integer "purpose", null: false t.datetime "updated_at", null: false - t.index ["code"], name: "index_magic_links_on_code", unique: true - t.index ["expires_at"], name: "index_magic_links_on_expires_at" - t.index ["identity_id"], name: "index_magic_links_on_identity_id" + t.index [ "code" ], name: "index_magic_links_on_code", unique: true + t.index [ "expires_at" ], name: "index_magic_links_on_expires_at" + t.index [ "identity_id" ], name: "index_magic_links_on_identity_id" end create_table "mentions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -349,10 +350,10 @@ t.uuid "source_id", null: false t.string "source_type", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_mentions_on_account_id" - t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" - t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" - t.index ["source_type", "source_id"], name: "index_mentions_on_source" + t.index [ "account_id" ], name: "index_mentions_on_account_id" + t.index [ "mentionee_id" ], name: "index_mentions_on_mentionee_id" + t.index [ "mentioner_id" ], name: "index_mentions_on_mentioner_id" + t.index [ "source_type", "source_id" ], name: "index_mentions_on_source" end create_table "notification_bundles", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -363,10 +364,10 @@ t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id"], name: "index_notification_bundles_on_account_id" - t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" - t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" - t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" + t.index [ "account_id" ], name: "index_notification_bundles_on_account_id" + t.index [ "ends_at", "status" ], name: "index_notification_bundles_on_ends_at_and_status" + t.index [ "user_id", "starts_at", "ends_at" ], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" + t.index [ "user_id", "status" ], name: "index_notification_bundles_on_user_id_and_status" end create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -378,11 +379,11 @@ t.string "source_type", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id"], name: "index_notifications_on_account_id" - t.index ["creator_id"], name: "index_notifications_on_creator_id" - t.index ["source_type", "source_id"], name: "index_notifications_on_source" - t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } - t.index ["user_id"], name: "index_notifications_on_user_id" + t.index [ "account_id" ], name: "index_notifications_on_account_id" + t.index [ "creator_id" ], name: "index_notifications_on_creator_id" + t.index [ "source_type", "source_id" ], name: "index_notifications_on_source" + t.index [ "user_id", "read_at", "created_at" ], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index [ "user_id" ], name: "index_notifications_on_user_id" end create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -391,10 +392,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id"], name: "index_pins_on_account_id" - t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true - t.index ["card_id"], name: "index_pins_on_card_id" - t.index ["user_id"], name: "index_pins_on_user_id" + t.index [ "account_id" ], name: "index_pins_on_account_id" + t.index [ "card_id", "user_id" ], name: "index_pins_on_card_id_and_user_id", unique: true + t.index [ "card_id" ], name: "index_pins_on_card_id" + t.index [ "user_id" ], name: "index_pins_on_user_id" end create_table "push_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -406,8 +407,8 @@ t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 t.uuid "user_id", null: false - t.index ["account_id"], name: "index_push_subscriptions_on_account_id" - t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true, length: { endpoint: 255 } + t.index [ "account_id" ], name: "index_push_subscriptions_on_account_id" + t.index [ "user_id", "endpoint" ], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true, length: { endpoint: 255 } end create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -417,9 +418,9 @@ t.datetime "created_at", null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_reactions_on_account_id" - t.index ["comment_id"], name: "index_reactions_on_comment_id" - t.index ["reacter_id"], name: "index_reactions_on_reacter_id" + t.index [ "account_id" ], name: "index_reactions_on_account_id" + t.index [ "comment_id" ], name: "index_reactions_on_comment_id" + t.index [ "reacter_id" ], name: "index_reactions_on_reacter_id" end create_table "search_queries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -428,10 +429,10 @@ t.string "terms", limit: 2000, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id"], name: "index_search_queries_on_account_id" - t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms", length: { terms: 255 } - t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true - t.index ["user_id"], name: "index_search_queries_on_user_id" + t.index [ "account_id" ], name: "index_search_queries_on_account_id" + t.index [ "user_id", "terms" ], name: "index_search_queries_on_user_id_and_terms", length: { terms: 255 } + t.index [ "user_id", "updated_at" ], name: "index_search_queries_on_user_id_and_updated_at", unique: true + t.index [ "user_id" ], name: "index_search_queries_on_user_id" end create_table "search_records_0", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -444,9 +445,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_0_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_0_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_0_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_0_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_0_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_0_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_1", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -459,9 +460,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_1_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_1_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_1_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_1_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_1_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_1_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_10", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -474,9 +475,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_10_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_10_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_10_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_10_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_10_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_10_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_11", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -489,9 +490,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_11_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_11_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_11_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_11_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_11_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_11_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_12", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -504,9 +505,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_12_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_12_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_12_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_12_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_12_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_12_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_13", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -519,9 +520,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_13_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_13_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_13_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_13_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_13_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_13_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_14", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -534,9 +535,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_14_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_14_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_14_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_14_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_14_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_14_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_15", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -549,9 +550,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_15_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_15_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_15_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_15_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_15_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_15_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_2", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -564,9 +565,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_2_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_2_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_2_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_2_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_2_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_2_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_3", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -579,9 +580,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_3_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_3_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_3_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_3_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_3_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_3_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_4", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -594,9 +595,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_4_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_4_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_4_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_4_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_4_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_4_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_5", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -609,9 +610,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_5_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_5_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_5_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_5_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_5_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_5_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_6", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -624,9 +625,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_6_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_6_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_6_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_6_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_6_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_6_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_7", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -639,9 +640,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_7_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_7_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_7_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_7_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_7_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_7_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_8", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -654,9 +655,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_8_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_8_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_8_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_8_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_8_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_8_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_9", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -669,9 +670,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index ["account_id"], name: "index_search_records_9_on_account_id" - t.index ["account_key", "content", "title"], name: "index_search_records_9_on_account_key_and_content_and_title", type: :fulltext - t.index ["searchable_type", "searchable_id"], name: "index_search_records_9_on_searchable_type_and_searchable_id", unique: true + t.index [ "account_id" ], name: "index_search_records_9_on_account_id" + t.index [ "account_key", "content", "title" ], name: "index_search_records_9_on_account_key_and_content_and_title", type: :fulltext + t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_9_on_searchable_type_and_searchable_id", unique: true end create_table "sessions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -680,7 +681,7 @@ t.string "ip_address" t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 - t.index ["identity_id"], name: "index_sessions_on_identity_id" + t.index [ "identity_id" ], name: "index_sessions_on_identity_id" end create_table "steps", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -690,9 +691,9 @@ t.text "content", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_steps_on_account_id" - t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" - t.index ["card_id"], name: "index_steps_on_card_id" + t.index [ "account_id" ], name: "index_steps_on_account_id" + t.index [ "card_id", "completed" ], name: "index_steps_on_card_id_and_completed" + t.index [ "card_id" ], name: "index_steps_on_card_id" end create_table "storage_entries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -706,12 +707,12 @@ t.string "recordable_type" t.string "request_id" t.uuid "user_id" - t.index ["account_id"], name: "index_storage_entries_on_account_id" - t.index ["blob_id"], name: "index_storage_entries_on_blob_id" - t.index ["board_id"], name: "index_storage_entries_on_board_id" - t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable" - t.index ["request_id"], name: "index_storage_entries_on_request_id" - t.index ["user_id"], name: "index_storage_entries_on_user_id" + t.index [ "account_id" ], name: "index_storage_entries_on_account_id" + t.index [ "blob_id" ], name: "index_storage_entries_on_blob_id" + t.index [ "board_id" ], name: "index_storage_entries_on_board_id" + t.index [ "recordable_type", "recordable_id" ], name: "index_storage_entries_on_recordable" + t.index [ "request_id" ], name: "index_storage_entries_on_request_id" + t.index [ "user_id" ], name: "index_storage_entries_on_user_id" end create_table "storage_totals", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -721,7 +722,7 @@ t.uuid "owner_id", null: false t.string "owner_type", null: false t.datetime "updated_at", null: false - t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true + t.index [ "owner_type", "owner_id" ], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true end create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -730,9 +731,9 @@ t.datetime "created_at", null: false t.uuid "tag_id", null: false t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_taggings_on_account_id" - t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true - t.index ["tag_id"], name: "index_taggings_on_tag_id" + t.index [ "account_id" ], name: "index_taggings_on_account_id" + t.index [ "card_id", "tag_id" ], name: "index_taggings_on_card_id_and_tag_id", unique: true + t.index [ "tag_id" ], name: "index_taggings_on_tag_id" end create_table "tags", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -740,7 +741,7 @@ t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false - t.index ["account_id", "title"], name: "index_tags_on_account_id_and_title", unique: true + t.index [ "account_id", "title" ], name: "index_tags_on_account_id_and_title", unique: true end create_table "user_settings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -750,9 +751,9 @@ t.string "timezone_name" t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index ["account_id"], name: "index_user_settings_on_account_id" - t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" - t.index ["user_id"], name: "index_user_settings_on_user_id" + t.index [ "account_id" ], name: "index_user_settings_on_account_id" + t.index [ "user_id", "bundle_email_frequency" ], name: "index_user_settings_on_user_id_and_bundle_email_frequency" + t.index [ "user_id" ], name: "index_user_settings_on_user_id" end create_table "users", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -764,9 +765,9 @@ t.string "role", default: "member", null: false t.datetime "updated_at", null: false t.datetime "verified_at" - t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true - t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" - t.index ["identity_id"], name: "index_users_on_identity_id" + t.index [ "account_id", "identity_id" ], name: "index_users_on_account_id_and_identity_id", unique: true + t.index [ "account_id", "role" ], name: "index_users_on_account_id_and_role" + t.index [ "identity_id" ], name: "index_users_on_identity_id" end create_table "watches", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -776,10 +777,10 @@ t.datetime "updated_at", null: false t.uuid "user_id", null: false t.boolean "watching", default: true, null: false - t.index ["account_id"], name: "index_watches_on_account_id" - t.index ["card_id"], name: "index_watches_on_card_id" - t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" - t.index ["user_id"], name: "index_watches_on_user_id" + t.index [ "account_id" ], name: "index_watches_on_account_id" + t.index [ "card_id" ], name: "index_watches_on_card_id" + t.index [ "user_id", "card_id" ], name: "index_watches_on_user_id_and_card_id" + t.index [ "user_id" ], name: "index_watches_on_user_id" end create_table "webhook_delinquency_trackers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -789,8 +790,8 @@ t.datetime "first_failure_at" t.datetime "updated_at", null: false t.uuid "webhook_id", null: false - t.index ["account_id"], name: "index_webhook_delinquency_trackers_on_account_id" - t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" + t.index [ "account_id" ], name: "index_webhook_delinquency_trackers_on_account_id" + t.index [ "webhook_id" ], name: "index_webhook_delinquency_trackers_on_webhook_id" end create_table "webhook_deliveries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -802,9 +803,9 @@ t.string "state", null: false t.datetime "updated_at", null: false t.uuid "webhook_id", null: false - t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" - t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" - t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" + t.index [ "account_id" ], name: "index_webhook_deliveries_on_account_id" + t.index [ "event_id" ], name: "index_webhook_deliveries_on_event_id" + t.index [ "webhook_id" ], name: "index_webhook_deliveries_on_webhook_id" end create_table "webhooks", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -817,7 +818,7 @@ t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false - t.index ["account_id"], name: "index_webhooks_on_account_id" - t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } + t.index [ "account_id" ], name: "index_webhooks_on_account_id" + t.index [ "board_id", "subscribed_actions" ], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end end diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb index 4806b65f06..1c4441cc5b 100644 --- a/test/controllers/accounts/exports_controller_test.rb +++ b/test/controllers/accounts/exports_controller_test.rb @@ -50,7 +50,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest end test "show displays completed export with download link" do - export = Account::Export.create!(account: Current.account, user: users(:david)) + export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) export.build get account_export_path(export) @@ -67,7 +67,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest end test "show does not allow access to another user's export" do - export = Account::Export.create!(account: Current.account, user: users(:kevin)) + export = Account::SingleUserExport.create!(account: Current.account, user: users(:kevin)) export.build get account_export_path(export) diff --git a/test/models/account/export_test.rb b/test/models/account/export_test.rb index 37b0ea47e3..306d2851a2 100644 --- a/test/models/account/export_test.rb +++ b/test/models/account/export_test.rb @@ -2,42 +2,15 @@ class Account::ExportTest < ActiveSupport::TestCase test "build_later enqueues ExportAccountDataJob" do - export = Account::Export.create!(account: Current.account, user: users(:david)) + export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do export.build_later end end - test "build generates zip with card JSON files" do - export = Account::Export.create!(account: Current.account, user: users(:david)) - - export.build - - assert export.completed? - assert export.file.attached? - assert_equal "application/zip", export.file.content_type - end - - test "build sets status to processing then completed" do - export = Account::Export.create!(account: Current.account, user: users(:david)) - - export.build - - assert export.completed? - assert_not_nil export.completed_at - end - - test "build sends email when completed" do - export = Account::Export.create!(account: Current.account, user: users(:david)) - - assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do - export.build - end - end - test "build sets status to failed on error" do - export = Account::Export.create!(account: Current.account, user: users(:david)) + export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) export.stubs(:generate_zip).raises(StandardError.new("Test error")) assert_raises(StandardError) do @@ -48,9 +21,9 @@ class Account::ExportTest < ActiveSupport::TestCase end test "cleanup deletes exports completed more than 24 hours ago" do - old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) - recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) - pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending) + old_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) + recent_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) + pending_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :pending) Account::Export.cleanup @@ -58,38 +31,4 @@ class Account::ExportTest < ActiveSupport::TestCase assert Account::Export.exists?(recent_export.id) assert Account::Export.exists?(pending_export.id) end - - test "build includes only accessible cards for user" do - user = users(:david) - export = Account::Export.create!(account: Current.account, user: user) - - export.build - - assert export.completed? - assert export.file.attached? - - # Verify zip contents - Tempfile.create([ "test", ".zip" ]) do |temp| - temp.binmode - export.file.download { |chunk| temp.write(chunk) } - temp.rewind - - Zip::File.open(temp.path) do |zip| - json_files = zip.glob("*.json") - assert json_files.any?, "Zip should contain at least one JSON file" - - # Verify structure of a JSON file - json_content = JSON.parse(zip.read(json_files.first.name)) - assert json_content.key?("number") - assert json_content.key?("title") - assert json_content.key?("board") - assert json_content.key?("creator") - assert json_content["creator"].key?("id") - assert json_content["creator"].key?("name") - assert json_content["creator"].key?("email") - assert json_content.key?("description") - assert json_content.key?("comments") - end - end - end end diff --git a/test/models/account/single_user_export_test.rb b/test/models/account/single_user_export_test.rb new file mode 100644 index 0000000000..8d24e16b98 --- /dev/null +++ b/test/models/account/single_user_export_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Account::SingleUserExportTest < ActiveSupport::TestCase + test "build generates zip with card JSON files" do + export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert export.file.attached? + assert_equal "application/zip", export.file.content_type + end + + test "build sets status to processing then completed" do + export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert_not_nil export.completed_at + end + + test "build sends email when completed" do + export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do + export.build + end + end + + test "build includes only accessible cards for user" do + user = users(:david) + export = Account::SingleUserExport.create!(account: Current.account, user: user) + + export.build + + assert export.completed? + assert export.file.attached? + + Tempfile.create([ "test", ".zip" ]) do |temp| + temp.binmode + export.file.download { |chunk| temp.write(chunk) } + temp.rewind + + Zip::File.open(temp.path) do |zip| + json_files = zip.glob("*.json") + assert json_files.any?, "Zip should contain at least one JSON file" + + json_content = JSON.parse(zip.read(json_files.first.name)) + assert json_content.key?("number") + assert json_content.key?("title") + assert json_content.key?("board") + assert json_content.key?("creator") + assert json_content["creator"].key?("id") + assert json_content["creator"].key?("name") + assert json_content["creator"].key?("email") + assert json_content.key?("description") + assert json_content.key?("comments") + end + end + end +end diff --git a/test/models/account/whole_account_export_test.rb b/test/models/account/whole_account_export_test.rb new file mode 100644 index 0000000000..fb947189ab --- /dev/null +++ b/test/models/account/whole_account_export_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::WholeAccountExportTest < ActiveSupport::TestCase + test "build generates zip with account data" do + skip "WholeAccountExport implementation incomplete" + end +end From b43b2677a93b8bf4dadae8d7edaf03b1e5862f87 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 22 Dec 2025 17:02:15 +0100 Subject: [PATCH 02/25] Implement full account exports --- app/jobs/import_account_data_job.rb | 7 + app/mailers/import_mailer.rb | 8 + app/models/account/export.rb | 23 +- app/models/account/import.rb | 271 +++++++++++++ app/models/account/whole_account_export.rb | 242 +++++++++++- .../mailers/import_mailer/completed.html.erb | 6 + .../mailers/import_mailer/completed.text.erb | 3 + .../20251223000001_create_account_imports.rb | 14 + db/schema.rb | 367 +++++++++--------- db/schema_sqlite.rb | 12 + 10 files changed, 762 insertions(+), 191 deletions(-) create mode 100644 app/jobs/import_account_data_job.rb create mode 100644 app/mailers/import_mailer.rb create mode 100644 app/models/account/import.rb create mode 100644 app/views/mailers/import_mailer/completed.html.erb create mode 100644 app/views/mailers/import_mailer/completed.text.erb create mode 100644 db/migrate/20251223000001_create_account_imports.rb diff --git a/app/jobs/import_account_data_job.rb b/app/jobs/import_account_data_job.rb new file mode 100644 index 0000000000..8caf731f98 --- /dev/null +++ b/app/jobs/import_account_data_job.rb @@ -0,0 +1,7 @@ +class ImportAccountDataJob < ApplicationJob + queue_as :backend + + def perform(import) + import.build + end +end diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb new file mode 100644 index 0000000000..bb1c0c4c29 --- /dev/null +++ b/app/mailers/import_mailer.rb @@ -0,0 +1,8 @@ +class ImportMailer < ApplicationMailer + def completed(import) + @import = import + @identity = import.identity + + mail to: @identity.email_address, subject: "Your Fizzy account import is complete" + end +end diff --git a/app/models/account/export.rb b/app/models/account/export.rb index 97d6e995db..e58f62fce0 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -19,18 +19,23 @@ def build_later def build processing! - 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 + Current.set(account: account) do + with_url_options do + zipfile = generate_zip { |zip| populate_zip(zip) } - ExportMailer.completed(self).deliver_later + 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 + end rescue => e update!(status: :failed) raise - ensure - zipfile&.close - zipfile&.unlink end def mark_completed @@ -42,6 +47,10 @@ def accessible_to?(accessor) end private + def with_url_options + ActiveStorage::Current.set(url_options: { host: "localhost" }) { yield } + end + def populate_zip(zip) raise NotImplementedError, "Subclasses must implement populate_zip" end diff --git a/app/models/account/import.rb b/app/models/account/import.rb new file mode 100644 index 0000000000..f51e984fcf --- /dev/null +++ b/app/models/account/import.rb @@ -0,0 +1,271 @@ +class Account::Import < ApplicationRecord + belongs_to :account, required: false + belongs_to :identity + + has_one_attached :file + + enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + + def build_later + ImportAccountDataJob.perform_later(self) + end + + def build + processing! + + ApplicationRecord.transaction do + populate_from_zip + end + + mark_completed + ImportMailer.completed(self).deliver_later + rescue => e + update!(status: :failed) + raise + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + private + def populate_from_zip + ApplicationRecord.transaction do + Zip::File.open_buffer(file.download) do |zip| + import_account(zip) + import_users(zip) + import_tags(zip) + import_entropies(zip) + import_columns(zip) + import_board_publications(zip) + import_webhooks(zip) + import_webhook_delinquency_trackers(zip) + import_accesses(zip) + import_assignments(zip) + import_taggings(zip) + import_steps(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) + import_events(zip) + import_notifications(zip) + import_notification_bundles(zip) + import_webhook_deliveries(zip) + + import_boards(zip) + import_cards(zip) + import_comments(zip) + + import_active_storage_blobs(zip) + import_active_storage_attachments(zip) + import_action_text_rich_texts(zip) + + import_blob_files(zip) + end + end + end + + def import_account(zip) + entry = zip.find_entry("data/account.json") + raise "Missing account.json in export" unless entry + + data = JSON.parse(entry.get_input_stream.read) + join_code_data = data.delete("join_code") + + Account.insert(data) + update!(account_id: data["id"]) + + Account::JoinCode.insert(join_code_data) if join_code_data + end + + def import_users(zip) + records = read_json_files(zip, "data/users") + User.insert_all(records) if records.any? + end + + def import_tags(zip) + records = read_json_files(zip, "data/tags") + Tag.insert_all(records) if records.any? + end + + def import_entropies(zip) + records = read_json_files(zip, "data/entropies") + Entropy.insert_all(records) if records.any? + end + + def import_columns(zip) + records = read_json_files(zip, "data/columns") + Column.insert_all(records) if records.any? + end + + def import_board_publications(zip) + records = read_json_files(zip, "data/board_publications") + Board::Publication.insert_all(records) if records.any? + end + + def import_webhooks(zip) + records = read_json_files(zip, "data/webhooks") + Webhook.insert_all(records) if records.any? + end + + def import_webhook_delinquency_trackers(zip) + records = read_json_files(zip, "data/webhook_delinquency_trackers") + Webhook::DelinquencyTracker.insert_all(records) if records.any? + end + + def import_accesses(zip) + records = read_json_files(zip, "data/accesses") + Access.insert_all(records) if records.any? + end + + def import_assignments(zip) + records = read_json_files(zip, "data/assignments") + Assignment.insert_all(records) if records.any? + end + + def import_taggings(zip) + records = read_json_files(zip, "data/taggings") + Tagging.insert_all(records) if records.any? + end + + def import_steps(zip) + records = read_json_files(zip, "data/steps") + Step.insert_all(records) if records.any? + end + + def import_closures(zip) + records = read_json_files(zip, "data/closures") + Closure.insert_all(records) if records.any? + end + + def import_card_goldnesses(zip) + records = read_json_files(zip, "data/card_goldnesses") + Card::Goldness.insert_all(records) if records.any? + end + + def import_card_not_nows(zip) + records = read_json_files(zip, "data/card_not_nows") + Card::NotNow.insert_all(records) if records.any? + end + + def import_card_activity_spikes(zip) + records = read_json_files(zip, "data/card_activity_spikes") + Card::ActivitySpike.insert_all(records) if records.any? + end + + def import_watches(zip) + records = read_json_files(zip, "data/watches") + Watch.insert_all(records) if records.any? + end + + def import_pins(zip) + records = read_json_files(zip, "data/pins") + Pin.insert_all(records) if records.any? + end + + def import_reactions(zip) + records = read_json_files(zip, "data/reactions") + Reaction.insert_all(records) if records.any? + end + + def import_mentions(zip) + records = read_json_files(zip, "data/mentions") + Mention.insert_all(records) if records.any? + end + + def import_filters(zip) + records = read_json_files(zip, "data/filters") + Filter.insert_all(records) if records.any? + end + + def import_events(zip) + records = read_json_files(zip, "data/events") + Event.insert_all(records) if records.any? + end + + def import_notifications(zip) + records = read_json_files(zip, "data/notifications") + Notification.insert_all(records) if records.any? + end + + def import_notification_bundles(zip) + records = read_json_files(zip, "data/notification_bundles") + Notification::Bundle.insert_all(records) if records.any? + end + + def import_webhook_deliveries(zip) + records = read_json_files(zip, "data/webhook_deliveries") + Webhook::Delivery.insert_all(records) if records.any? + end + + def import_boards(zip) + records = read_json_files(zip, "data/boards") + Board.insert_all(records) if records.any? + end + + def import_cards(zip) + records = read_json_files(zip, "data/cards") + Card.insert_all(records) if records.any? + end + + def import_comments(zip) + records = read_json_files(zip, "data/comments") + Comment.insert_all(records) if records.any? + end + + def import_active_storage_blobs(zip) + records = read_json_files(zip, "data/active_storage_blobs") + ActiveStorage::Blob.insert_all(records) if records.any? + end + + def import_active_storage_attachments(zip) + records = read_json_files(zip, "data/active_storage_attachments") + ActiveStorage::Attachment.insert_all(records) if records.any? + end + + def import_action_text_rich_texts(zip) + records = read_json_files(zip, "data/action_text_rich_texts").map do |record| + record["body"] = convert_gids_to_sgids(record["body"]) + record + end + ActionText::RichText.insert_all(records) if records.any? + end + + def import_blob_files(zip) + zip.glob("storage/*").each do |entry| + key = File.basename(entry.name) + blob = ActiveStorage::Blob.find_by(key: key) + next unless blob + + blob.upload(entry.get_input_stream) + end + end + + 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_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"]) + record = gid&.find + next unless record + + node["sgid"] = record.attachable_sgid + node.remove_attribute("gid") + end + + fragment.to_html + end +end diff --git a/app/models/account/whole_account_export.rb b/app/models/account/whole_account_export.rb index f8e22b5c77..43865144a7 100644 --- a/app/models/account/whole_account_export.rb +++ b/app/models/account/whole_account_export.rb @@ -3,19 +3,249 @@ class Account::WholeAccountExport < Account::Export 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, "account.json", JSON.pretty_generate(data)) + 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| - add_file_to_zip(zip, "users/#{user.id}.json", user.export_json) + add_file_to_zip(zip, "data/users/#{user.id}.json", JSON.pretty_generate(user.as_json)) + 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 end end end diff --git a/app/views/mailers/import_mailer/completed.html.erb b/app/views/mailers/import_mailer/completed.html.erb new file mode 100644 index 0000000000..914a182d73 --- /dev/null +++ b/app/views/mailers/import_mailer/completed.html.erb @@ -0,0 +1,6 @@ +

Your Fizzy account import is complete

+

Your Fizzy account data has been successfully imported.

+ +

<%= link_to "View your account", root_url %>

+ + diff --git a/app/views/mailers/import_mailer/completed.text.erb b/app/views/mailers/import_mailer/completed.text.erb new file mode 100644 index 0000000000..704df0f6f3 --- /dev/null +++ b/app/views/mailers/import_mailer/completed.text.erb @@ -0,0 +1,3 @@ +Your Fizzy account data has been successfully imported. + +View your account: <%= root_url %> diff --git a/db/migrate/20251223000001_create_account_imports.rb b/db/migrate/20251223000001_create_account_imports.rb new file mode 100644 index 0000000000..5354ac72b0 --- /dev/null +++ b/db/migrate/20251223000001_create_account_imports.rb @@ -0,0 +1,14 @@ +class CreateAccountImports < ActiveRecord::Migration[8.2] + def change + create_table :account_imports, id: :uuid do |t| + t.uuid :identity_id, null: false + t.uuid :account_id + t.string :status, default: "pending", null: false + t.datetime :completed_at + t.timestamps + + t.index :identity_id + t.index :account_id + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 3440a25f44..b62bcfc377 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,10 +19,10 @@ t.string "involvement", default: "access_only", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id", "accessed_at" ], name: "index_accesses_on_account_id_and_accessed_at" - t.index [ "board_id", "user_id" ], name: "index_accesses_on_board_id_and_user_id", unique: true - t.index [ "board_id" ], name: "index_accesses_on_board_id" - t.index [ "user_id" ], name: "index_accesses_on_user_id" + t.index ["account_id", "accessed_at"], name: "index_accesses_on_account_id_and_accessed_at" + t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true + t.index ["board_id"], name: "index_accesses_on_board_id" + t.index ["user_id"], name: "index_accesses_on_user_id" end create_table "account_cancellations", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -41,13 +41,24 @@ t.string "type" t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_account_exports_on_account_id" - t.index [ "user_id" ], name: "index_account_exports_on_user_id" + t.index ["account_id"], name: "index_account_exports_on_account_id" + t.index ["user_id"], name: "index_account_exports_on_user_id" end create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false - t.index [ "value" ], name: "index_account_external_id_sequences_on_value", unique: true + t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true + end + + create_table "account_imports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id" + t.datetime "completed_at" + t.datetime "created_at", null: false + t.uuid "identity_id", null: false + t.string "status", default: "pending", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_imports_on_account_id" + t.index ["identity_id"], name: "index_account_imports_on_identity_id" end create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -57,7 +68,7 @@ t.datetime "updated_at", null: false t.bigint "usage_count", default: 0, null: false t.bigint "usage_limit", default: 10, null: false - t.index [ "account_id", "code" ], name: "index_account_join_codes_on_account_id_and_code", unique: true + t.index ["account_id", "code"], name: "index_account_join_codes_on_account_id_and_code", unique: true end create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -66,7 +77,7 @@ t.bigint "external_account_id" t.string "name", null: false t.datetime "updated_at", null: false - t.index [ "external_account_id" ], name: "index_accounts_on_external_account_id", unique: true + t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -77,8 +88,8 @@ t.uuid "record_id", null: false t.string "record_type", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_action_text_rich_texts_on_account_id" - t.index [ "record_type", "record_id", "name" ], name: "index_action_text_rich_texts_uniqueness", unique: true + t.index ["account_id"], name: "index_action_text_rich_texts_on_account_id" + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -88,9 +99,9 @@ t.string "name", null: false t.uuid "record_id", null: false t.string "record_type", null: false - t.index [ "account_id" ], name: "index_active_storage_attachments_on_account_id" - t.index [ "blob_id" ], name: "index_active_storage_attachments_on_blob_id" - t.index [ "record_type", "record_id", "name", "blob_id" ], name: "index_active_storage_attachments_uniqueness", unique: true + t.index ["account_id"], name: "index_active_storage_attachments_on_account_id" + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -103,30 +114,30 @@ t.string "key", null: false t.text "metadata" t.string "service_name", null: false - t.index [ "account_id" ], name: "index_active_storage_blobs_on_account_id" - t.index [ "key" ], name: "index_active_storage_blobs_on_key", unique: true + t.index ["account_id"], name: "index_active_storage_blobs_on_account_id" + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end create_table "active_storage_variant_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id", null: false t.string "variation_digest", null: false - t.index [ "account_id" ], name: "index_active_storage_variant_records_on_account_id" - t.index [ "blob_id", "variation_digest" ], name: "index_active_storage_variant_records_uniqueness", unique: true + t.index ["account_id"], name: "index_active_storage_variant_records_on_account_id" + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end create_table "assignees_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "assignee_id", null: false t.uuid "filter_id", null: false - t.index [ "assignee_id" ], name: "index_assignees_filters_on_assignee_id" - t.index [ "filter_id" ], name: "index_assignees_filters_on_filter_id" + t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" + t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" end create_table "assigners_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "assigner_id", null: false t.uuid "filter_id", null: false - t.index [ "assigner_id" ], name: "index_assigners_filters_on_assigner_id" - t.index [ "filter_id" ], name: "index_assigners_filters_on_filter_id" + t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" + t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" end create_table "assignments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -136,9 +147,9 @@ t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_assignments_on_account_id" - t.index [ "assignee_id", "card_id" ], name: "index_assignments_on_assignee_id_and_card_id", unique: true - t.index [ "card_id" ], name: "index_assignments_on_card_id" + t.index ["account_id"], name: "index_assignments_on_account_id" + t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true + t.index ["card_id"], name: "index_assignments_on_card_id" end create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -147,8 +158,8 @@ t.datetime "created_at", null: false t.string "key" t.datetime "updated_at", null: false - t.index [ "account_id", "key" ], name: "index_board_publications_on_account_id_and_key" - t.index [ "board_id" ], name: "index_board_publications_on_board_id" + t.index ["account_id", "key"], name: "index_board_publications_on_account_id_and_key" + t.index ["board_id"], name: "index_board_publications_on_board_id" end create_table "boards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -158,15 +169,15 @@ t.uuid "creator_id", null: false t.string "name", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_boards_on_account_id" - t.index [ "creator_id" ], name: "index_boards_on_creator_id" + t.index ["account_id"], name: "index_boards_on_account_id" + t.index ["creator_id"], name: "index_boards_on_creator_id" end create_table "boards_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "board_id", null: false t.uuid "filter_id", null: false - t.index [ "board_id" ], name: "index_boards_filters_on_board_id" - t.index [ "filter_id" ], name: "index_boards_filters_on_filter_id" + t.index ["board_id"], name: "index_boards_filters_on_board_id" + t.index ["filter_id"], name: "index_boards_filters_on_filter_id" end create_table "card_activity_spikes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -174,8 +185,8 @@ t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_card_activity_spikes_on_account_id" - t.index [ "card_id" ], name: "index_card_activity_spikes_on_card_id", unique: true + t.index ["account_id"], name: "index_card_activity_spikes_on_account_id" + t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true end create_table "card_goldnesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -183,8 +194,8 @@ t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_card_goldnesses_on_account_id" - t.index [ "card_id" ], name: "index_card_goldnesses_on_card_id", unique: true + t.index ["account_id"], name: "index_card_goldnesses_on_account_id" + t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true end create_table "card_not_nows", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -193,9 +204,9 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" - t.index [ "account_id" ], name: "index_card_not_nows_on_account_id" - t.index [ "card_id" ], name: "index_card_not_nows_on_card_id", unique: true - t.index [ "user_id" ], name: "index_card_not_nows_on_user_id" + t.index ["account_id"], name: "index_card_not_nows_on_account_id" + t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true + t.index ["user_id"], name: "index_card_not_nows_on_user_id" end create_table "cards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -210,17 +221,17 @@ t.string "status", default: "drafted", null: false t.string "title" t.datetime "updated_at", null: false - t.index [ "account_id", "last_active_at", "status" ], name: "index_cards_on_account_id_and_last_active_at_and_status" - t.index [ "account_id", "number" ], name: "index_cards_on_account_id_and_number", unique: true - t.index [ "board_id" ], name: "index_cards_on_board_id" - t.index [ "column_id" ], name: "index_cards_on_column_id" + t.index ["account_id", "last_active_at", "status"], name: "index_cards_on_account_id_and_last_active_at_and_status" + t.index ["account_id", "number"], name: "index_cards_on_account_id_and_number", unique: true + t.index ["board_id"], name: "index_cards_on_board_id" + t.index ["column_id"], name: "index_cards_on_column_id" end create_table "closers_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "closer_id", null: false t.uuid "filter_id", null: false - t.index [ "closer_id" ], name: "index_closers_filters_on_closer_id" - t.index [ "filter_id" ], name: "index_closers_filters_on_filter_id" + t.index ["closer_id"], name: "index_closers_filters_on_closer_id" + t.index ["filter_id"], name: "index_closers_filters_on_filter_id" end create_table "closures", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -229,10 +240,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" - t.index [ "account_id" ], name: "index_closures_on_account_id" - t.index [ "card_id", "created_at" ], name: "index_closures_on_card_id_and_created_at" - t.index [ "card_id" ], name: "index_closures_on_card_id", unique: true - t.index [ "user_id" ], name: "index_closures_on_user_id" + t.index ["account_id"], name: "index_closures_on_account_id" + t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" + t.index ["card_id"], name: "index_closures_on_card_id", unique: true + t.index ["user_id"], name: "index_closures_on_user_id" end create_table "columns", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -243,9 +254,9 @@ t.string "name", null: false t.integer "position", default: 0, null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_columns_on_account_id" - t.index [ "board_id", "position" ], name: "index_columns_on_board_id_and_position" - t.index [ "board_id" ], name: "index_columns_on_board_id" + t.index ["account_id"], name: "index_columns_on_account_id" + t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" + t.index ["board_id"], name: "index_columns_on_board_id" end create_table "comments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -254,15 +265,15 @@ t.datetime "created_at", null: false t.uuid "creator_id", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_comments_on_account_id" - t.index [ "card_id" ], name: "index_comments_on_card_id" + t.index ["account_id"], name: "index_comments_on_account_id" + t.index ["card_id"], name: "index_comments_on_card_id" end create_table "creators_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "creator_id", null: false t.uuid "filter_id", null: false - t.index [ "creator_id" ], name: "index_creators_filters_on_creator_id" - t.index [ "filter_id" ], name: "index_creators_filters_on_filter_id" + t.index ["creator_id"], name: "index_creators_filters_on_creator_id" + t.index ["filter_id"], name: "index_creators_filters_on_filter_id" end create_table "entropies", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -272,9 +283,9 @@ t.string "container_type", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_entropies_on_account_id" - t.index [ "container_type", "container_id", "auto_postpone_period" ], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" - t.index [ "container_type", "container_id" ], name: "index_entropy_configurations_on_container", unique: true + t.index ["account_id"], name: "index_entropies_on_account_id" + t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" + t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true end create_table "events", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -287,11 +298,11 @@ t.string "eventable_type", null: false t.json "particulars", default: -> { "(json_object())" } t.datetime "updated_at", null: false - t.index [ "account_id", "action" ], name: "index_events_on_account_id_and_action" - t.index [ "board_id", "action", "created_at" ], name: "index_events_on_board_id_and_action_and_created_at" - t.index [ "board_id" ], name: "index_events_on_board_id" - t.index [ "creator_id" ], name: "index_events_on_creator_id" - t.index [ "eventable_type", "eventable_id" ], name: "index_events_on_eventable" + t.index ["account_id", "action"], name: "index_events_on_account_id_and_action" + t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" + t.index ["board_id"], name: "index_events_on_board_id" + t.index ["creator_id"], name: "index_events_on_creator_id" + t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -301,15 +312,15 @@ t.json "fields", default: -> { "(json_object())" }, null: false t.string "params_digest", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_filters_on_account_id" - t.index [ "creator_id", "params_digest" ], name: "index_filters_on_creator_id_and_params_digest", unique: true + t.index ["account_id"], name: "index_filters_on_account_id" + t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true end create_table "filters_tags", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "filter_id", null: false t.uuid "tag_id", null: false - t.index [ "filter_id" ], name: "index_filters_tags_on_filter_id" - t.index [ "tag_id" ], name: "index_filters_tags_on_tag_id" + t.index ["filter_id"], name: "index_filters_tags_on_filter_id" + t.index ["tag_id"], name: "index_filters_tags_on_tag_id" end create_table "identities", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -317,7 +328,7 @@ t.string "email_address", null: false t.boolean "staff", default: false, null: false t.datetime "updated_at", null: false - t.index [ "email_address" ], name: "index_identities_on_email_address", unique: true + t.index ["email_address"], name: "index_identities_on_email_address", unique: true end create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -327,7 +338,7 @@ t.string "permission" t.string "token" t.datetime "updated_at", null: false - t.index [ "identity_id" ], name: "index_access_token_on_identity_id" + t.index ["identity_id"], name: "index_access_token_on_identity_id" end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -337,9 +348,9 @@ t.uuid "identity_id" t.integer "purpose", null: false t.datetime "updated_at", null: false - t.index [ "code" ], name: "index_magic_links_on_code", unique: true - t.index [ "expires_at" ], name: "index_magic_links_on_expires_at" - t.index [ "identity_id" ], name: "index_magic_links_on_identity_id" + t.index ["code"], name: "index_magic_links_on_code", unique: true + t.index ["expires_at"], name: "index_magic_links_on_expires_at" + t.index ["identity_id"], name: "index_magic_links_on_identity_id" end create_table "mentions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -350,10 +361,10 @@ t.uuid "source_id", null: false t.string "source_type", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_mentions_on_account_id" - t.index [ "mentionee_id" ], name: "index_mentions_on_mentionee_id" - t.index [ "mentioner_id" ], name: "index_mentions_on_mentioner_id" - t.index [ "source_type", "source_id" ], name: "index_mentions_on_source" + t.index ["account_id"], name: "index_mentions_on_account_id" + t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" + t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" + t.index ["source_type", "source_id"], name: "index_mentions_on_source" end create_table "notification_bundles", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -364,10 +375,10 @@ t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_notification_bundles_on_account_id" - t.index [ "ends_at", "status" ], name: "index_notification_bundles_on_ends_at_and_status" - t.index [ "user_id", "starts_at", "ends_at" ], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" - t.index [ "user_id", "status" ], name: "index_notification_bundles_on_user_id_and_status" + t.index ["account_id"], name: "index_notification_bundles_on_account_id" + t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" + t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" + t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" end create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -379,11 +390,11 @@ t.string "source_type", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_notifications_on_account_id" - t.index [ "creator_id" ], name: "index_notifications_on_creator_id" - t.index [ "source_type", "source_id" ], name: "index_notifications_on_source" - t.index [ "user_id", "read_at", "created_at" ], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } - t.index [ "user_id" ], name: "index_notifications_on_user_id" + t.index ["account_id"], name: "index_notifications_on_account_id" + t.index ["creator_id"], name: "index_notifications_on_creator_id" + t.index ["source_type", "source_id"], name: "index_notifications_on_source" + t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } + t.index ["user_id"], name: "index_notifications_on_user_id" end create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -392,10 +403,10 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_pins_on_account_id" - t.index [ "card_id", "user_id" ], name: "index_pins_on_card_id_and_user_id", unique: true - t.index [ "card_id" ], name: "index_pins_on_card_id" - t.index [ "user_id" ], name: "index_pins_on_user_id" + t.index ["account_id"], name: "index_pins_on_account_id" + t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true + t.index ["card_id"], name: "index_pins_on_card_id" + t.index ["user_id"], name: "index_pins_on_user_id" end create_table "push_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -407,8 +418,8 @@ t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_push_subscriptions_on_account_id" - t.index [ "user_id", "endpoint" ], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true, length: { endpoint: 255 } + t.index ["account_id"], name: "index_push_subscriptions_on_account_id" + t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true, length: { endpoint: 255 } end create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -418,9 +429,9 @@ t.datetime "created_at", null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_reactions_on_account_id" - t.index [ "comment_id" ], name: "index_reactions_on_comment_id" - t.index [ "reacter_id" ], name: "index_reactions_on_reacter_id" + t.index ["account_id"], name: "index_reactions_on_account_id" + t.index ["comment_id"], name: "index_reactions_on_comment_id" + t.index ["reacter_id"], name: "index_reactions_on_reacter_id" end create_table "search_queries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -429,10 +440,10 @@ t.string "terms", limit: 2000, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_search_queries_on_account_id" - t.index [ "user_id", "terms" ], name: "index_search_queries_on_user_id_and_terms", length: { terms: 255 } - t.index [ "user_id", "updated_at" ], name: "index_search_queries_on_user_id_and_updated_at", unique: true - t.index [ "user_id" ], name: "index_search_queries_on_user_id" + t.index ["account_id"], name: "index_search_queries_on_account_id" + t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms", length: { terms: 255 } + t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true + t.index ["user_id"], name: "index_search_queries_on_user_id" end create_table "search_records_0", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -445,9 +456,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_0_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_0_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_0_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_0_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_0_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_0_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_1", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -460,9 +471,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_1_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_1_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_1_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_1_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_1_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_1_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_10", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -475,9 +486,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_10_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_10_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_10_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_10_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_10_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_10_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_11", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -490,9 +501,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_11_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_11_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_11_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_11_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_11_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_11_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_12", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -505,9 +516,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_12_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_12_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_12_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_12_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_12_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_12_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_13", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -520,9 +531,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_13_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_13_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_13_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_13_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_13_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_13_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_14", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -535,9 +546,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_14_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_14_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_14_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_14_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_14_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_14_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_15", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -550,9 +561,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_15_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_15_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_15_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_15_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_15_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_15_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_2", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -565,9 +576,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_2_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_2_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_2_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_2_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_2_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_2_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_3", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -580,9 +591,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_3_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_3_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_3_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_3_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_3_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_3_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_4", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -595,9 +606,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_4_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_4_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_4_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_4_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_4_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_4_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_5", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -610,9 +621,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_5_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_5_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_5_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_5_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_5_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_5_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_6", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -625,9 +636,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_6_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_6_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_6_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_6_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_6_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_6_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_7", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -640,9 +651,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_7_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_7_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_7_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_7_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_7_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_7_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_8", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -655,9 +666,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_8_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_8_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_8_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_8_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_8_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_8_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_9", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -670,9 +681,9 @@ t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" - t.index [ "account_id" ], name: "index_search_records_9_on_account_id" - t.index [ "account_key", "content", "title" ], name: "index_search_records_9_on_account_key_and_content_and_title", type: :fulltext - t.index [ "searchable_type", "searchable_id" ], name: "index_search_records_9_on_searchable_type_and_searchable_id", unique: true + t.index ["account_id"], name: "index_search_records_9_on_account_id" + t.index ["account_key", "content", "title"], name: "index_search_records_9_on_account_key_and_content_and_title", type: :fulltext + t.index ["searchable_type", "searchable_id"], name: "index_search_records_9_on_searchable_type_and_searchable_id", unique: true end create_table "sessions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -681,7 +692,7 @@ t.string "ip_address" t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 - t.index [ "identity_id" ], name: "index_sessions_on_identity_id" + t.index ["identity_id"], name: "index_sessions_on_identity_id" end create_table "steps", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -691,9 +702,9 @@ t.text "content", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_steps_on_account_id" - t.index [ "card_id", "completed" ], name: "index_steps_on_card_id_and_completed" - t.index [ "card_id" ], name: "index_steps_on_card_id" + t.index ["account_id"], name: "index_steps_on_account_id" + t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" + t.index ["card_id"], name: "index_steps_on_card_id" end create_table "storage_entries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -707,12 +718,12 @@ t.string "recordable_type" t.string "request_id" t.uuid "user_id" - t.index [ "account_id" ], name: "index_storage_entries_on_account_id" - t.index [ "blob_id" ], name: "index_storage_entries_on_blob_id" - t.index [ "board_id" ], name: "index_storage_entries_on_board_id" - t.index [ "recordable_type", "recordable_id" ], name: "index_storage_entries_on_recordable" - t.index [ "request_id" ], name: "index_storage_entries_on_request_id" - t.index [ "user_id" ], name: "index_storage_entries_on_user_id" + t.index ["account_id"], name: "index_storage_entries_on_account_id" + t.index ["blob_id"], name: "index_storage_entries_on_blob_id" + t.index ["board_id"], name: "index_storage_entries_on_board_id" + t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable" + t.index ["request_id"], name: "index_storage_entries_on_request_id" + t.index ["user_id"], name: "index_storage_entries_on_user_id" end create_table "storage_totals", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -722,7 +733,7 @@ t.uuid "owner_id", null: false t.string "owner_type", null: false t.datetime "updated_at", null: false - t.index [ "owner_type", "owner_id" ], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true + t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true end create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -731,9 +742,9 @@ t.datetime "created_at", null: false t.uuid "tag_id", null: false t.datetime "updated_at", null: false - t.index [ "account_id" ], name: "index_taggings_on_account_id" - t.index [ "card_id", "tag_id" ], name: "index_taggings_on_card_id_and_tag_id", unique: true - t.index [ "tag_id" ], name: "index_taggings_on_tag_id" + t.index ["account_id"], name: "index_taggings_on_account_id" + t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true + t.index ["tag_id"], name: "index_taggings_on_tag_id" end create_table "tags", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -741,7 +752,7 @@ t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false - t.index [ "account_id", "title" ], name: "index_tags_on_account_id_and_title", unique: true + t.index ["account_id", "title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "user_settings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -751,9 +762,9 @@ t.string "timezone_name" t.datetime "updated_at", null: false t.uuid "user_id", null: false - t.index [ "account_id" ], name: "index_user_settings_on_account_id" - t.index [ "user_id", "bundle_email_frequency" ], name: "index_user_settings_on_user_id_and_bundle_email_frequency" - t.index [ "user_id" ], name: "index_user_settings_on_user_id" + t.index ["account_id"], name: "index_user_settings_on_account_id" + t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" + t.index ["user_id"], name: "index_user_settings_on_user_id" end create_table "users", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -765,9 +776,9 @@ t.string "role", default: "member", null: false t.datetime "updated_at", null: false t.datetime "verified_at" - t.index [ "account_id", "identity_id" ], name: "index_users_on_account_id_and_identity_id", unique: true - t.index [ "account_id", "role" ], name: "index_users_on_account_id_and_role" - t.index [ "identity_id" ], name: "index_users_on_identity_id" + t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true + t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" + t.index ["identity_id"], name: "index_users_on_identity_id" end create_table "watches", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -777,10 +788,10 @@ t.datetime "updated_at", null: false t.uuid "user_id", null: false t.boolean "watching", default: true, null: false - t.index [ "account_id" ], name: "index_watches_on_account_id" - t.index [ "card_id" ], name: "index_watches_on_card_id" - t.index [ "user_id", "card_id" ], name: "index_watches_on_user_id_and_card_id" - t.index [ "user_id" ], name: "index_watches_on_user_id" + t.index ["account_id"], name: "index_watches_on_account_id" + t.index ["card_id"], name: "index_watches_on_card_id" + t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" + t.index ["user_id"], name: "index_watches_on_user_id" end create_table "webhook_delinquency_trackers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -790,8 +801,8 @@ t.datetime "first_failure_at" t.datetime "updated_at", null: false t.uuid "webhook_id", null: false - t.index [ "account_id" ], name: "index_webhook_delinquency_trackers_on_account_id" - t.index [ "webhook_id" ], name: "index_webhook_delinquency_trackers_on_webhook_id" + t.index ["account_id"], name: "index_webhook_delinquency_trackers_on_account_id" + t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" end create_table "webhook_deliveries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -803,9 +814,9 @@ t.string "state", null: false t.datetime "updated_at", null: false t.uuid "webhook_id", null: false - t.index [ "account_id" ], name: "index_webhook_deliveries_on_account_id" - t.index [ "event_id" ], name: "index_webhook_deliveries_on_event_id" - t.index [ "webhook_id" ], name: "index_webhook_deliveries_on_webhook_id" + t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" + t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" + t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" end create_table "webhooks", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -818,7 +829,7 @@ t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false - t.index [ "account_id" ], name: "index_webhooks_on_account_id" - t.index [ "board_id", "subscribed_actions" ], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } + t.index ["account_id"], name: "index_webhooks_on_account_id" + t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f998659..e37b168bf3 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -38,6 +38,7 @@ t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", limit: 255, default: "pending", null: false + t.string "type" t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_account_exports_on_account_id" @@ -49,6 +50,17 @@ t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true end + create_table "account_imports", id: :uuid, force: :cascade do |t| + t.uuid "account_id" + t.datetime "completed_at" + t.datetime "created_at", null: false + t.uuid "identity_id", null: false + t.string "status", limit: 255, default: "pending", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_imports_on_account_id" + t.index ["identity_id"], name: "index_account_imports_on_identity_id" + end + create_table "account_join_codes", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.string "code", limit: 255, null: false From bef2016ff259d55eb1d738a20af90e92feb442b8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 9 Jan 2026 15:15:37 +0100 Subject: [PATCH 03/25] Implement basic imports --- app/controllers/imports_controller.rb | 38 + app/jobs/import_account_data_job.rb | 2 +- app/mailers/import_mailer.rb | 9 +- app/models/account.rb | 1 + app/models/account/import.rb | 746 ++++++++++++++---- app/models/account/import/id_mapper.rb | 60 ++ app/models/account/whole_account_export.rb | 5 +- app/models/identity.rb | 1 + app/views/imports/new.html.erb | 21 + app/views/imports/show.html.erb | 23 + .../mailers/import_mailer/failed.html.erb | 6 + .../mailers/import_mailer/failed.text.erb | 7 + app/views/sessions/menus/show.html.erb | 5 +- config/routes.rb | 3 + test/models/account/import_test.rb | 255 ++++++ 15 files changed, 1039 insertions(+), 143 deletions(-) create mode 100644 app/controllers/imports_controller.rb create mode 100644 app/models/account/import/id_mapper.rb create mode 100644 app/views/imports/new.html.erb create mode 100644 app/views/imports/show.html.erb create mode 100644 app/views/mailers/import_mailer/failed.html.erb create mode 100644 app/views/mailers/import_mailer/failed.text.erb create mode 100644 test/models/account/import_test.rb diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb new file mode 100644 index 0000000000..76cb41c830 --- /dev/null +++ b/app/controllers/imports_controller.rb @@ -0,0 +1,38 @@ +class ImportsController < ApplicationController + disallow_account_scope + + layout "public" + + def new + end + + def create + account = create_account_for_import + + Current.set(account: account) do + @import = account.imports.create!(identity: Current.identity, file: params[:file]) + end + + @import.perform_later + redirect_to import_path(@import) + end + + def show + @import = Current.identity.imports.find(params[:id]) + end + + private + def create_account_for_import + Account.create_with_owner( + account: { name: account_name_from_zip }, + owner: { name: Current.identity.email_address.split("@").first, identity: Current.identity } + ) + end + + def account_name_from_zip + Zip::File.open(params[:file].tempfile.path) do |zip| + entry = zip.find_entry("data/account.json") + JSON.parse(entry.get_input_stream.read)["name"] + end + end +end diff --git a/app/jobs/import_account_data_job.rb b/app/jobs/import_account_data_job.rb index 8caf731f98..5aa29dc9e9 100644 --- a/app/jobs/import_account_data_job.rb +++ b/app/jobs/import_account_data_job.rb @@ -2,6 +2,6 @@ class ImportAccountDataJob < ApplicationJob queue_as :backend def perform(import) - import.build + import.perform end end diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb index bb1c0c4c29..5040a224e0 100644 --- a/app/mailers/import_mailer.rb +++ b/app/mailers/import_mailer.rb @@ -1,8 +1,9 @@ class ImportMailer < ApplicationMailer - def completed(import) - @import = import - @identity = import.identity + def completed(identity) + mail to: identity.email_address, subject: "Your Fizzy account import is complete" + end - mail to: @identity.email_address, subject: "Your Fizzy account import is complete" + def failed(identity) + mail to: identity.email_address, subject: "Your Fizzy account import failed" end end diff --git a/app/models/account.rb b/app/models/account.rb index 34b63b6889..cd19eba5eb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -9,6 +9,7 @@ class Account < ApplicationRecord has_many :tags, dependent: :destroy has_many :columns, dependent: :destroy has_many :exports, class_name: "Account::Export", dependent: :destroy + has_many :imports, class_name: "Account::Import", dependent: :destroy before_create :assign_external_account_id after_create :create_join_code diff --git a/app/models/account/import.rb b/app/models/account/import.rb index f51e984fcf..2357fbd5f9 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -1,271 +1,745 @@ class Account::Import < ApplicationRecord - belongs_to :account, required: false + class IntegrityError < StandardError; end + + belongs_to :account belongs_to :identity has_one_attached :file enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending - def build_later + def perform_later ImportAccountDataJob.perform_later(self) end - def build + def perform processing! - ApplicationRecord.transaction do - populate_from_zip + 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 + end end mark_completed - ImportMailer.completed(self).deliver_later rescue => e - update!(status: :failed) + mark_failed raise end - def mark_completed - update!(status: :completed, completed_at: Time.current) + def owner_user + account.users.find_by(identity: identity) end private - def populate_from_zip - ApplicationRecord.transaction do - Zip::File.open_buffer(file.download) do |zip| - import_account(zip) - import_users(zip) - import_tags(zip) - import_entropies(zip) - import_columns(zip) - import_board_publications(zip) - import_webhooks(zip) - import_webhook_delinquency_trackers(zip) - import_accesses(zip) - import_assignments(zip) - import_taggings(zip) - import_steps(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) - import_events(zip) - import_notifications(zip) - import_notification_bundles(zip) - import_webhook_deliveries(zip) - - import_boards(zip) - import_cards(zip) - import_comments(zip) - - import_active_storage_blobs(zip) - import_active_storage_attachments(zip) - import_action_text_rich_texts(zip) - - import_blob_files(zip) - end - 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 import_account(zip) + def load_account_data(zip) entry = zip.find_entry("data/account.json") - raise "Missing account.json in export" unless entry + 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 - data = JSON.parse(entry.get_input_stream.read) - join_code_data = data.delete("join_code") + # Phase 2: Users + + def import_users(zip) + users_data = read_json_files(zip, "data/users") - Account.insert(data) - update!(account_id: data["id"]) + # 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 - Account::JoinCode.insert(join_code_data) if join_code_data + # Import non-system users + users_data.reject { |u| u["role"] == "system" }.each do |data| + import_user(data) + end end - def import_users(zip) - records = read_json_files(zip, "data/users") - User.insert_all(records) if records.any? + 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") - Tag.insert_all(records) if records.any? + 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_entropies(zip) - records = read_json_files(zip, "data/entropies") - Entropy.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/columns") - Column.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/board_publications") - Board::Publication.insert_all(records) if records.any? + 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 - def import_webhooks(zip) - records = read_json_files(zip, "data/webhooks") - Webhook.insert_all(records) if records.any? + # 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_webhook_delinquency_trackers(zip) - records = read_json_files(zip, "data/webhook_delinquency_trackers") - Webhook::DelinquencyTracker.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/accesses") - Access.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/assignments") - Assignment.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/taggings") - Tagging.insert_all(records) if records.any? - end - - def import_steps(zip) - records = read_json_files(zip, "data/steps") - Step.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/closures") - Closure.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/card_goldnesses") - Card::Goldness.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/card_not_nows") - Card::NotNow.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/card_activity_spikes") - Card::ActivitySpike.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/watches") - Watch.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/pins") - Pin.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/reactions") - Reaction.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/mentions") - Mention.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/filters") - Filter.insert_all(records) if records.any? + 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 - def import_events(zip) - records = read_json_files(zip, "data/events") - Event.insert_all(records) if records.any? - end + # Phase 6: Webhooks - def import_notifications(zip) - records = read_json_files(zip, "data/notifications") - Notification.insert_all(records) if records.any? + 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_notification_bundles(zip) - records = read_json_files(zip, "data/notification_bundles") - Notification::Bundle.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/webhook_deliveries") - Webhook::Delivery.insert_all(records) if records.any? + 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 - def import_boards(zip) - records = read_json_files(zip, "data/boards") - Board.insert_all(records) if records.any? + # 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_cards(zip) - records = read_json_files(zip, "data/cards") - Card.insert_all(records) if records.any? + 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_comments(zip) - records = read_json_files(zip, "data/comments") - Comment.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/active_storage_blobs") - ActiveStorage::Blob.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/active_storage_attachments") - ActiveStorage::Attachment.insert_all(records) if records.any? + 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) - records = read_json_files(zip, "data/action_text_rich_texts").map do |record| - record["body"] = convert_gids_to_sgids(record["body"]) - record + 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 - ActionText::RichText.insert_all(records) if records.any? end def import_blob_files(zip) zip.glob("storage/*").each do |entry| key = File.basename(entry.name) - blob = ActiveStorage::Blob.find_by(key: key) + 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_to_sgids(html) + 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"]) - record = gid&.find - next unless record + 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 + end end diff --git a/app/models/account/import/id_mapper.rb b/app/models/account/import/id_mapper.rb new file mode 100644 index 0000000000..63e19afd08 --- /dev/null +++ b/app/models/account/import/id_mapper.rb @@ -0,0 +1,60 @@ +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 diff --git a/app/models/account/whole_account_export.rb b/app/models/account/whole_account_export.rb index 43865144a7..8551463e1a 100644 --- a/app/models/account/whole_account_export.rb +++ b/app/models/account/whole_account_export.rb @@ -45,7 +45,10 @@ def export_account(zip) def export_users(zip) account.users.find_each do |user| - add_file_to_zip(zip, "data/users/#{user.id}.json", JSON.pretty_generate(user.as_json)) + 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 diff --git a/app/models/identity.rb b/app/models/identity.rb index 7495e37c3f..33955a8b2b 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -2,6 +2,7 @@ class Identity < ApplicationRecord include Joinable, Transferable has_many :access_tokens, dependent: :destroy + has_many :imports, class_name: "Account::Import", dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb new file mode 100644 index 0000000000..138be6758b --- /dev/null +++ b/app/views/imports/new.html.erb @@ -0,0 +1,21 @@ +<% @page_title = "Import an account" %> + +
+

Import an account

+ + <%= form_with url: imports_path, class: "flex flex-column gap", data: { controller: "form" }, multipart: true do |form| %> +
+ <%= form.file_field :file, accept: ".zip", required: true, class: "input" %> +

Upload the .zip file from your Fizzy export.

+
+ + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb new file mode 100644 index 0000000000..acc2c187ec --- /dev/null +++ b/app/views/imports/show.html.erb @@ -0,0 +1,23 @@ +<% @page_title = "Import status" %> + +
+

Import status

+ + <% case @import.status %> + <% when "pending", "processing" %> +

Your import is in progress. This may take a while for large accounts.

+

This page will refresh automatically.

+ + <% when "completed" %> +

Your import has completed successfully!

+ <%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %> + <% when "failed" %> +

Your import failed.

+

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.

+ <%= link_to "Try again", new_import_path, class: "btn btn--plain center txt-medium" %> + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/mailers/import_mailer/failed.html.erb b/app/views/mailers/import_mailer/failed.html.erb new file mode 100644 index 0000000000..cd3999957e --- /dev/null +++ b/app/views/mailers/import_mailer/failed.html.erb @@ -0,0 +1,6 @@ +

Your Fizzy account import failed

+

Unfortunately, we were unable to import your Fizzy account data.

+ +

This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or contact support if the problem persists.

+ + diff --git a/app/views/mailers/import_mailer/failed.text.erb b/app/views/mailers/import_mailer/failed.text.erb new file mode 100644 index 0000000000..1a0f0c1713 --- /dev/null +++ b/app/views/mailers/import_mailer/failed.text.erb @@ -0,0 +1,7 @@ +Your Fizzy account import failed + +Unfortunately, we were unable to import your Fizzy account data. + +This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or contact support if the problem persists. + +Need help? Send us an email at support@fizzy.do diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 09383b72aa..84dd43c87e 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -24,10 +24,13 @@

You don’t have any Fizzy accounts.

<% end %> -
+
<%= link_to new_signup_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %> Sign up for a new Fizzy account <% end %> + <%= link_to new_import_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %> + Import an exported account + <% end %>
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 97e34290cb..e070f823b3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,8 @@ resources :exports, only: [ :create, :show ] end + resources :imports, only: %i[ new create show ] + resources :users do scope module: :users do resource :avatar @@ -165,6 +167,7 @@ resource :landing + namespace :my do resource :identity, only: :show resources :access_tokens diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb new file mode 100644 index 0000000000..6a21d8343c --- /dev/null +++ b/test/models/account/import_test.rb @@ -0,0 +1,255 @@ +require "test_helper" + +class Account::ImportTest < ActiveSupport::TestCase + setup do + @identity = identities(:david) + @source_account = accounts("37s") + end + + test "perform_later enqueues ImportAccountDataJob" 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")) + + assert_raises(StandardError) do + import.perform + end + + assert import.failed? + end + + test "perform imports account name from export" do + target_account = create_target_account + import = create_import_for_account(target_account) + + import.perform + + assert_equal @source_account.name, target_account.reload.name + end + + test "perform maps system user" do + target_account = create_target_account + import = create_import_for_account(target_account) + + import.perform + + # The target account should still have exactly one system user + assert_equal 1, target_account.users.where(role: :system).count + end + + test "perform imports users with identity matching" 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" + Account::JoinCode.where(code: export_code).delete_all + + # 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 + + assert_equal export_code, target_account.join_code.reload.code + end + + test "perform keeps existing join code on collision" do + target_account = create_target_account + original_code = target_account.join_code.code + + # 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 + end + end + + test "perform 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!( + 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 + ) + + import = create_import_for_account(target_account, card_id: colliding_id) + + assert_raises(ActiveRecord::RecordNotUnique) do + import.perform + end + + # Import should be marked as failed + assert import.reload.failed? + end + + test "perform sends completion email and schedules cleanup 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 + + 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") + account.users.create!(role: :system, name: "System") + account.users.create!( + role: :owner, + name: "Importer", + identity: @identity, + verified_at: Time.current + ) + account + 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") + 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") + import + end + + def create_import_with_foreign_account_reference(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" + ) + 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)) } + + # 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 + end + + tempfile.rewind + StringIO.new(tempfile.read) + end + end +end From 6e1cd2f9c78a435416877f76d0d73b504815e845 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 9 Jan 2026 16:15:32 +0100 Subject: [PATCH 04/25] Convert Account::SingleUserExport to User::DataExport --- app/controllers/account/exports_controller.rb | 7 +- .../users/data_exports_controller.rb | 33 ++ ...account_data_job.rb => export_data_job.rb} | 2 +- app/mailers/export_mailer.rb | 11 + app/models/account/export.rb | 281 ++++++++++++++---- app/models/account/whole_account_export.rb | 254 ---------------- app/models/export.rb | 79 +++++ app/models/user.rb | 2 +- .../data_export.rb} | 2 +- app/views/account/settings/_export.html.erb | 10 +- app/views/account/settings/show.html.erb | 2 +- .../mailers/export_mailer/completed.html.erb | 2 +- .../mailers/export_mailer/completed.text.erb | 2 +- app/views/users/_data_export.html.erb | 19 ++ app/views/users/data_exports/show.html.erb | 29 ++ app/views/users/show.html.erb | 1 + config/recurring.yml | 2 +- config/routes.rb | 2 + ...51212164543_add_type_to_account_exports.rb | 5 - ...00001_rename_account_exports_to_exports.rb | 7 + ... 20251223000002_create_account_imports.rb} | 0 db/queue_schema.rb | 1 - db/schema.rb | 25 +- .../accounts/exports_controller_test.rb | 32 +- .../users/data_exports_controller_test.rb | 87 ++++++ test/fixtures/account/exports.yml | 12 - test/fixtures/exports.yml | 29 ++ test/mailers/export_mailer_test.rb | 15 +- test/models/account/export_test.rb | 32 +- .../account/whole_account_export_test.rb | 7 - .../data_export_test.rb} | 18 +- 31 files changed, 629 insertions(+), 381 deletions(-) create mode 100644 app/controllers/users/data_exports_controller.rb rename app/jobs/{export_account_data_job.rb => export_data_job.rb} (72%) delete mode 100644 app/models/account/whole_account_export.rb create mode 100644 app/models/export.rb rename app/models/{account/single_user_export.rb => user/data_export.rb} (94%) create mode 100644 app/views/users/_data_export.html.erb create mode 100644 app/views/users/data_exports/show.html.erb delete mode 100644 db/migrate/20251212164543_add_type_to_account_exports.rb create mode 100644 db/migrate/20251223000001_rename_account_exports_to_exports.rb rename db/migrate/{20251223000001_create_account_imports.rb => 20251223000002_create_account_imports.rb} (100%) create mode 100644 test/controllers/users/data_exports_controller_test.rb delete mode 100644 test/fixtures/account/exports.yml create mode 100644 test/fixtures/exports.yml delete mode 100644 test/models/account/whole_account_export_test.rb rename test/models/{account/single_user_export_test.rb => user/data_export_test.rb} (70%) diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb index ab40286af7..dbe0a11c10 100644 --- a/app/controllers/account/exports_controller.rb +++ b/app/controllers/account/exports_controller.rb @@ -1,4 +1,5 @@ class Account::ExportsController < ApplicationController + before_action :ensure_admin_or_owner before_action :ensure_export_limit_not_exceeded, only: :create before_action :set_export, only: :show @@ -13,8 +14,12 @@ def create end private + def ensure_admin_or_owner + head :forbidden unless Current.user.admin? || Current.user.owner? + end + def ensure_export_limit_not_exceeded - head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT + head :too_many_requests if Current.account.exports.current.count >= CURRENT_EXPORT_LIMIT end def set_export diff --git a/app/controllers/users/data_exports_controller.rb b/app/controllers/users/data_exports_controller.rb new file mode 100644 index 0000000000..60df1e3265 --- /dev/null +++ b/app/controllers/users/data_exports_controller.rb @@ -0,0 +1,33 @@ +class Users::DataExportsController < ApplicationController + before_action :set_user + before_action :ensure_current_user + before_action :ensure_export_limit_not_exceeded, only: :create + before_action :set_export, only: :show + + CURRENT_EXPORT_LIMIT = 10 + + def show + end + + def create + @user.data_exports.create!(account: Current.account).build_later + redirect_to @user, notice: "Export started. You'll receive an email when it's ready." + end + + private + def set_user + @user = Current.account.users.find(params[:user_id]) + end + + def ensure_current_user + head :forbidden unless @user == Current.user + end + + def ensure_export_limit_not_exceeded + head :too_many_requests if @user.data_exports.current.count >= CURRENT_EXPORT_LIMIT + end + + def set_export + @export = @user.data_exports.completed.find_by(id: params[:id]) + end +end diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_data_job.rb similarity index 72% rename from app/jobs/export_account_data_job.rb rename to app/jobs/export_data_job.rb index c0286c7362..e342ea34b3 100644 --- a/app/jobs/export_account_data_job.rb +++ b/app/jobs/export_data_job.rb @@ -1,4 +1,4 @@ -class ExportAccountDataJob < ApplicationJob +class ExportDataJob < ApplicationJob queue_as :backend discard_on ActiveJob::DeserializationError diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb index 5385aeed6f..045d954d0e 100644 --- a/app/mailers/export_mailer.rb +++ b/app/mailers/export_mailer.rb @@ -1,8 +1,19 @@ class ExportMailer < ApplicationMailer + helper_method :export_download_url + def completed(export) @export = export @user = export.user mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download" end + + private + def export_download_url(export) + if export.is_a?(User::DataExport) + user_data_export_url(export.user, export) + else + account_export_url(export) + end + end end diff --git a/app/models/account/export.rb b/app/models/account/export.rb index e58f62fce0..e8ab0dd1f8 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -1,79 +1,254 @@ -class Account::Export < ApplicationRecord - belongs_to :account - belongs_to :user +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 - has_one_attached :file + 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 - enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + 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 - scope :current, -> { where(created_at: 24.hours.ago..) } - scope :expired, -> { where(completed_at: ...24.hours.ago) } + 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 self.cleanup - expired.destroy_all - 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 build_later - ExportAccountDataJob.perform_later(self) - 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 build - processing! + 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 - Current.set(account: account) do - with_url_options do - zipfile = generate_zip { |zip| populate_zip(zip) } + 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 - file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip" - mark_completed + 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 - ExportMailer.completed(self).deliver_later - ensure - zipfile&.close - zipfile&.unlink + 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 - rescue => e - update!(status: :failed) - raise - end - def mark_completed - update!(status: :completed, completed_at: Time.current) - 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 accessible_to?(accessor) - accessor == user - 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 - private - def with_url_options - ActiveStorage::Current.set(url_options: { host: "localhost" }) { yield } + 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 populate_zip(zip) - raise NotImplementedError, "Subclasses must implement populate_zip" + 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 generate_zip - raise ArgumentError, "Block is required" unless block_given? + 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 - Tempfile.new([ "export", ".zip" ]).tap do |tempfile| - Zip::File.open(tempfile.path, create: true) do |zip| - yield zip - 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 add_file_to_zip(zip, path, content = nil, **options) - zip.get_output_stream(path, **options) do |f| - if block_given? - yield f - elsif content - f.write(content) - else - raise ArgumentError, "Either content or a block must be provided" + 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 end end end diff --git a/app/models/account/whole_account_export.rb b/app/models/account/whole_account_export.rb deleted file mode 100644 index 8551463e1a..0000000000 --- a/app/models/account/whole_account_export.rb +++ /dev/null @@ -1,254 +0,0 @@ -class Account::WholeAccountExport < Account::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 - end - end -end diff --git a/app/models/export.rb b/app/models/export.rb new file mode 100644 index 0000000000..871d6d8b4e --- /dev/null +++ b/app/models/export.rb @@ -0,0 +1,79 @@ +class Export < ApplicationRecord + belongs_to :account + belongs_to :user + + has_one_attached :file + + enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + + scope :current, -> { where(created_at: 24.hours.ago..) } + scope :expired, -> { where(completed_at: ...24.hours.ago) } + + def self.cleanup + expired.destroy_all + end + + def build_later + ExportDataJob.perform_later(self) + end + + def build + processing! + + Current.set(account: account) do + with_url_options 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 + + ExportMailer.completed(self).deliver_later + ensure + zipfile&.close + zipfile&.unlink + end + end + rescue => e + update!(status: :failed) + raise + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + def accessible_to?(accessor) + accessor == user + end + + private + def with_url_options + ActiveStorage::Current.set(url_options: { host: "localhost" }) { yield } + end + + def populate_zip(zip) + raise NotImplementedError, "Subclasses must implement populate_zip" + end + + def generate_zip + raise ArgumentError, "Block is required" unless block_given? + + Tempfile.new([ "export", ".zip" ]).tap do |tempfile| + Zip::File.open(tempfile.path, create: true) do |zip| + yield zip + end + end + end + + def add_file_to_zip(zip, path, content = nil, **options) + zip.get_output_stream(path, **options) do |f| + if block_given? + yield f + elsif content + f.write(content) + else + raise ArgumentError, "Either content or a block must be provided" + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e16d70670a..842f7f6c80 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,7 @@ class User < ApplicationRecord has_many :closures, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card - has_many :exports, class_name: "Account::Export", dependent: :destroy + has_many :data_exports, class_name: "User::DataExport", dependent: :destroy def deactivate transaction do diff --git a/app/models/account/single_user_export.rb b/app/models/user/data_export.rb similarity index 94% rename from app/models/account/single_user_export.rb rename to app/models/user/data_export.rb index d5fbcfb7bc..4eb7b9e775 100644 --- a/app/models/account/single_user_export.rb +++ b/app/models/user/data_export.rb @@ -1,4 +1,4 @@ -class Account::SingleUserExport < Account::Export +class User::DataExport < Export private def populate_zip(zip) exportable_cards.find_each do |card| diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb index d44bb4b744..3d19469962 100644 --- a/app/views/account/settings/_export.html.erb +++ b/app/views/account/settings/_export.html.erb @@ -1,15 +1,15 @@
-

Export your data

-

Download an archive of your Fizzy data.

+

Export account data

+

Download a complete archive of all account data.

-

Export your account data

-

This will kick off a request to generate a ZIP archive of all the data in boards you have access to.

-

When the file is ready, we’ll email you a link to download it. The link will expire after 24 hours.

+

Export all account data

+

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

+

When the file is ready, we'll email you a link to download it. The link will expire after 24 hours.

<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 74e2744148..b2739807cc 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -17,7 +17,7 @@
<%= render "account/settings/entropy", account: @account %> - <%= render "account/settings/export" %> + <%= render "account/settings/export" if Current.user.admin? || Current.user.owner? %> <%= render "account/settings/cancellation" %>
diff --git a/app/views/mailers/export_mailer/completed.html.erb b/app/views/mailers/export_mailer/completed.html.erb index c0d458fc27..cadf87dca7 100644 --- a/app/views/mailers/export_mailer/completed.html.erb +++ b/app/views/mailers/export_mailer/completed.html.erb @@ -1,6 +1,6 @@

Download your Fizzy data

Your Fizzy data export has finished processing and is ready to download.

-

<%= link_to "Download your data", account_export_url(@export) %>

+

<%= link_to "Download your data", export_download_url(@export) %>

diff --git a/app/views/mailers/export_mailer/completed.text.erb b/app/views/mailers/export_mailer/completed.text.erb index e5c274a9af..75f7491839 100644 --- a/app/views/mailers/export_mailer/completed.text.erb +++ b/app/views/mailers/export_mailer/completed.text.erb @@ -1,3 +1,3 @@ Your Fizzy data export has finished processing and is ready to download. -Download your data: <%= account_export_url(@export) %> +Download your data: <%= export_download_url(@export) %> diff --git a/app/views/users/_data_export.html.erb b/app/views/users/_data_export.html.erb new file mode 100644 index 0000000000..a6cd3bde80 --- /dev/null +++ b/app/views/users/_data_export.html.erb @@ -0,0 +1,19 @@ +
+

Export your data

+

Download an archive of your Fizzy data.

+
+ +
+ + + +

Export your data

+

This will generate a ZIP archive of all cards you have access to.

+

When ready, we'll email you a download link (expires in 24 hours).

+ +
+ <%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> + +
+
+
diff --git a/app/views/users/data_exports/show.html.erb b/app/views/users/data_exports/show.html.erb new file mode 100644 index 0000000000..2a1d638056 --- /dev/null +++ b/app/views/users/data_exports/show.html.erb @@ -0,0 +1,29 @@ +<% if @export.present? %> + <% @page_title = "Download Export" %> +<% else %> + <% @page_title = "Download Expired" %> +<% end %> + +<% content_for :header do %> +
+ <%= back_link_to @user.name, user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+<% end %> + +
+

<%= @page_title %>

+ + <% if @export.present? %> +

Your export is ready. The download should start automatically.

+ + <%= link_to rails_blob_path(@export.file, disposition: "attachment"), + id: "download-link", + class: "btn btn--link", + data: { turbo: false, controller: "auto-click" } do %> + Download your data + <% end %> + <% else %> +

That download link has expired. You'll need to <%= link_to "request a new export", user_path(@user), class: "txt-lnk" %>.

+ <% end %> + +
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 771a8e924c..6a5cc2c022 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -53,6 +53,7 @@ <%= render "users/theme" %> <%= render "users/transfer", user: @user %> <%= render "users/access_tokens" %> + <%= render "users/data_export" %> <% end %>
diff --git a/config/recurring.yml b/config/recurring.yml index 8aba572ca4..981511d451 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -25,7 +25,7 @@ production: &production command: "MagicLink.cleanup" schedule: every 4 hours cleanup_exports: - command: "Account::Export.cleanup" + command: "Export.cleanup" schedule: every hour at minute 20 incineration: class: "Account::IncinerateDueJob" diff --git a/config/routes.rb b/config/routes.rb index e070f823b3..9f7af72ef6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,8 @@ resources :email_addresses, param: :token do resource :confirmation, module: :email_addresses end + + resources :data_exports, only: [ :create, :show ] end end diff --git a/db/migrate/20251212164543_add_type_to_account_exports.rb b/db/migrate/20251212164543_add_type_to_account_exports.rb deleted file mode 100644 index 3931380c65..0000000000 --- a/db/migrate/20251212164543_add_type_to_account_exports.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddTypeToAccountExports < ActiveRecord::Migration[8.2] - def change - add_column :account_exports, :type, :string - end -end diff --git a/db/migrate/20251223000001_rename_account_exports_to_exports.rb b/db/migrate/20251223000001_rename_account_exports_to_exports.rb new file mode 100644 index 0000000000..f9823030c3 --- /dev/null +++ b/db/migrate/20251223000001_rename_account_exports_to_exports.rb @@ -0,0 +1,7 @@ +class RenameAccountExportsToExports < ActiveRecord::Migration[8.2] + def change + rename_table :account_exports, :exports + add_column :exports, :type, :string + add_index :exports, :type + end +end diff --git a/db/migrate/20251223000001_create_account_imports.rb b/db/migrate/20251223000002_create_account_imports.rb similarity index 100% rename from db/migrate/20251223000001_create_account_imports.rb rename to db/migrate/20251223000002_create_account_imports.rb diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 84bc6b8a6f..c4713f8d13 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -132,7 +132,6 @@ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end - # If these FKs are removed, make sure to periodically run `RecurringExecution.clear_in_batches` add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/db/schema.rb b/db/schema.rb index b62bcfc377..7848efe56c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -33,18 +33,6 @@ t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true end - create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.uuid "account_id", null: false - t.datetime "completed_at" - t.datetime "created_at", null: false - t.string "status", default: "pending", null: false - t.string "type" - t.datetime "updated_at", null: false - t.uuid "user_id", null: false - t.index ["account_id"], name: "index_account_exports_on_account_id" - t.index ["user_id"], name: "index_account_exports_on_user_id" - end - create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true @@ -305,6 +293,19 @@ t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end + create_table "exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", default: "pending", null: false + t.string "type" + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_exports_on_account_id" + t.index ["type"], name: "index_exports_on_type" + t.index ["user_id"], name: "index_exports_on_user_id" + end + create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb index 1c4441cc5b..1ebe7e5eae 100644 --- a/test/controllers/accounts/exports_controller_test.rb +++ b/test/controllers/accounts/exports_controller_test.rb @@ -2,12 +2,12 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest setup do - sign_in_as :david + sign_in_as :jason end test "create creates an export record and enqueues job" do assert_difference -> { Account::Export.count }, 1 do - assert_enqueued_with(job: ExportAccountDataJob) do + assert_enqueued_with(job: ExportDataJob) do post account_exports_path end end @@ -20,14 +20,14 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest post account_exports_path export = Account::Export.last - assert_equal users(:david), export.user + assert_equal users(:jason), export.user assert_equal Current.account, export.account assert export.pending? end test "create rejects request when current export limit is reached" do Account::ExportsController::CURRENT_EXPORT_LIMIT.times do - Account::Export.create!(account: Current.account, user: users(:david)) + Account::Export.create!(account: Current.account, user: users(:jason)) end assert_no_difference -> { Account::Export.count } do @@ -39,7 +39,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest test "create allows request when exports are older than one day" do Account::ExportsController::CURRENT_EXPORT_LIMIT.times do - Account::Export.create!(account: Current.account, user: users(:david), created_at: 2.days.ago) + Account::Export.create!(account: Current.account, user: users(:jason), created_at: 2.days.ago) end assert_difference -> { Account::Export.count }, 1 do @@ -50,7 +50,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest end test "show displays completed export with download link" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = Account::Export.create!(account: Current.account, user: users(:jason)) export.build get account_export_path(export) @@ -67,7 +67,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest end test "show does not allow access to another user's export" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:kevin)) + export = Account::Export.create!(account: Current.account, user: users(:kevin)) export.build get account_export_path(export) @@ -75,4 +75,22 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "h2", "Download Expired" end + + test "create is forbidden for non-admin members" do + logout_and_sign_in_as :david + + post account_exports_path + + assert_response :forbidden + end + + test "show is forbidden for non-admin members" do + logout_and_sign_in_as :david + export = Account::Export.create!(account: Current.account, user: users(:jason)) + export.build + + get account_export_path(export) + + assert_response :forbidden + end end diff --git a/test/controllers/users/data_exports_controller_test.rb b/test/controllers/users/data_exports_controller_test.rb new file mode 100644 index 0000000000..f89f121b11 --- /dev/null +++ b/test/controllers/users/data_exports_controller_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class Users::DataExportsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + @user = users(:david) + end + + test "create creates an export record and enqueues job" do + assert_difference -> { User::DataExport.count }, 1 do + assert_enqueued_with(job: ExportDataJob) do + post user_data_exports_path(@user) + end + end + + assert_redirected_to @user + assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice] + end + + test "create associates export with user and account" do + post user_data_exports_path(@user) + + export = User::DataExport.last + assert_equal @user, export.user + assert_equal Current.account, export.account + assert export.pending? + end + + test "create rejects request when current export limit is reached" do + Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do + @user.data_exports.create!(account: Current.account) + end + + assert_no_difference -> { User::DataExport.count } do + post user_data_exports_path(@user) + end + + assert_response :too_many_requests + end + + test "create allows request when exports are older than one day" do + Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do + @user.data_exports.create!(account: Current.account, created_at: 2.days.ago) + end + + assert_difference -> { User::DataExport.count }, 1 do + post user_data_exports_path(@user) + end + + assert_redirected_to @user + end + + test "show displays completed export with download link" do + export = @user.data_exports.create!(account: Current.account) + export.build + + get user_data_export_path(@user, export) + + assert_response :success + assert_select "a#download-link" + end + + test "show displays a warning if the export is missing" do + get user_data_export_path(@user, "not-really-an-export") + + assert_response :success + assert_select "h2", "Download Expired" + end + + test "create is forbidden for other users" do + other_user = users(:kevin) + + post user_data_exports_path(other_user) + + assert_response :forbidden + end + + test "show is forbidden for other users" do + other_user = users(:kevin) + export = other_user.data_exports.create!(account: Current.account) + export.build + + get user_data_export_path(other_user, export) + + assert_response :forbidden + end +end diff --git a/test/fixtures/account/exports.yml b/test/fixtures/account/exports.yml deleted file mode 100644 index 764f8aada6..0000000000 --- a/test/fixtures/account/exports.yml +++ /dev/null @@ -1,12 +0,0 @@ -pending_export: - id: <%= ActiveRecord::FixtureSet.identify("pending_export", :uuid) %> - account: 37s_uuid - user: david_uuid - status: pending - -completed_export: - id: <%= ActiveRecord::FixtureSet.identify("completed_export", :uuid) %> - account: 37s_uuid - user: david_uuid - status: completed - completed_at: <%= 1.hour.ago.to_fs(:db) %> diff --git a/test/fixtures/exports.yml b/test/fixtures/exports.yml new file mode 100644 index 0000000000..8d86be46b1 --- /dev/null +++ b/test/fixtures/exports.yml @@ -0,0 +1,29 @@ +pending_account_export: + id: <%= ActiveRecord::FixtureSet.identify("pending_account_export", :uuid) %> + account: 37s_uuid + user: david_uuid + type: Account::Export + status: pending + +completed_account_export: + id: <%= ActiveRecord::FixtureSet.identify("completed_account_export", :uuid) %> + account: 37s_uuid + user: david_uuid + type: Account::Export + status: completed + completed_at: <%= 1.hour.ago.to_fs(:db) %> + +pending_user_data_export: + id: <%= ActiveRecord::FixtureSet.identify("pending_user_data_export", :uuid) %> + account: 37s_uuid + user: david_uuid + type: User::DataExport + status: pending + +completed_user_data_export: + id: <%= ActiveRecord::FixtureSet.identify("completed_user_data_export", :uuid) %> + account: 37s_uuid + user: david_uuid + type: User::DataExport + status: completed + completed_at: <%= 1.hour.ago.to_fs(:db) %> diff --git a/test/mailers/export_mailer_test.rb b/test/mailers/export_mailer_test.rb index c981aa191f..ac48780a32 100644 --- a/test/mailers/export_mailer_test.rb +++ b/test/mailers/export_mailer_test.rb @@ -1,7 +1,7 @@ require "test_helper" class ExportMailerTest < ActionMailer::TestCase - test "completed" do + test "completed for account export" do export = Account::Export.create!(account: Current.account, user: users(:david)) email = ExportMailer.completed(export) @@ -13,4 +13,17 @@ class ExportMailerTest < ActionMailer::TestCase assert_equal "Your Fizzy data export is ready for download", email.subject assert_match %r{/exports/#{export.id}}, email.body.encoded end + + test "completed for user data export" do + export = User::DataExport.create!(account: Current.account, user: users(:david)) + email = ExportMailer.completed(export) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ "david@37signals.com" ], email.to + assert_equal "Your Fizzy data export is ready for download", email.subject + assert_match %r{/users/#{export.user.id}/data_exports/#{export.id}}, email.body.encoded + end end diff --git a/test/models/account/export_test.rb b/test/models/account/export_test.rb index 306d2851a2..f4171f9da8 100644 --- a/test/models/account/export_test.rb +++ b/test/models/account/export_test.rb @@ -1,16 +1,16 @@ require "test_helper" class Account::ExportTest < ActiveSupport::TestCase - test "build_later enqueues ExportAccountDataJob" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + test "build_later enqueues ExportDataJob" do + export = Account::Export.create!(account: Current.account, user: users(:david)) - assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do + assert_enqueued_with(job: ExportDataJob, args: [ export ]) do export.build_later end end test "build sets status to failed on error" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = Account::Export.create!(account: Current.account, user: users(:david)) export.stubs(:generate_zip).raises(StandardError.new("Test error")) assert_raises(StandardError) do @@ -21,14 +21,24 @@ class Account::ExportTest < ActiveSupport::TestCase end test "cleanup deletes exports completed more than 24 hours ago" do - old_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) - recent_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) - pending_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :pending) + old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) + recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) + pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending) - Account::Export.cleanup + Export.cleanup - assert_not Account::Export.exists?(old_export.id) - assert Account::Export.exists?(recent_export.id) - assert Account::Export.exists?(pending_export.id) + assert_not Export.exists?(old_export.id) + assert Export.exists?(recent_export.id) + assert Export.exists?(pending_export.id) + end + + test "build generates zip with account data" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert export.file.attached? + assert_equal "application/zip", export.file.content_type end end diff --git a/test/models/account/whole_account_export_test.rb b/test/models/account/whole_account_export_test.rb deleted file mode 100644 index fb947189ab..0000000000 --- a/test/models/account/whole_account_export_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class Account::WholeAccountExportTest < ActiveSupport::TestCase - test "build generates zip with account data" do - skip "WholeAccountExport implementation incomplete" - end -end diff --git a/test/models/account/single_user_export_test.rb b/test/models/user/data_export_test.rb similarity index 70% rename from test/models/account/single_user_export_test.rb rename to test/models/user/data_export_test.rb index 8d24e16b98..4169c7f86c 100644 --- a/test/models/account/single_user_export_test.rb +++ b/test/models/user/data_export_test.rb @@ -1,8 +1,8 @@ require "test_helper" -class Account::SingleUserExportTest < ActiveSupport::TestCase +class User::DataExportTest < ActiveSupport::TestCase test "build generates zip with card JSON files" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = User::DataExport.create!(account: Current.account, user: users(:david)) export.build @@ -12,7 +12,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase end test "build sets status to processing then completed" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = User::DataExport.create!(account: Current.account, user: users(:david)) export.build @@ -21,7 +21,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase end test "build sends email when completed" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = User::DataExport.create!(account: Current.account, user: users(:david)) assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do export.build @@ -30,7 +30,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase test "build includes only accessible cards for user" do user = users(:david) - export = Account::SingleUserExport.create!(account: Current.account, user: user) + export = User::DataExport.create!(account: Current.account, user: user) export.build @@ -59,4 +59,12 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase end end end + + test "build_later enqueues ExportDataJob" do + export = User::DataExport.create!(account: Current.account, user: users(:david)) + + assert_enqueued_with(job: ExportDataJob, args: [ export ]) do + export.build_later + end + end end From cb1f4de8ec9dc505f476a338759dd85c3b6a9425 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 14 Jan 2026 13:07:09 -0600 Subject: [PATCH 05/25] Simplify settings layout and correct h2 levels --- app/assets/stylesheets/profile-layout.css | 16 -------- app/assets/stylesheets/settings.css | 23 +++++++++-- app/views/account/settings/_entropy.html.erb | 13 ++++--- app/views/account/settings/_export.html.erb | 34 +++++++++-------- app/views/account/settings/_name.html.erb | 16 ++++---- app/views/account/settings/_users.html.erb | 40 ++++++++++---------- app/views/account/settings/show.html.erb | 4 +- app/views/users/_access_tokens.html.erb | 10 +++-- app/views/users/_data_export.html.erb | 34 +++++++++-------- app/views/users/_theme.html.erb | 6 ++- app/views/users/_transfer.html.erb | 21 +++++----- app/views/users/show.html.erb | 10 ++--- 12 files changed, 119 insertions(+), 108 deletions(-) delete mode 100644 app/assets/stylesheets/profile-layout.css diff --git a/app/assets/stylesheets/profile-layout.css b/app/assets/stylesheets/profile-layout.css deleted file mode 100644 index bb18c2655e..0000000000 --- a/app/assets/stylesheets/profile-layout.css +++ /dev/null @@ -1,16 +0,0 @@ -@layer components { - .profile-layout { - display: flex; - gap: var(--inline-space); - - @media (min-width: 800px) { - align-items: stretch; - justify-content: center; - } - - @media (max-width: 799px) { - align-items: center; - flex-direction: column; - } - } -} diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css index cd67a4cc0e..2e234487a1 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -13,18 +13,21 @@ } } + /* Sections & Panels + /* -------------------------------------------------------------------------- */ + .settings__panel { --panel-size: 100%; --panel-padding: calc(var(--settings-spacer) / 1); display: flex; flex-direction: column; - gap: calc(var(--settings-spacer) / 2); + gap: var(--panel-padding); min-block-size: 100%; min-inline-size: 0; - @media (min-width: 960px) { - --panel-padding: calc(var(--settings-spacer) * 1.5) calc(var(--settings-spacer) * 2); + @media (min-width: 640px) { + --panel-padding: calc(var(--settings-spacer) * 2); } } @@ -36,6 +39,20 @@ } } + .settings__section { + h2 { + font-size: var(--text-large); + } + + > * + * { + margin-block-start: calc(var(--panel-padding) / 2); + } + + &:is(:first-child):has(h2) { + margin-top: -0.33lh; /* Align h2 letters caps with panel padding */ + } + } + /* Users /* ------------------------------------------------------------------------ */ diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb index daac70a4a1..4a24d4cd64 100644 --- a/app/views/account/settings/_entropy.html.erb +++ b/app/views/account/settings/_entropy.html.erb @@ -1,7 +1,8 @@ -
-

Auto close

-

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.

-
- -<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> +
+
+

Auto close

+
Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.
+
+ <%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> +
diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb index 3d19469962..2b831837c1 100644 --- a/app/views/account/settings/_export.html.erb +++ b/app/views/account/settings/_export.html.erb @@ -1,19 +1,21 @@ -
-

Export account data

-

Download a complete archive of all account data.

-
+
+
+

Export account data

+
Download a complete archive of all account data.
+
-
- +
+ - -

Export all account data

-

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

-

When the file is ready, we'll email you a link to download it. The link will expire after 24 hours.

+ +

Export all account data

+

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

+

When the file is ready, we'll email you a link to download it. The link will expire after 24 hours.

-
- <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> - -
-
-
\ No newline at end of file +
+ <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> + +
+
+
+ diff --git a/app/views/account/settings/_name.html.erb b/app/views/account/settings/_name.html.erb index e3b12f325e..6ad8667811 100644 --- a/app/views/account/settings/_name.html.erb +++ b/app/views/account/settings/_name.html.erb @@ -1,10 +1,12 @@ -<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %> - <%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %> +
+ <%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %> + <%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %> - <% if Current.user.admin? %> - <%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %> - <%= icon_tag "arrow-right" %> - Save changes + <% if Current.user.admin? %> + <%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %> + <%= icon_tag "arrow-right" %> + Save changes + <% end %> <% end %> <% end %> -<% end %> +
diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index 63b5b17637..aeee056389 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -1,24 +1,26 @@ -
-

People on this account

-
+
+
+

People on this account

+
-<%= tag.div class: "flex flex-column gap settings__user-filter", data: { - controller: "filter navigable-list", - action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", - navigable_list_focus_on_selection_value: true, - navigable_list_actionable_items_value: true -} do %> + <%= tag.div class: "flex flex-column gap settings__user-filter", data: { + controller: "filter navigable-list", + action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", + navigable_list_focus_on_selection_value: true, + navigable_list_actionable_items_value: true + } do %> -
- +
+ -
    - <%= render partial: "account/settings/user", collection: users %> -
-
+
    + <%= render partial: "account/settings/user", collection: users %> +
+
- <%= link_to account_join_code_path, class: "btn btn--link center txt-small" do %> - <%= icon_tag "add" %> - Invite people + <%= link_to account_join_code_path, class: "btn btn--link center" do %> + <%= icon_tag "add" %> + Invite people + <% end %> <% end %> -<% end %> +
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index b2739807cc..0347466818 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -9,7 +9,7 @@ <% end %> -
+
<%= render "account/settings/name", account: @account %> <%= render "account/settings/users", users: @users %> @@ -20,6 +20,6 @@ <%= render "account/settings/export" if Current.user.admin? || Current.user.owner? %> <%= render "account/settings/cancellation" %>
-
+ <%= render "account/settings/subscription_panel" if Fizzy.saas? %> diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb index 285f87149e..75bffa95c9 100644 --- a/app/views/users/_access_tokens.html.erb +++ b/app/views/users/_access_tokens.html.erb @@ -1,4 +1,6 @@ -
-

Developer

-

Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.

-
\ No newline at end of file +
+
+

Developer

+
Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
+
+
diff --git a/app/views/users/_data_export.html.erb b/app/views/users/_data_export.html.erb index a6cd3bde80..2359fbbefa 100644 --- a/app/views/users/_data_export.html.erb +++ b/app/views/users/_data_export.html.erb @@ -1,19 +1,21 @@ -
-

Export your data

-

Download an archive of your Fizzy data.

-
+
+
+

Export your data

+
Download an archive of your Fizzy data.
+
-
- +
+ - -

Export your data

-

This will generate a ZIP archive of all cards you have access to.

-

When ready, we'll email you a download link (expires in 24 hours).

+ +

Export your data

+

This will generate a ZIP archive of all cards you have access to.

+

When ready, we'll email you a download link (expires in 24 hours).

-
- <%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> - -
-
-
+
+ <%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> + +
+ +
+
diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb index 0f7db95472..47300abef5 100644 --- a/app/views/users/_theme.html.erb +++ b/app/views/users/_theme.html.erb @@ -1,5 +1,7 @@ -
- Appearance +
+
+

Appearance

+
From b1c3391076be3b1b467ae0c5e15e821c8794d753 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 14 Jan 2026 13:37:44 -0600 Subject: [PATCH 06/25] Polish up notification settings to match --- app/assets/stylesheets/notifications.css | 4 ++-- app/views/boards/edit.html.erb | 4 ++-- .../notifications/settings/_board.html.erb | 2 +- .../notifications/settings/_email.html.erb | 18 +++++++++--------- .../settings/_push_notifications.html.erb | 13 +++++++++---- app/views/notifications/settings/show.html.erb | 14 ++++++++------ saas/app/views/admin/stats/show.html.erb | 6 +++--- 7 files changed, 34 insertions(+), 27 deletions(-) diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index 91ff5750d7..fabea58a86 100644 --- a/app/assets/stylesheets/notifications.css +++ b/app/assets/stylesheets/notifications.css @@ -95,12 +95,12 @@ display: none; .notifications--on & { - display: block; + display: inline; } } .notifications__off-message { - display: block; + display: inline; .notifications--on & { display: none; diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb index 36beee3c86..403fe823aa 100644 --- a/app/views/boards/edit.html.erb +++ b/app/views/boards/edit.html.erb @@ -14,7 +14,7 @@ <% end %>
-
+
<%= form_with model: @board, class: "display-contents", data: { controller: "form boards-form", boards_form_self_removal_prompt_message_value: "Are you sure you want to remove yourself from this board? You won’t be able to get back in unless someone invites you.", @@ -30,7 +30,7 @@ <% end %>
-
+
<%= render "boards/edit/auto_close", board: @board %> <%= render "boards/edit/publication", board: @board %> <%= render "boards/edit/delete", board: @board if Current.user.can_administer_board?(@board) %> diff --git a/app/views/notifications/settings/_board.html.erb b/app/views/notifications/settings/_board.html.erb index a5f4857522..bf9a41b94d 100644 --- a/app/views/notifications/settings/_board.html.erb +++ b/app/views/notifications/settings/_board.html.erb @@ -1,5 +1,5 @@ <%= turbo_frame_tag board, :involvement do %> -
+

<%= board.name %>

diff --git a/app/views/notifications/settings/_email.html.erb b/app/views/notifications/settings/_email.html.erb index a51aea099b..7ff1b8b4ed 100644 --- a/app/views/notifications/settings/_email.html.erb +++ b/app/views/notifications/settings/_email.html.erb @@ -1,12 +1,12 @@ -
-

Email Notifications

-

Get a single email with all your notifications every few hours, daily, or weekly.

- <%= form_with model: settings, url: notifications_settings_path, - class: "flex flex-column gap-half", +
+ +

Email Notifications

+
Get a single email with all your notifications every few hours, daily, or weekly.
+
+ + <%= form_with model: settings, url: notifications_settings_path, method: :patch, local: true, data: { controller: "form" } do |form| %> -
- <%= form.label :bundle_email_frequency, "Email me about new notifications..." %> - <%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %> -
+
<%= form.label :bundle_email_frequency, "Email me about new notifications..." %>
+ <%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %> <% end %>
diff --git a/app/views/notifications/settings/_push_notifications.html.erb b/app/views/notifications/settings/_push_notifications.html.erb index 047f57a159..d4e03d885b 100644 --- a/app/views/notifications/settings/_push_notifications.html.erb +++ b/app/views/notifications/settings/_push_notifications.html.erb @@ -1,6 +1,11 @@ -
-

Push notifications are ON

-

Push notifications are OFF

+
+ +

+ Push notifications are + ON + OFF +

+
-
+
diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 5972e7fdad..adf5af6811 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -5,14 +5,16 @@ <% end %>
-
-

Boards

-
- <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %> -
+
+
+

Boards

+
+ <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %> +
+
-
+
<%= render "notifications/settings/push_notifications" %> <%= render "notifications/settings/email", settings: @settings %>
diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb index cb999172ec..f93bd3ebae 100644 --- a/saas/app/views/admin/stats/show.html.erb +++ b/saas/app/views/admin/stats/show.html.erb @@ -5,7 +5,7 @@ <% end %>
-
+
@@ -87,7 +87,7 @@
-
+

10 Most Recent Signups @@ -118,7 +118,7 @@

-
+

Top 20 Accounts by Card Count From 59c3e684a2335e3836cdb3b90b8df7d48de8e6f5 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 16 Jan 2026 10:42:57 +0100 Subject: [PATCH 07/25] Break import and export objects into record sets --- app/jobs/import_account_data_job.rb | 14 +- .../data_transfer/access_record_set.rb | 48 ++ .../data_transfer/account_record_set.rb | 54 ++ .../action_text_rich_text_record_set.rb | 82 ++ .../active_storage_attachment_record_set.rb | 47 ++ .../active_storage_blob_record_set.rb | 50 ++ .../data_transfer/assignment_record_set.rb | 47 ++ .../data_transfer/blob_file_record_set.rb | 39 + .../board_publication_record_set.rb | 46 ++ .../account/data_transfer/board_record_set.rb | 47 ++ .../card_activity_spike_record_set.rb | 45 ++ .../data_transfer/card_goldness_record_set.rb | 45 ++ .../data_transfer/card_not_now_record_set.rb | 46 ++ .../account/data_transfer/card_record_set.rb | 52 ++ .../data_transfer/closure_record_set.rb | 46 ++ .../data_transfer/column_record_set.rb | 48 ++ .../data_transfer/comment_record_set.rb | 46 ++ .../data_transfer/entropy_record_set.rb | 51 ++ .../account/data_transfer/event_record_set.rb | 50 ++ .../data_transfer/filter_record_set.rb | 47 ++ app/models/account/data_transfer/manifest.rb | 54 ++ .../data_transfer/mention_record_set.rb | 54 ++ .../notification_bundle_record_set.rb | 48 ++ .../data_transfer/notification_record_set.rb | 49 ++ .../account/data_transfer/pin_record_set.rb | 46 ++ .../data_transfer/reaction_record_set.rb | 47 ++ .../account/data_transfer/record_set.rb | 76 ++ .../account/data_transfer/step_record_set.rb | 47 ++ .../account/data_transfer/tag_record_set.rb | 45 ++ .../data_transfer/tagging_record_set.rb | 46 ++ .../account/data_transfer/user_record_set.rb | 54 ++ .../account/data_transfer/watch_record_set.rb | 47 ++ .../webhook_delinquency_tracker_record_set.rb | 47 ++ .../webhook_delivery_record_set.rb | 49 ++ .../data_transfer/webhook_record_set.rb | 50 ++ app/models/account/data_transfer/zip_file.rb | 57 ++ app/models/account/export.rb | 250 +----- app/models/account/import.rb | 739 +----------------- app/models/account/import/id_mapper.rb | 60 -- app/models/export.rb | 24 +- test/models/account/import_test.rb | 262 +++---- 41 files changed, 1910 insertions(+), 1191 deletions(-) create mode 100644 app/models/account/data_transfer/access_record_set.rb create mode 100644 app/models/account/data_transfer/account_record_set.rb create mode 100644 app/models/account/data_transfer/action_text_rich_text_record_set.rb create mode 100644 app/models/account/data_transfer/active_storage_attachment_record_set.rb create mode 100644 app/models/account/data_transfer/active_storage_blob_record_set.rb create mode 100644 app/models/account/data_transfer/assignment_record_set.rb create mode 100644 app/models/account/data_transfer/blob_file_record_set.rb create mode 100644 app/models/account/data_transfer/board_publication_record_set.rb create mode 100644 app/models/account/data_transfer/board_record_set.rb create mode 100644 app/models/account/data_transfer/card_activity_spike_record_set.rb create mode 100644 app/models/account/data_transfer/card_goldness_record_set.rb create mode 100644 app/models/account/data_transfer/card_not_now_record_set.rb create mode 100644 app/models/account/data_transfer/card_record_set.rb create mode 100644 app/models/account/data_transfer/closure_record_set.rb create mode 100644 app/models/account/data_transfer/column_record_set.rb create mode 100644 app/models/account/data_transfer/comment_record_set.rb create mode 100644 app/models/account/data_transfer/entropy_record_set.rb create mode 100644 app/models/account/data_transfer/event_record_set.rb create mode 100644 app/models/account/data_transfer/filter_record_set.rb create mode 100644 app/models/account/data_transfer/manifest.rb create mode 100644 app/models/account/data_transfer/mention_record_set.rb create mode 100644 app/models/account/data_transfer/notification_bundle_record_set.rb create mode 100644 app/models/account/data_transfer/notification_record_set.rb create mode 100644 app/models/account/data_transfer/pin_record_set.rb create mode 100644 app/models/account/data_transfer/reaction_record_set.rb create mode 100644 app/models/account/data_transfer/record_set.rb create mode 100644 app/models/account/data_transfer/step_record_set.rb create mode 100644 app/models/account/data_transfer/tag_record_set.rb create mode 100644 app/models/account/data_transfer/tagging_record_set.rb create mode 100644 app/models/account/data_transfer/user_record_set.rb create mode 100644 app/models/account/data_transfer/watch_record_set.rb create mode 100644 app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb create mode 100644 app/models/account/data_transfer/webhook_delivery_record_set.rb create mode 100644 app/models/account/data_transfer/webhook_record_set.rb create mode 100644 app/models/account/data_transfer/zip_file.rb delete mode 100644 app/models/account/import/id_mapper.rb diff --git a/app/jobs/import_account_data_job.rb b/app/jobs/import_account_data_job.rb index 5aa29dc9e9..e131a41e91 100644 --- a/app/jobs/import_account_data_job.rb +++ b/app/jobs/import_account_data_job.rb @@ -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 diff --git a/app/models/account/data_transfer/access_record_set.rb b/app/models/account/data_transfer/access_record_set.rb new file mode 100644 index 0000000000..9b5e05ceb1 --- /dev/null +++ b/app/models/account/data_transfer/access_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/account_record_set.rb b/app/models/account/data_transfer/account_record_set.rb new file mode 100644 index 0000000000..c27e03101a --- /dev/null +++ b/app/models/account/data_transfer/account_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb new file mode 100644 index 0000000000..62491fa520 --- /dev/null +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/active_storage_attachment_record_set.rb b/app/models/account/data_transfer/active_storage_attachment_record_set.rb new file mode 100644 index 0000000000..5e10c76221 --- /dev/null +++ b/app/models/account/data_transfer/active_storage_attachment_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/active_storage_blob_record_set.rb b/app/models/account/data_transfer/active_storage_blob_record_set.rb new file mode 100644 index 0000000000..ccc5e2babb --- /dev/null +++ b/app/models/account/data_transfer/active_storage_blob_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/assignment_record_set.rb b/app/models/account/data_transfer/assignment_record_set.rb new file mode 100644 index 0000000000..437c564cb3 --- /dev/null +++ b/app/models/account/data_transfer/assignment_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/blob_file_record_set.rb b/app/models/account/data_transfer/blob_file_record_set.rb new file mode 100644 index 0000000000..233985a0b5 --- /dev/null +++ b/app/models/account/data_transfer/blob_file_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/board_publication_record_set.rb b/app/models/account/data_transfer/board_publication_record_set.rb new file mode 100644 index 0000000000..be357da77f --- /dev/null +++ b/app/models/account/data_transfer/board_publication_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/board_record_set.rb b/app/models/account/data_transfer/board_record_set.rb new file mode 100644 index 0000000000..4b678cb4c3 --- /dev/null +++ b/app/models/account/data_transfer/board_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/card_activity_spike_record_set.rb b/app/models/account/data_transfer/card_activity_spike_record_set.rb new file mode 100644 index 0000000000..ff8e1425ec --- /dev/null +++ b/app/models/account/data_transfer/card_activity_spike_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/card_goldness_record_set.rb b/app/models/account/data_transfer/card_goldness_record_set.rb new file mode 100644 index 0000000000..ecd8713990 --- /dev/null +++ b/app/models/account/data_transfer/card_goldness_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/card_not_now_record_set.rb b/app/models/account/data_transfer/card_not_now_record_set.rb new file mode 100644 index 0000000000..0d26b1405d --- /dev/null +++ b/app/models/account/data_transfer/card_not_now_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/card_record_set.rb b/app/models/account/data_transfer/card_record_set.rb new file mode 100644 index 0000000000..15299ffd9f --- /dev/null +++ b/app/models/account/data_transfer/card_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/closure_record_set.rb b/app/models/account/data_transfer/closure_record_set.rb new file mode 100644 index 0000000000..6bfc1a2d59 --- /dev/null +++ b/app/models/account/data_transfer/closure_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/column_record_set.rb b/app/models/account/data_transfer/column_record_set.rb new file mode 100644 index 0000000000..fb5819bbd6 --- /dev/null +++ b/app/models/account/data_transfer/column_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/comment_record_set.rb b/app/models/account/data_transfer/comment_record_set.rb new file mode 100644 index 0000000000..61891794bc --- /dev/null +++ b/app/models/account/data_transfer/comment_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/entropy_record_set.rb b/app/models/account/data_transfer/entropy_record_set.rb new file mode 100644 index 0000000000..550f52f85d --- /dev/null +++ b/app/models/account/data_transfer/entropy_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/event_record_set.rb b/app/models/account/data_transfer/event_record_set.rb new file mode 100644 index 0000000000..77ec845f12 --- /dev/null +++ b/app/models/account/data_transfer/event_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/filter_record_set.rb b/app/models/account/data_transfer/filter_record_set.rb new file mode 100644 index 0000000000..26e9b6dab1 --- /dev/null +++ b/app/models/account/data_transfer/filter_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb new file mode 100644 index 0000000000..91ce8028ee --- /dev/null +++ b/app/models/account/data_transfer/manifest.rb @@ -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 diff --git a/app/models/account/data_transfer/mention_record_set.rb b/app/models/account/data_transfer/mention_record_set.rb new file mode 100644 index 0000000000..4067418e57 --- /dev/null +++ b/app/models/account/data_transfer/mention_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/notification_bundle_record_set.rb b/app/models/account/data_transfer/notification_bundle_record_set.rb new file mode 100644 index 0000000000..6dee002867 --- /dev/null +++ b/app/models/account/data_transfer/notification_bundle_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/notification_record_set.rb b/app/models/account/data_transfer/notification_record_set.rb new file mode 100644 index 0000000000..da08f8be07 --- /dev/null +++ b/app/models/account/data_transfer/notification_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/pin_record_set.rb b/app/models/account/data_transfer/pin_record_set.rb new file mode 100644 index 0000000000..1244fbe3c6 --- /dev/null +++ b/app/models/account/data_transfer/pin_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/reaction_record_set.rb b/app/models/account/data_transfer/reaction_record_set.rb new file mode 100644 index 0000000000..8060f57fe9 --- /dev/null +++ b/app/models/account/data_transfer/reaction_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb new file mode 100644 index 0000000000..ff8a7d5758 --- /dev/null +++ b/app/models/account/data_transfer/record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/step_record_set.rb b/app/models/account/data_transfer/step_record_set.rb new file mode 100644 index 0000000000..b3f0acea76 --- /dev/null +++ b/app/models/account/data_transfer/step_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/tag_record_set.rb b/app/models/account/data_transfer/tag_record_set.rb new file mode 100644 index 0000000000..6f4f72e08d --- /dev/null +++ b/app/models/account/data_transfer/tag_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/tagging_record_set.rb b/app/models/account/data_transfer/tagging_record_set.rb new file mode 100644 index 0000000000..e9e65f7b53 --- /dev/null +++ b/app/models/account/data_transfer/tagging_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/user_record_set.rb b/app/models/account/data_transfer/user_record_set.rb new file mode 100644 index 0000000000..c77e7bbec5 --- /dev/null +++ b/app/models/account/data_transfer/user_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/watch_record_set.rb b/app/models/account/data_transfer/watch_record_set.rb new file mode 100644 index 0000000000..497f8e4ce2 --- /dev/null +++ b/app/models/account/data_transfer/watch_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb b/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb new file mode 100644 index 0000000000..15f35c6e50 --- /dev/null +++ b/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/webhook_delivery_record_set.rb b/app/models/account/data_transfer/webhook_delivery_record_set.rb new file mode 100644 index 0000000000..2566019e04 --- /dev/null +++ b/app/models/account/data_transfer/webhook_delivery_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/webhook_record_set.rb b/app/models/account/data_transfer/webhook_record_set.rb new file mode 100644 index 0000000000..3290503438 --- /dev/null +++ b/app/models/account/data_transfer/webhook_record_set.rb @@ -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 diff --git a/app/models/account/data_transfer/zip_file.rb b/app/models/account/data_transfer/zip_file.rb new file mode 100644 index 0000000000..058476625e --- /dev/null +++ b/app/models/account/data_transfer/zip_file.rb @@ -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 diff --git a/app/models/account/export.rb b/app/models/account/export.rb index e8ab0dd1f8..6d78513246 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -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 diff --git a/app/models/account/import.rb b/app/models/account/import.rb index 2357fbd5f9..a68a1b37bf 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -1,6 +1,4 @@ class Account::Import < ApplicationRecord - class IntegrityError < StandardError; end - belongs_to :account belongs_to :identity @@ -8,738 +6,51 @@ class IntegrityError < StandardError; end 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) - end - - private - 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" + def validate(start: nil, callback: nil) + processing! + ensure_downloaded - 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) + 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 - 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"]) + private + def ensure_downloaded + unless download_path.exist? + download_path.open("wb") do |f| + file.download { |chunk| f.write(chunk) } 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 + def download_path + Pathname.new("/tmp/account-import-#{id}.zip") 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 + def mark_completed + completed! + # TODO: send email end end diff --git a/app/models/account/import/id_mapper.rb b/app/models/account/import/id_mapper.rb deleted file mode 100644 index 63e19afd08..0000000000 --- a/app/models/account/import/id_mapper.rb +++ /dev/null @@ -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 diff --git a/app/models/export.rb b/app/models/export.rb index 871d6d8b4e..9986f7539e 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -20,18 +20,16 @@ def build_later 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 @@ def accessible_to?(accessor) 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) diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index 6a21d8343c..1abada2a3a 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -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 - - assert_equal export_code, target_account.join_code.reload.code - end - - test "perform keeps existing join code on collision" do - target_account = create_target_account - original_code = target_account.join_code.code - - # 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 + import_with_custom_join_code.process - # The target account should keep its original code since there's a collision - assert_equal original_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 validates export integrity - rejects foreign account references" do + test "validate raises IntegrityError for missing required fields" do target_account = create_target_account - import = create_import_with_foreign_account_reference(target_account) + import = create_import_with_invalid_data(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 @@ def create_target_account 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)) } - - # 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 + 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 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 + + # 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 - tempfile.rewind - StringIO.new(tempfile.read) + # 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 From 2d696dbe17a778e49adcbf88c85644dca0985921 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 16 Jan 2026 13:43:35 +0100 Subject: [PATCH 08/25] Regenerate schemas --- db/schema_sqlite.rb | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index e37b168bf3..46e655abcb 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -33,18 +33,6 @@ t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true end - create_table "account_exports", id: :uuid, force: :cascade do |t| - t.uuid "account_id", null: false - t.datetime "completed_at" - t.datetime "created_at", null: false - t.string "status", limit: 255, default: "pending", null: false - t.string "type" - t.datetime "updated_at", null: false - t.uuid "user_id", null: false - t.index ["account_id"], name: "index_account_exports_on_account_id" - t.index ["user_id"], name: "index_account_exports_on_user_id" - end - create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true @@ -305,6 +293,19 @@ t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end + create_table "exports", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", limit: 255, default: "pending", null: false + t.string "type" + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_exports_on_account_id" + t.index ["type"], name: "index_exports_on_type" + t.index ["user_id"], name: "index_exports_on_user_id" + end + create_table "filters", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false @@ -489,7 +490,7 @@ t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 - t.string "request_id", limit: 255 + t.string "request_id" t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" From fec581959f6202ea0be3caabd29ee439698ce2e8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 16 Jan 2026 14:15:37 +0100 Subject: [PATCH 09/25] Convert double negatives into a positive question --- app/models/account/data_transfer/access_record_set.rb | 2 +- .../account/data_transfer/action_text_rich_text_record_set.rb | 2 +- .../data_transfer/active_storage_attachment_record_set.rb | 2 +- .../account/data_transfer/active_storage_blob_record_set.rb | 2 +- app/models/account/data_transfer/assignment_record_set.rb | 2 +- .../account/data_transfer/board_publication_record_set.rb | 2 +- app/models/account/data_transfer/board_record_set.rb | 2 +- .../account/data_transfer/card_activity_spike_record_set.rb | 2 +- app/models/account/data_transfer/card_goldness_record_set.rb | 2 +- app/models/account/data_transfer/card_not_now_record_set.rb | 2 +- app/models/account/data_transfer/card_record_set.rb | 2 +- app/models/account/data_transfer/closure_record_set.rb | 2 +- app/models/account/data_transfer/column_record_set.rb | 2 +- app/models/account/data_transfer/comment_record_set.rb | 2 +- app/models/account/data_transfer/entropy_record_set.rb | 2 +- app/models/account/data_transfer/event_record_set.rb | 2 +- app/models/account/data_transfer/filter_record_set.rb | 2 +- app/models/account/data_transfer/mention_record_set.rb | 2 +- .../account/data_transfer/notification_bundle_record_set.rb | 2 +- app/models/account/data_transfer/notification_record_set.rb | 2 +- app/models/account/data_transfer/pin_record_set.rb | 2 +- app/models/account/data_transfer/reaction_record_set.rb | 2 +- app/models/account/data_transfer/step_record_set.rb | 2 +- app/models/account/data_transfer/tag_record_set.rb | 2 +- app/models/account/data_transfer/tagging_record_set.rb | 2 +- app/models/account/data_transfer/watch_record_set.rb | 2 +- .../data_transfer/webhook_delinquency_tracker_record_set.rb | 2 +- app/models/account/data_transfer/webhook_delivery_record_set.rb | 2 +- app/models/account/data_transfer/webhook_record_set.rb | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/app/models/account/data_transfer/access_record_set.rb b/app/models/account/data_transfer/access_record_set.rb index 9b5e05ceb1..0f78e3c7a7 100644 --- a/app/models/account/data_transfer/access_record_set.rb +++ b/app/models/account/data_transfer/access_record_set.rb @@ -41,7 +41,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb index 62491fa520..355c5d71b6 100644 --- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -43,7 +43,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/active_storage_attachment_record_set.rb b/app/models/account/data_transfer/active_storage_attachment_record_set.rb index 5e10c76221..f33fe5bb4a 100644 --- a/app/models/account/data_transfer/active_storage_attachment_record_set.rb +++ b/app/models/account/data_transfer/active_storage_attachment_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/active_storage_blob_record_set.rb b/app/models/account/data_transfer/active_storage_blob_record_set.rb index ccc5e2babb..43aa55b1ef 100644 --- a/app/models/account/data_transfer/active_storage_blob_record_set.rb +++ b/app/models/account/data_transfer/active_storage_blob_record_set.rb @@ -43,7 +43,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/assignment_record_set.rb b/app/models/account/data_transfer/assignment_record_set.rb index 437c564cb3..955946c2da 100644 --- a/app/models/account/data_transfer/assignment_record_set.rb +++ b/app/models/account/data_transfer/assignment_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/board_publication_record_set.rb b/app/models/account/data_transfer/board_publication_record_set.rb index be357da77f..922257be56 100644 --- a/app/models/account/data_transfer/board_publication_record_set.rb +++ b/app/models/account/data_transfer/board_publication_record_set.rb @@ -39,7 +39,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/board_record_set.rb b/app/models/account/data_transfer/board_record_set.rb index 4b678cb4c3..752e1100be 100644 --- a/app/models/account/data_transfer/board_record_set.rb +++ b/app/models/account/data_transfer/board_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/card_activity_spike_record_set.rb b/app/models/account/data_transfer/card_activity_spike_record_set.rb index ff8e1425ec..61c73eb19b 100644 --- a/app/models/account/data_transfer/card_activity_spike_record_set.rb +++ b/app/models/account/data_transfer/card_activity_spike_record_set.rb @@ -38,7 +38,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/card_goldness_record_set.rb b/app/models/account/data_transfer/card_goldness_record_set.rb index ecd8713990..fe488cbec3 100644 --- a/app/models/account/data_transfer/card_goldness_record_set.rb +++ b/app/models/account/data_transfer/card_goldness_record_set.rb @@ -38,7 +38,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/card_not_now_record_set.rb b/app/models/account/data_transfer/card_not_now_record_set.rb index 0d26b1405d..4ee68977a7 100644 --- a/app/models/account/data_transfer/card_not_now_record_set.rb +++ b/app/models/account/data_transfer/card_not_now_record_set.rb @@ -39,7 +39,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/card_record_set.rb b/app/models/account/data_transfer/card_record_set.rb index 15299ffd9f..8542b73965 100644 --- a/app/models/account/data_transfer/card_record_set.rb +++ b/app/models/account/data_transfer/card_record_set.rb @@ -45,7 +45,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/closure_record_set.rb b/app/models/account/data_transfer/closure_record_set.rb index 6bfc1a2d59..b1b3214380 100644 --- a/app/models/account/data_transfer/closure_record_set.rb +++ b/app/models/account/data_transfer/closure_record_set.rb @@ -39,7 +39,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/column_record_set.rb b/app/models/account/data_transfer/column_record_set.rb index fb5819bbd6..4706d5adcc 100644 --- a/app/models/account/data_transfer/column_record_set.rb +++ b/app/models/account/data_transfer/column_record_set.rb @@ -41,7 +41,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/comment_record_set.rb b/app/models/account/data_transfer/comment_record_set.rb index 61891794bc..70dc2bab5e 100644 --- a/app/models/account/data_transfer/comment_record_set.rb +++ b/app/models/account/data_transfer/comment_record_set.rb @@ -39,7 +39,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/entropy_record_set.rb b/app/models/account/data_transfer/entropy_record_set.rb index 550f52f85d..193a627378 100644 --- a/app/models/account/data_transfer/entropy_record_set.rb +++ b/app/models/account/data_transfer/entropy_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end diff --git a/app/models/account/data_transfer/event_record_set.rb b/app/models/account/data_transfer/event_record_set.rb index 77ec845f12..42ccb80fed 100644 --- a/app/models/account/data_transfer/event_record_set.rb +++ b/app/models/account/data_transfer/event_record_set.rb @@ -43,7 +43,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/filter_record_set.rb b/app/models/account/data_transfer/filter_record_set.rb index 26e9b6dab1..01f8e8ba32 100644 --- a/app/models/account/data_transfer/filter_record_set.rb +++ b/app/models/account/data_transfer/filter_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/mention_record_set.rb b/app/models/account/data_transfer/mention_record_set.rb index 4067418e57..253fb8ee4f 100644 --- a/app/models/account/data_transfer/mention_record_set.rb +++ b/app/models/account/data_transfer/mention_record_set.rb @@ -43,7 +43,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end diff --git a/app/models/account/data_transfer/notification_bundle_record_set.rb b/app/models/account/data_transfer/notification_bundle_record_set.rb index 6dee002867..fd468ef891 100644 --- a/app/models/account/data_transfer/notification_bundle_record_set.rb +++ b/app/models/account/data_transfer/notification_bundle_record_set.rb @@ -41,7 +41,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/notification_record_set.rb b/app/models/account/data_transfer/notification_record_set.rb index da08f8be07..260e388ab0 100644 --- a/app/models/account/data_transfer/notification_record_set.rb +++ b/app/models/account/data_transfer/notification_record_set.rb @@ -42,7 +42,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/pin_record_set.rb b/app/models/account/data_transfer/pin_record_set.rb index 1244fbe3c6..fd4c33e7dd 100644 --- a/app/models/account/data_transfer/pin_record_set.rb +++ b/app/models/account/data_transfer/pin_record_set.rb @@ -39,7 +39,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/reaction_record_set.rb b/app/models/account/data_transfer/reaction_record_set.rb index 8060f57fe9..6b145a2e66 100644 --- a/app/models/account/data_transfer/reaction_record_set.rb +++ b/app/models/account/data_transfer/reaction_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/step_record_set.rb b/app/models/account/data_transfer/step_record_set.rb index b3f0acea76..1f82c00aaa 100644 --- a/app/models/account/data_transfer/step_record_set.rb +++ b/app/models/account/data_transfer/step_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/tag_record_set.rb b/app/models/account/data_transfer/tag_record_set.rb index 6f4f72e08d..3a47255016 100644 --- a/app/models/account/data_transfer/tag_record_set.rb +++ b/app/models/account/data_transfer/tag_record_set.rb @@ -38,7 +38,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/tagging_record_set.rb b/app/models/account/data_transfer/tagging_record_set.rb index e9e65f7b53..e925cf68e1 100644 --- a/app/models/account/data_transfer/tagging_record_set.rb +++ b/app/models/account/data_transfer/tagging_record_set.rb @@ -39,7 +39,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/watch_record_set.rb b/app/models/account/data_transfer/watch_record_set.rb index 497f8e4ce2..3c86da7703 100644 --- a/app/models/account/data_transfer/watch_record_set.rb +++ b/app/models/account/data_transfer/watch_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb b/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb index 15f35c6e50..a31600bdb2 100644 --- a/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb +++ b/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb @@ -40,7 +40,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/webhook_delivery_record_set.rb b/app/models/account/data_transfer/webhook_delivery_record_set.rb index 2566019e04..c26c3c0dc5 100644 --- a/app/models/account/data_transfer/webhook_delivery_record_set.rb +++ b/app/models/account/data_transfer/webhook_delivery_record_set.rb @@ -42,7 +42,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end diff --git a/app/models/account/data_transfer/webhook_record_set.rb b/app/models/account/data_transfer/webhook_record_set.rb index 3290503438..64147ba6df 100644 --- a/app/models/account/data_transfer/webhook_record_set.rb +++ b/app/models/account/data_transfer/webhook_record_set.rb @@ -43,7 +43,7 @@ def validate_record(file_path) end missing = ATTRIBUTES - data.keys - unless missing.empty? + if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end end From 3827dc163d6e042bcc0d72b8df35c1d655e1d5f9 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 19 Jan 2026 12:58:30 +0100 Subject: [PATCH 10/25] Use convention to create boilerplate record sets --- .../data_transfer/access_record_set.rb | 48 ----------- .../data_transfer/account_record_set.rb | 4 + .../action_text_rich_text_record_set.rb | 4 + .../active_storage_attachment_record_set.rb | 47 ----------- .../active_storage_blob_record_set.rb | 50 ------------ .../data_transfer/assignment_record_set.rb | 47 ----------- .../data_transfer/blob_file_record_set.rb | 4 + .../board_publication_record_set.rb | 46 ----------- .../account/data_transfer/board_record_set.rb | 47 ----------- .../card_activity_spike_record_set.rb | 45 ----------- .../data_transfer/card_goldness_record_set.rb | 45 ----------- .../data_transfer/card_not_now_record_set.rb | 46 ----------- .../account/data_transfer/card_record_set.rb | 52 ------------ .../data_transfer/closure_record_set.rb | 46 ----------- .../data_transfer/column_record_set.rb | 48 ----------- .../data_transfer/comment_record_set.rb | 46 ----------- .../data_transfer/entropy_record_set.rb | 51 ------------ .../account/data_transfer/event_record_set.rb | 50 ------------ .../data_transfer/filter_record_set.rb | 47 ----------- app/models/account/data_transfer/manifest.rb | 79 ++++++++++--------- .../data_transfer/mention_record_set.rb | 54 ------------- .../notification_bundle_record_set.rb | 48 ----------- .../data_transfer/notification_record_set.rb | 49 ------------ .../account/data_transfer/pin_record_set.rb | 46 ----------- .../data_transfer/reaction_record_set.rb | 47 ----------- .../account/data_transfer/record_set.rb | 39 +++++++-- .../account/data_transfer/step_record_set.rb | 47 ----------- .../account/data_transfer/tag_record_set.rb | 45 ----------- .../data_transfer/tagging_record_set.rb | 46 ----------- .../account/data_transfer/user_record_set.rb | 4 + .../account/data_transfer/watch_record_set.rb | 47 ----------- .../webhook_delinquency_tracker_record_set.rb | 47 ----------- .../webhook_delivery_record_set.rb | 49 ------------ .../data_transfer/webhook_record_set.rb | 50 ------------ 34 files changed, 88 insertions(+), 1382 deletions(-) delete mode 100644 app/models/account/data_transfer/access_record_set.rb delete mode 100644 app/models/account/data_transfer/active_storage_attachment_record_set.rb delete mode 100644 app/models/account/data_transfer/active_storage_blob_record_set.rb delete mode 100644 app/models/account/data_transfer/assignment_record_set.rb delete mode 100644 app/models/account/data_transfer/board_publication_record_set.rb delete mode 100644 app/models/account/data_transfer/board_record_set.rb delete mode 100644 app/models/account/data_transfer/card_activity_spike_record_set.rb delete mode 100644 app/models/account/data_transfer/card_goldness_record_set.rb delete mode 100644 app/models/account/data_transfer/card_not_now_record_set.rb delete mode 100644 app/models/account/data_transfer/card_record_set.rb delete mode 100644 app/models/account/data_transfer/closure_record_set.rb delete mode 100644 app/models/account/data_transfer/column_record_set.rb delete mode 100644 app/models/account/data_transfer/comment_record_set.rb delete mode 100644 app/models/account/data_transfer/entropy_record_set.rb delete mode 100644 app/models/account/data_transfer/event_record_set.rb delete mode 100644 app/models/account/data_transfer/filter_record_set.rb delete mode 100644 app/models/account/data_transfer/mention_record_set.rb delete mode 100644 app/models/account/data_transfer/notification_bundle_record_set.rb delete mode 100644 app/models/account/data_transfer/notification_record_set.rb delete mode 100644 app/models/account/data_transfer/pin_record_set.rb delete mode 100644 app/models/account/data_transfer/reaction_record_set.rb delete mode 100644 app/models/account/data_transfer/step_record_set.rb delete mode 100644 app/models/account/data_transfer/tag_record_set.rb delete mode 100644 app/models/account/data_transfer/tagging_record_set.rb delete mode 100644 app/models/account/data_transfer/watch_record_set.rb delete mode 100644 app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb delete mode 100644 app/models/account/data_transfer/webhook_delivery_record_set.rb delete mode 100644 app/models/account/data_transfer/webhook_record_set.rb diff --git a/app/models/account/data_transfer/access_record_set.rb b/app/models/account/data_transfer/access_record_set.rb deleted file mode 100644 index 0f78e3c7a7..0000000000 --- a/app/models/account/data_transfer/access_record_set.rb +++ /dev/null @@ -1,48 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/account_record_set.rb b/app/models/account/data_transfer/account_record_set.rb index c27e03101a..86bc790674 100644 --- a/app/models/account/data_transfer/account_record_set.rb +++ b/app/models/account/data_transfer/account_record_set.rb @@ -10,6 +10,10 @@ class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet usage_limit ] + def initialize(account) + super(account: account, model: Account) + end + private def records [ account ] diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb index 355c5d71b6..0ba663e1ea 100644 --- a/app/models/account/data_transfer/action_text_rich_text_record_set.rb +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -10,6 +10,10 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer updated_at ].freeze + def initialize(account) + super(account: account, model: ActionText::RichText) + end + private def records ActionText::RichText.where(account: account) diff --git a/app/models/account/data_transfer/active_storage_attachment_record_set.rb b/app/models/account/data_transfer/active_storage_attachment_record_set.rb deleted file mode 100644 index f33fe5bb4a..0000000000 --- a/app/models/account/data_transfer/active_storage_attachment_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/active_storage_blob_record_set.rb b/app/models/account/data_transfer/active_storage_blob_record_set.rb deleted file mode 100644 index 43aa55b1ef..0000000000 --- a/app/models/account/data_transfer/active_storage_blob_record_set.rb +++ /dev/null @@ -1,50 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/assignment_record_set.rb b/app/models/account/data_transfer/assignment_record_set.rb deleted file mode 100644 index 955946c2da..0000000000 --- a/app/models/account/data_transfer/assignment_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/blob_file_record_set.rb b/app/models/account/data_transfer/blob_file_record_set.rb index 233985a0b5..c17070ed27 100644 --- a/app/models/account/data_transfer/blob_file_record_set.rb +++ b/app/models/account/data_transfer/blob_file_record_set.rb @@ -1,4 +1,8 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet + def initialize(account) + super(account: account, model: ActiveStorage::Blob) + end + private def records ActiveStorage::Blob.where(account: account) diff --git a/app/models/account/data_transfer/board_publication_record_set.rb b/app/models/account/data_transfer/board_publication_record_set.rb deleted file mode 100644 index 922257be56..0000000000 --- a/app/models/account/data_transfer/board_publication_record_set.rb +++ /dev/null @@ -1,46 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/board_record_set.rb b/app/models/account/data_transfer/board_record_set.rb deleted file mode 100644 index 752e1100be..0000000000 --- a/app/models/account/data_transfer/board_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/card_activity_spike_record_set.rb b/app/models/account/data_transfer/card_activity_spike_record_set.rb deleted file mode 100644 index 61c73eb19b..0000000000 --- a/app/models/account/data_transfer/card_activity_spike_record_set.rb +++ /dev/null @@ -1,45 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/card_goldness_record_set.rb b/app/models/account/data_transfer/card_goldness_record_set.rb deleted file mode 100644 index fe488cbec3..0000000000 --- a/app/models/account/data_transfer/card_goldness_record_set.rb +++ /dev/null @@ -1,45 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/card_not_now_record_set.rb b/app/models/account/data_transfer/card_not_now_record_set.rb deleted file mode 100644 index 4ee68977a7..0000000000 --- a/app/models/account/data_transfer/card_not_now_record_set.rb +++ /dev/null @@ -1,46 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/card_record_set.rb b/app/models/account/data_transfer/card_record_set.rb deleted file mode 100644 index 8542b73965..0000000000 --- a/app/models/account/data_transfer/card_record_set.rb +++ /dev/null @@ -1,52 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/closure_record_set.rb b/app/models/account/data_transfer/closure_record_set.rb deleted file mode 100644 index b1b3214380..0000000000 --- a/app/models/account/data_transfer/closure_record_set.rb +++ /dev/null @@ -1,46 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/column_record_set.rb b/app/models/account/data_transfer/column_record_set.rb deleted file mode 100644 index 4706d5adcc..0000000000 --- a/app/models/account/data_transfer/column_record_set.rb +++ /dev/null @@ -1,48 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/comment_record_set.rb b/app/models/account/data_transfer/comment_record_set.rb deleted file mode 100644 index 70dc2bab5e..0000000000 --- a/app/models/account/data_transfer/comment_record_set.rb +++ /dev/null @@ -1,46 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/entropy_record_set.rb b/app/models/account/data_transfer/entropy_record_set.rb deleted file mode 100644 index 193a627378..0000000000 --- a/app/models/account/data_transfer/entropy_record_set.rb +++ /dev/null @@ -1,51 +0,0 @@ -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 - if missing.any? - 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 diff --git a/app/models/account/data_transfer/event_record_set.rb b/app/models/account/data_transfer/event_record_set.rb deleted file mode 100644 index 42ccb80fed..0000000000 --- a/app/models/account/data_transfer/event_record_set.rb +++ /dev/null @@ -1,50 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/filter_record_set.rb b/app/models/account/data_transfer/filter_record_set.rb deleted file mode 100644 index 01f8e8ba32..0000000000 --- a/app/models/account/data_transfer/filter_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index 91ce8028ee..ffa65ad2fe 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -1,43 +1,6 @@ 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) @@ -47,8 +10,46 @@ def initialize(account) 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) + record_sets.each do |record_set| + yield record_set end end + + private + def record_sets + [ + Account::DataTransfer::AccountRecordSet.new(account), + Account::DataTransfer::UserRecordSet.new(account), + Account::DataTransfer::RecordSet.new(account: account, model: ::Tag), + Account::DataTransfer::RecordSet.new(account: account, model: ::Board), + Account::DataTransfer::RecordSet.new(account: account, model: ::Column), + Account::DataTransfer::RecordSet.new(account: account, model: ::Entropy), + Account::DataTransfer::RecordSet.new(account: account, model: ::Board::Publication), + Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook), + Account::DataTransfer::RecordSet.new(account: account, model: ::Access), + Account::DataTransfer::RecordSet.new(account: account, model: ::Card), + Account::DataTransfer::RecordSet.new(account: account, model: ::Comment), + Account::DataTransfer::RecordSet.new(account: account, model: ::Step), + Account::DataTransfer::RecordSet.new(account: account, model: ::Assignment), + Account::DataTransfer::RecordSet.new(account: account, model: ::Tagging), + Account::DataTransfer::RecordSet.new(account: account, model: ::Closure), + Account::DataTransfer::RecordSet.new(account: account, model: ::Card::Goldness), + Account::DataTransfer::RecordSet.new(account: account, model: ::Card::NotNow), + Account::DataTransfer::RecordSet.new(account: account, model: ::Card::ActivitySpike), + Account::DataTransfer::RecordSet.new(account: account, model: ::Watch), + Account::DataTransfer::RecordSet.new(account: account, model: ::Pin), + Account::DataTransfer::RecordSet.new(account: account, model: ::Reaction), + Account::DataTransfer::RecordSet.new(account: account, model: ::Mention), + Account::DataTransfer::RecordSet.new(account: account, model: ::Filter), + Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook::DelinquencyTracker), + Account::DataTransfer::RecordSet.new(account: account, model: ::Event), + Account::DataTransfer::RecordSet.new(account: account, model: ::Notification), + Account::DataTransfer::RecordSet.new(account: account, model: ::Notification::Bundle), + Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook::Delivery), + Account::DataTransfer::RecordSet.new(account: account, model: ::ActiveStorage::Blob), + Account::DataTransfer::RecordSet.new(account: account, model: ::ActiveStorage::Attachment), + Account::DataTransfer::ActionTextRichTextRecordSet.new(account), + Account::DataTransfer::BlobFileRecordSet.new(account) + ] + end end diff --git a/app/models/account/data_transfer/mention_record_set.rb b/app/models/account/data_transfer/mention_record_set.rb deleted file mode 100644 index 253fb8ee4f..0000000000 --- a/app/models/account/data_transfer/mention_record_set.rb +++ /dev/null @@ -1,54 +0,0 @@ -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 - if missing.any? - 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 diff --git a/app/models/account/data_transfer/notification_bundle_record_set.rb b/app/models/account/data_transfer/notification_bundle_record_set.rb deleted file mode 100644 index fd468ef891..0000000000 --- a/app/models/account/data_transfer/notification_bundle_record_set.rb +++ /dev/null @@ -1,48 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/notification_record_set.rb b/app/models/account/data_transfer/notification_record_set.rb deleted file mode 100644 index 260e388ab0..0000000000 --- a/app/models/account/data_transfer/notification_record_set.rb +++ /dev/null @@ -1,49 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/pin_record_set.rb b/app/models/account/data_transfer/pin_record_set.rb deleted file mode 100644 index fd4c33e7dd..0000000000 --- a/app/models/account/data_transfer/pin_record_set.rb +++ /dev/null @@ -1,46 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/reaction_record_set.rb b/app/models/account/data_transfer/reaction_record_set.rb deleted file mode 100644 index 6b145a2e66..0000000000 --- a/app/models/account/data_transfer/reaction_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index ff8a7d5758..c2dc094264 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -3,10 +3,12 @@ class IntegrityError < StandardError; end IMPORT_BATCH_SIZE = 100 - attr_reader :account + attr_reader :account, :model, :attributes - def initialize(account) + def initialize(account:, model:, attributes: nil) @account = account + @model = model + @attributes = (attributes || model.column_names).map(&:to_s) end def export(to:, start: nil) @@ -49,23 +51,42 @@ def with_zip(zip) end def records - [] + model.where(account_id: account.id) end def export_record(record) - raise NotImplementedError + zip.add_file "data/#{model_dir}/#{record.id}.json", record.to_json end def files - [] + zip.glob("data/#{model_dir}/*.json") end def import_batch(files) - raise NotImplementedError + batch_data = files.map do |file| + data = load(file) + data.slice(*attributes).merge("account_id" => account.id) + end + + model.insert_all!(batch_data) end def validate_record(file_path) - raise NotImplementedError + data = load(file_path) + expected_id = File.basename(file_path, ".json") + + unless data["id"].to_s == expected_id + raise IntegrityError, "#{model} record ID mismatch: expected #{expected_id}, got #{data['id']}" + end + + missing = attributes - data.keys + if missing.any? + raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" + end + + if model.exists?(id: data["id"]) + raise IntegrityError, "#{model} record with ID #{data['id']} already exists" + end end def load(file_path) @@ -73,4 +94,8 @@ def load(file_path) rescue ArgumentError => e raise IntegrityError, e.message end + + def model_dir + model.table_name + end end diff --git a/app/models/account/data_transfer/step_record_set.rb b/app/models/account/data_transfer/step_record_set.rb deleted file mode 100644 index 1f82c00aaa..0000000000 --- a/app/models/account/data_transfer/step_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/tag_record_set.rb b/app/models/account/data_transfer/tag_record_set.rb deleted file mode 100644 index 3a47255016..0000000000 --- a/app/models/account/data_transfer/tag_record_set.rb +++ /dev/null @@ -1,45 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/tagging_record_set.rb b/app/models/account/data_transfer/tagging_record_set.rb deleted file mode 100644 index e925cf68e1..0000000000 --- a/app/models/account/data_transfer/tagging_record_set.rb +++ /dev/null @@ -1,46 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/user_record_set.rb b/app/models/account/data_transfer/user_record_set.rb index c77e7bbec5..277dc82c34 100644 --- a/app/models/account/data_transfer/user_record_set.rb +++ b/app/models/account/data_transfer/user_record_set.rb @@ -10,6 +10,10 @@ class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet updated_at ] + def initialize(account) + super(account: account, model: User) + end + private def records User.where(account: account) diff --git a/app/models/account/data_transfer/watch_record_set.rb b/app/models/account/data_transfer/watch_record_set.rb deleted file mode 100644 index 3c86da7703..0000000000 --- a/app/models/account/data_transfer/watch_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb b/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb deleted file mode 100644 index a31600bdb2..0000000000 --- a/app/models/account/data_transfer/webhook_delinquency_tracker_record_set.rb +++ /dev/null @@ -1,47 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/webhook_delivery_record_set.rb b/app/models/account/data_transfer/webhook_delivery_record_set.rb deleted file mode 100644 index c26c3c0dc5..0000000000 --- a/app/models/account/data_transfer/webhook_delivery_record_set.rb +++ /dev/null @@ -1,49 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end diff --git a/app/models/account/data_transfer/webhook_record_set.rb b/app/models/account/data_transfer/webhook_record_set.rb deleted file mode 100644 index 64147ba6df..0000000000 --- a/app/models/account/data_transfer/webhook_record_set.rb +++ /dev/null @@ -1,50 +0,0 @@ -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 - if missing.any? - raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" - end - end -end From be608aaeba738d0b7e981f336b45d541e6ae30ec Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Jan 2026 10:25:14 -0600 Subject: [PATCH 11/25] Touch up export modals --- app/views/account/settings/_export.html.erb | 10 +++++----- app/views/users/_data_export.html.erb | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb index 2b831837c1..ad2d8392a6 100644 --- a/app/views/account/settings/_export.html.erb +++ b/app/views/account/settings/_export.html.erb @@ -4,13 +4,13 @@
Download a complete archive of all account data.

-
+
- -

Export all account data

-

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

-

When the file is ready, we'll email you a link to download it. The link will expire after 24 hours.

+ +

Export all account data

+

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

+

We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.

<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> diff --git a/app/views/users/_data_export.html.erb b/app/views/users/_data_export.html.erb index 2359fbbefa..566d41f0b9 100644 --- a/app/views/users/_data_export.html.erb +++ b/app/views/users/_data_export.html.erb @@ -4,13 +4,13 @@
Download an archive of your Fizzy data.
-
+
- -

Export your data

-

This will generate a ZIP archive of all cards you have access to.

-

When ready, we'll email you a download link (expires in 24 hours).

+ +

Export your data

+

This will generate a ZIP archive of all cards you have access to.

+

We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.

<%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> From 6e47be1e44ed02b0fa7af65f3b41833dbac9cdb1 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Jan 2026 10:37:05 -0600 Subject: [PATCH 12/25] Polish the session menu page --- app/views/sessions/menus/show.html.erb | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 84dd43c87e..e3843321e4 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -1,15 +1,15 @@ <% @page_title = "Choose an account" %> <% cache [ Current.identity, @accounts ] do %> -
<% if @accounts.any? %> -

+

Your Fizzy accounts

- + <% @accounts.each do |account| %>
+ <%= link_to new_signup_path, class: "btn center txt-small margin-block-start", data: { turbo_prefetch: false } do %> + Sign up for a new Fizzy account + <% end %> + + <%= link_to new_import_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %> + Import an exported account + <% end %> +
<% end %> <% content_for :footer do %> From c8f7a512d6fda8111df1a32d6566c6d20a95ec1c Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Jan 2026 12:02:00 -0600 Subject: [PATCH 13/25] Nicer file input --- app/assets/stylesheets/inputs.css | 47 ++++++++++++++----- .../controllers/upload_preview_controller.js | 27 +++++++++-- app/views/imports/new.html.erb | 22 +++++---- app/views/imports/show.html.erb | 2 +- 4 files changed, 70 insertions(+), 28 deletions(-) diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 252ef28357..573bb5e38d 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -57,18 +57,12 @@ } } - .input--file { + .input--file, + .input--upload { cursor: pointer; - display: grid; - inline-size: auto; - place-items: center; - - > * { - grid-area: 1 / 1; - } - img { - border-radius: 0.4em; + &:has(input[type="file"]:focus-visible) { + outline: 0.15rem solid var(--color-selected-dark); } input[type="file"] { @@ -88,10 +82,19 @@ opacity: 0; } } + } - &:has(input[type="file"]:focus), - &:has(input[type="file"]:focus-visible) { - outline: 0.15rem solid var(--color-selected-dark); + .input--file { + display: grid; + inline-size: auto; + place-items: center; + + > * { + grid-area: 1 / 1; + } + + img { + border-radius: 0.4em; } &:is(.avatar) { @@ -101,6 +104,24 @@ } } + .input--upload { + --btn-border-color: var(--color-ink); + + border-style: dashed; + position: relative; + + input[type="file"] { + inset: 0; + outline: none; + position: absolute; + } + + &:has([data-upload-preview-target="fileName"]:not([hidden])) { + --btn-border-color: var(--color-positive); + --btn-color: var(--color-positive); + } + } + .input--select { --input-border-radius: 2em; --input-padding: 0.5em 1.8em 0.5em 1.2em; diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js index 14cc5bfee2..d7b0bd4783 100644 --- a/app/javascript/controllers/upload_preview_controller.js +++ b/app/javascript/controllers/upload_preview_controller.js @@ -1,14 +1,31 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = [ "image", "input" ] + static targets = [ "image", "input", "fileName", "placeholder" ] previewImage() { - const file = this.inputTarget.files[0] - - if (file) { - this.imageTarget.src = URL.createObjectURL(file) + if (this.#file) { + this.imageTarget.src = URL.createObjectURL(this.#file) this.imageTarget.onload = () => URL.revokeObjectURL(this.imageTarget.src) } } + + previewFileName() { + this.#file ? this.#showFileName() : this.#showPlaceholder() + } + + #showFileName() { + this.fileNameTarget.innerHTML = this.#file.name + this.fileNameTarget.removeAttribute("hidden") + this.placeholderTarget.setAttribute("hidden", true) + } + + #showPlaceholder() { + this.placeholderTarget.removeAttribute("hidden") + this.fileNameTarget.setAttribute("hidden", true) + } + + get #file() { + return this.inputTarget.files[0] + } } diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 138be6758b..d1acb4dd28 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -1,17 +1,21 @@ <% @page_title = "Import an account" %> -
-

Import an account

+
+
+

Import a Fizzy account

+
Upload the .zip file from your Fizzy export
+
- <%= form_with url: imports_path, class: "flex flex-column gap", data: { controller: "form" }, multipart: true do |form| %> -
- <%= form.file_field :file, accept: ".zip", required: true, class: "input" %> -

Upload the .zip file from your Fizzy export.

-
+ <%= form_with url: imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %> + + <% end %>
diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index acc2c187ec..0b184f6c42 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -6,7 +6,7 @@ <% case @import.status %> <% when "pending", "processing" %>

Your import is in progress. This may take a while for large accounts.

-

This page will refresh automatically.

+

This page will refresh automatically.

<% when "completed" %>

Your import has completed successfully!

From 6c16652e5ebb1f270f305bb1848196e793cbc4cb Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Jan 2026 12:08:42 -0600 Subject: [PATCH 14/25] Better instructions --- app/views/imports/new.html.erb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index d1acb4dd28..ca90c32ffa 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -3,7 +3,9 @@

Import a Fizzy account

-
Upload the .zip file from your Fizzy export
+ + +
Create an account using data from a Fizzy export. Upload the exported .zip file below.
<%= form_with url: imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %> @@ -14,7 +16,7 @@ <%= form.file_field :file, accept: ".zip", required: true, data: { action: "upload-preview#previewFileName", upload_preview_target: "input" } %> - <% end %> From 2a00f2afc0b696b4c2e4dec9f5ab4d32e838105b Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Tue, 20 Jan 2026 15:33:37 -0600 Subject: [PATCH 15/25] Touch up the Download Export page --- app/views/account/exports/show.html.erb | 14 ++++++-------- app/views/imports/new.html.erb | 1 - app/views/users/data_exports/show.html.erb | 14 ++++++-------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb index f01c13dc5a..77fd79b0d6 100644 --- a/app/views/account/exports/show.html.erb +++ b/app/views/account/exports/show.html.erb @@ -10,20 +10,18 @@
<% end %> -
-

<%= @page_title %>

+
+

<%= @page_title %>

<% if @export.present? %> -

Your export is ready. The download should start automatically.

+
Your export is ready. The download should start automatically.
- <%= link_to rails_blob_path(@export.file, disposition: "attachment"), + <%= link_to "Download your data", rails_blob_path(@export.file, disposition: "attachment"), id: "download-link", class: "btn btn--link", - data: { turbo: false, controller: "auto-click" } do %> - Download your data - <% end %> + data: { turbo: false, controller: "auto-click" } %> <% else %> -

That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.

+
That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-link" %>.
<% end %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index ca90c32ffa..886a0b48f1 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -9,7 +9,6 @@ <%= form_with url: imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %> -
<% end %> -
-

<%= @page_title %>

+
+

<%= @page_title %>

<% if @export.present? %> -

Your export is ready. The download should start automatically.

+
Your export is ready. The download should start automatically.
- <%= link_to rails_blob_path(@export.file, disposition: "attachment"), + <%= link_to "Download your data", rails_blob_path(@export.file, disposition: "attachment"), id: "download-link", class: "btn btn--link", - data: { turbo: false, controller: "auto-click" } do %> - Download your data - <% end %> + data: { turbo: false, controller: "auto-click" } %> <% else %> -

That download link has expired. You'll need to <%= link_to "request a new export", user_path(@user), class: "txt-lnk" %>.

+
That download link has expired. You'll need to <%= link_to "request a new export", user_path(@user), class: "txt-link" %>.
<% end %>
From e8c3b52be568acbb809a96899ca4b0039341cdf8 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 21 Jan 2026 12:00:00 -0600 Subject: [PATCH 16/25] Smaller border radius to handle long file names --- app/assets/stylesheets/inputs.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 573bb5e38d..95bd3d00f3 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -106,6 +106,7 @@ .input--upload { --btn-border-color: var(--color-ink); + --btn-border-radius: 1ch; border-style: dashed; position: relative; From d22ea58bcd5cf4919b95cda497dd4db65cfd6746 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 26 Jan 2026 15:14:12 +0100 Subject: [PATCH 17/25] Fix crash when exporting ActiveStorage files --- app/models/account/data_transfer/zip_file.rb | 2 +- test/models/account/export_test.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/models/account/data_transfer/zip_file.rb b/app/models/account/data_transfer/zip_file.rb index 058476625e..b79771927d 100644 --- a/app/models/account/data_transfer/zip_file.rb +++ b/app/models/account/data_transfer/zip_file.rb @@ -26,7 +26,7 @@ def initialize(zip) 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) + zip.get_output_stream(path, compression_method: compression, &block) else zip.get_output_stream(path) { |f| f.write(content) } end diff --git a/test/models/account/export_test.rb b/test/models/account/export_test.rb index f4171f9da8..8d3b433fe0 100644 --- a/test/models/account/export_test.rb +++ b/test/models/account/export_test.rb @@ -41,4 +41,22 @@ class Account::ExportTest < ActiveSupport::TestCase assert export.file.attached? assert_equal "application/zip", export.file.content_type end + + test "build includes blob files in zip" do + blob = ActiveStorage::Blob.create_and_upload!( + io: file_fixture("moon.jpg").open, + filename: "moon.jpg", + content_type: "image/jpeg" + ) + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + export.file.open do |file| + Zip::File.open(file.path) do |zip| + assert zip.find_entry("storage/#{blob.key}"), "Expected blob file in zip" + end + end + end end From d38e8dcb9e52162227ce4665a423ad2e27bebbfd Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 11:31:30 +0100 Subject: [PATCH 18/25] Fix account creation and import updates --- app/controllers/imports_controller.rb | 32 ++++++++++++--------------- app/models/signup.rb | 4 ++-- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 76cb41c830..a791c1929f 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,5 +1,5 @@ class ImportsController < ApplicationController - disallow_account_scope + disallow_account_scope only: %i[ new create ] layout "public" @@ -7,32 +7,28 @@ def new end def create - account = create_account_for_import + signup = Signup.new(identity: Current.identity, full_name: "Import", skip_account_seeding: true) - Current.set(account: account) do - @import = account.imports.create!(identity: Current.identity, file: params[:file]) + if signup.complete + start_import(signup.account) + else + render :new, alert: "Couldn't create account." end - - @import.perform_later - redirect_to import_path(@import) end def show - @import = Current.identity.imports.find(params[:id]) + @import = Current.account.imports.find(params[:id]) end private - def create_account_for_import - Account.create_with_owner( - account: { name: account_name_from_zip }, - owner: { name: Current.identity.email_address.split("@").first, identity: Current.identity } - ) - end + def start_import(account) + import = nil - def account_name_from_zip - Zip::File.open(params[:file].tempfile.path) do |zip| - entry = zip.find_entry("data/account.json") - JSON.parse(entry.get_input_stream.read)["name"] + Current.set(account: account) do + import = account.imports.create!(identity: Current.identity, file: params[:file]) + import.process_later end + + redirect_to import_path(import, script_name: account.slug) end end diff --git a/app/models/signup.rb b/app/models/signup.rb index 041f461acc..2f096165de 100644 --- a/app/models/signup.rb +++ b/app/models/signup.rb @@ -3,7 +3,7 @@ class Signup include ActiveModel::Attributes include ActiveModel::Validations - attr_accessor :full_name, :email_address, :identity + attr_accessor :full_name, :email_address, :identity, :skip_account_seeding attr_reader :account, :user validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation @@ -65,7 +65,7 @@ def create_account } ) @user = @account.users.find_by!(role: :owner) - @account.setup_customer_template + @account.setup_customer_template unless skip_account_seeding end def generate_account_name From 89046d86998c8741118733402fae8c8ac89ed9c6 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 11:32:26 +0100 Subject: [PATCH 19/25] Fix orphaned Entropy records upon Account destruction --- app/models/account.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/account.rb b/app/models/account.rb index cd19eba5eb..7087a5b9b6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -8,6 +8,7 @@ class Account < ApplicationRecord has_many :webhooks, dependent: :destroy has_many :tags, dependent: :destroy has_many :columns, dependent: :destroy + has_many :entropies, dependent: :destroy has_many :exports, class_name: "Account::Export", dependent: :destroy has_many :imports, class_name: "Account::Import", dependent: :destroy From 5be35ac1a67c7c58ac595af8623907a19684db80 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 11:33:58 +0100 Subject: [PATCH 20/25] Push updates via Turbo --- app/views/imports/show.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index 0b184f6c42..a879996e13 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -1,13 +1,13 @@ <% @page_title = "Import status" %> +<%= turbo_stream_from @import %> +

Import status

<% case @import.status %> <% when "pending", "processing" %>

Your import is in progress. This may take a while for large accounts.

-

This page will refresh automatically.

- <% when "completed" %>

Your import has completed successfully!

<%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %> From aa46d16fc1801e14be22360ca09df52d8d18576d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 11:35:16 +0100 Subject: [PATCH 21/25] Remove unused association --- app/models/identity.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/identity.rb b/app/models/identity.rb index 33955a8b2b..7495e37c3f 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -2,7 +2,6 @@ class Identity < ApplicationRecord include Joinable, Transferable has_many :access_tokens, dependent: :destroy - has_many :imports, class_name: "Account::Import", dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify From 66c952fd0ab96263ae8e57967ae12d9c442ba8b7 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 11:35:46 +0100 Subject: [PATCH 22/25] Resolve import conflicts --- .../data_transfer/entropy_record_set.rb | 32 ++++++++++++ app/models/account/data_transfer/manifest.rb | 3 +- .../account/data_transfer/record_set.rb | 10 ++++ .../account/data_transfer/user_record_set.rb | 3 ++ app/models/account/import.rb | 3 ++ test/models/account/import_test.rb | 50 +++++++++++++++++++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/models/account/data_transfer/entropy_record_set.rb diff --git a/app/models/account/data_transfer/entropy_record_set.rb b/app/models/account/data_transfer/entropy_record_set.rb new file mode 100644 index 0000000000..e620a8121b --- /dev/null +++ b/app/models/account/data_transfer/entropy_record_set.rb @@ -0,0 +1,32 @@ +class Account::DataTransfer::EntropyRecordSet < Account::DataTransfer::RecordSet + def initialize(account) + super(account: account, model: Entropy) + end + + private + def import_batch(files) + batch_data = files.map do |file| + data = load(file) + data.slice(*attributes).merge("account_id" => account.id) + end + + container_keys = batch_data.map { |d| [ d["container_type"], d["container_id"] ] } + existing_containers = Entropy + .where(account_id: account.id) + .where(container_type: container_keys.map(&:first), container_id: container_keys.map(&:last)) + .pluck(:container_type, :container_id) + .to_set + + to_update, to_insert = batch_data.partition do |data| + existing_containers.include?([ data["container_type"], data["container_id"] ]) + end + + to_update.each do |data| + Entropy + .find_by(account_id: account.id, container_type: data["container_type"], container_id: data["container_id"]) + .update!(data.slice("auto_postpone_period")) + end + + Entropy.insert_all!(to_insert) if to_insert.any? + end +end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index ffa65ad2fe..e764f69c15 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -20,10 +20,11 @@ def record_sets [ Account::DataTransfer::AccountRecordSet.new(account), Account::DataTransfer::UserRecordSet.new(account), + Account::DataTransfer::RecordSet.new(account: account, model: ::User::Settings), Account::DataTransfer::RecordSet.new(account: account, model: ::Tag), Account::DataTransfer::RecordSet.new(account: account, model: ::Board), Account::DataTransfer::RecordSet.new(account: account, model: ::Column), - Account::DataTransfer::RecordSet.new(account: account, model: ::Entropy), + Account::DataTransfer::EntropyRecordSet.new(account), Account::DataTransfer::RecordSet.new(account: account, model: ::Board::Publication), Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook), Account::DataTransfer::RecordSet.new(account: account, model: ::Access), diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index c2dc094264..b3fd9d5c11 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -87,6 +87,16 @@ def validate_record(file_path) if model.exists?(id: data["id"]) raise IntegrityError, "#{model} record with ID #{data['id']} already exists" end + + record = model.new(data.slice(*attributes).merge("account_id" => account.id)) + belongs_to_associations = model.reflect_on_all_associations(:belongs_to).map { |a| a.name.to_s } + + record.validate + errors = record.errors.reject { |e| belongs_to_associations.include?(e.attribute.to_s) } + + if errors.any? + raise IntegrityError, "Validation failed for #{model} record ID #{data['id']}: #{errors.map(&:full_message).join(', ')}" + end end def load(file_path) diff --git a/app/models/account/data_transfer/user_record_set.rb b/app/models/account/data_transfer/user_record_set.rb index 277dc82c34..a0a140f2dd 100644 --- a/app/models/account/data_transfer/user_record_set.rb +++ b/app/models/account/data_transfer/user_record_set.rb @@ -40,6 +40,9 @@ def import_batch(files) ) end + conflicting_identity_ids = batch_data.pluck("identity_id").compact + account.users.where(identity_id: conflicting_identity_ids).destroy_all + User.insert_all!(batch_data) end diff --git a/app/models/account/import.rb b/app/models/account/import.rb index a68a1b37bf..1e98ce2e17 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -1,4 +1,6 @@ class Account::Import < ApplicationRecord + broadcasts_refreshes + belongs_to :account belongs_to :identity @@ -7,6 +9,7 @@ class Account::Import < ApplicationRecord enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending def process_later + ImportAccountDataJob.perform_later(self) end def process(start: nil, callback: nil) diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index 1abada2a3a..2d357da1f3 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -93,6 +93,45 @@ class Account::ImportTest < ActiveSupport::TestCase assert import.completed? end + test "export and import round-trip preserves account data" do + source_account = accounts("37s") + exporter = users(:david) + identity = exporter.identity + + # Capture original counts + original_counts = capture_counts(source_account) + + # Export + export = Account::Export.create!(account: source_account, user: exporter) + export.build + assert export.completed? + + # Save export file before deleting account + export_tempfile = Tempfile.new([ "export", ".zip" ]) + export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) } + + # Delete source account + source_account.destroy! + + # Create target account + target_account = Account.create!(name: "Import Target") + target_account.users.create!(role: :system, name: "System") + target_account.users.create!(role: :owner, name: "Owner", identity: identity, verified_at: Time.current) + + # Import + import = Account::Import.create!(identity: identity, account: target_account) + Current.set(account: target_account) do + import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip") + end + import.process + assert import.completed? + + assert_equal original_counts.except(:users), capture_counts(target_account).except(:users) + ensure + export_tempfile&.close + export_tempfile&.unlink + end + private def create_target_account account = Account.create!(name: "Import Target") @@ -202,4 +241,15 @@ def generate_invalid_export_zip end File.open(tempfile.path, "rb") end + + def capture_counts(account) + { + boards: account.boards.count, + columns: Column.joins(:board).where(boards: { account_id: account.id }).count, + cards: account.cards.count, + comments: Comment.joins(:card).where(cards: { account_id: account.id }).count, + tags: account.tags.count, + users: account.users.count + } + end end From 4968a8be7382319e3b2964c68fb79fe9fa96f58e Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 14:46:40 +0100 Subject: [PATCH 23/25] Implement cursors for imports --- app/jobs/import_account_data_job.rb | 8 ++- app/mailers/import_mailer.rb | 7 ++- app/models/account/data_transfer/manifest.rb | 55 ++++++++----------- .../account/data_transfer/record_set.rb | 20 ++++++- app/models/account/import.rb | 11 ++-- app/models/export.rb | 9 ++- .../mailers/import_mailer/completed.html.erb | 4 +- .../mailers/import_mailer/completed.text.erb | 4 +- test/models/account/import_test.rb | 6 +- 9 files changed, 70 insertions(+), 54 deletions(-) diff --git a/app/jobs/import_account_data_job.rb b/app/jobs/import_account_data_job.rb index e131a41e91..863aa111fe 100644 --- a/app/jobs/import_account_data_job.rb +++ b/app/jobs/import_account_data_job.rb @@ -7,13 +7,17 @@ def perform(import) step :validate do import.validate \ start: step.cursor, - callback: proc { |record_set:, record_id:| step.set! [ record_set, record_id ] } + callback: proc do |record_set:, file:| + step.set!([ record_set.model.name, file ]) + end end step :process do import.process \ start: step.cursor, - callback: proc { |record_set:, record_id:| step.set! [ record_set, record_id ] } + callback: proc do |record_set:, files:| + step.set!([ record_set.model.name, files.last ]) + end end end end diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb index 5040a224e0..df557ad057 100644 --- a/app/mailers/import_mailer.rb +++ b/app/mailers/import_mailer.rb @@ -1,9 +1,12 @@ class ImportMailer < ApplicationMailer - def completed(identity) + def completed(identity, account) + @account = account + @landing_url = landing_url(script_name: account.slug) mail to: identity.email_address, subject: "Your Fizzy account import is complete" end - def failed(identity) + def failed(identity, account) + @account = account mail to: identity.email_address, subject: "Your Fizzy account import failed" end end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index e764f69c15..33b7e1e93d 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -1,6 +1,4 @@ class Account::DataTransfer::Manifest - Cursor = Struct.new(:record_class, :last_id) - attr_reader :account def initialize(account) @@ -10,8 +8,16 @@ def initialize(account) def each_record_set(start: nil) raise ArgumentError, "No block given" unless block_given? + started = start.nil? + record_class, last_id = start if start + record_sets.each do |record_set| - yield record_set + if started + yield record_set + elsif record_set.model.name == record_class + started = true + yield record_set, last_id + end end end @@ -20,37 +26,24 @@ def record_sets [ Account::DataTransfer::AccountRecordSet.new(account), Account::DataTransfer::UserRecordSet.new(account), - Account::DataTransfer::RecordSet.new(account: account, model: ::User::Settings), - Account::DataTransfer::RecordSet.new(account: account, model: ::Tag), - Account::DataTransfer::RecordSet.new(account: account, model: ::Board), - Account::DataTransfer::RecordSet.new(account: account, model: ::Column), + *build_record_sets(::User::Settings, ::Tag, ::Board, ::Column), Account::DataTransfer::EntropyRecordSet.new(account), - Account::DataTransfer::RecordSet.new(account: account, model: ::Board::Publication), - Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook), - Account::DataTransfer::RecordSet.new(account: account, model: ::Access), - Account::DataTransfer::RecordSet.new(account: account, model: ::Card), - Account::DataTransfer::RecordSet.new(account: account, model: ::Comment), - Account::DataTransfer::RecordSet.new(account: account, model: ::Step), - Account::DataTransfer::RecordSet.new(account: account, model: ::Assignment), - Account::DataTransfer::RecordSet.new(account: account, model: ::Tagging), - Account::DataTransfer::RecordSet.new(account: account, model: ::Closure), - Account::DataTransfer::RecordSet.new(account: account, model: ::Card::Goldness), - Account::DataTransfer::RecordSet.new(account: account, model: ::Card::NotNow), - Account::DataTransfer::RecordSet.new(account: account, model: ::Card::ActivitySpike), - Account::DataTransfer::RecordSet.new(account: account, model: ::Watch), - Account::DataTransfer::RecordSet.new(account: account, model: ::Pin), - Account::DataTransfer::RecordSet.new(account: account, model: ::Reaction), - Account::DataTransfer::RecordSet.new(account: account, model: ::Mention), - Account::DataTransfer::RecordSet.new(account: account, model: ::Filter), - Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook::DelinquencyTracker), - Account::DataTransfer::RecordSet.new(account: account, model: ::Event), - Account::DataTransfer::RecordSet.new(account: account, model: ::Notification), - Account::DataTransfer::RecordSet.new(account: account, model: ::Notification::Bundle), - Account::DataTransfer::RecordSet.new(account: account, model: ::Webhook::Delivery), - Account::DataTransfer::RecordSet.new(account: account, model: ::ActiveStorage::Blob), - Account::DataTransfer::RecordSet.new(account: account, model: ::ActiveStorage::Attachment), + *build_record_sets( + ::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step, + ::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow, + ::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention, + ::Filter, ::Webhook::DelinquencyTracker, ::Event, + ::Notification, ::Notification::Bundle, ::Webhook::Delivery, + ::ActiveStorage::Blob, ::ActiveStorage::Attachment + ), Account::DataTransfer::ActionTextRichTextRecordSet.new(account), Account::DataTransfer::BlobFileRecordSet.new(account) ] end + + def build_record_sets(*models) + models.map do |model| + Account::DataTransfer::RecordSet.new(account: account, model: model) + end + end end diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index b3fd9d5c11..61359b0fda 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -23,7 +23,10 @@ def export(to:, start: nil) def import(from:, start: nil, callback: nil) with_zip(from) do - files.each_slice(IMPORT_BATCH_SIZE) do |file_batch| + file_list = files + file_list = skip_to(file_list, start) if start + + file_list.each_slice(IMPORT_BATCH_SIZE) do |file_batch| import_batch(file_batch) callback&.call(record_set: self, files: file_batch) end @@ -32,7 +35,10 @@ def import(from:, start: nil, callback: nil) def validate(from:, start: nil, callback: nil) with_zip(from) do - files.each do |file_path| + file_list = files + file_list = skip_to(file_list, start) if start + + file_list.each do |file_path| validate_record(file_path) callback&.call(record_set: self, file: file_path) end @@ -99,6 +105,16 @@ def validate_record(file_path) end end + def skip_to(file_list, last_id) + index = file_list.index(last_id) + + if index + file_list[(index + 1)..] + else + file_list + end + end + def load(file_path) JSON.parse(zip.read(file_path)) rescue ArgumentError => e diff --git a/app/models/account/import.rb b/app/models/account/import.rb index 1e98ce2e17..5ebb1288b1 100644 --- a/app/models/account/import.rb +++ b/app/models/account/import.rb @@ -17,14 +17,15 @@ def process(start: nil, callback: nil) 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.import(from: zip, start: start&.record_id, callback: callback) + Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id| + record_set.import(from: zip, start: last_id, callback: callback) end end mark_completed rescue => e failed! + ImportMailer.failed(identity, account).deliver_later raise e end @@ -33,8 +34,8 @@ def validate(start: nil, callback: nil) 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) + Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id| + record_set.validate(from: zip, start: last_id, callback: callback) end end end @@ -54,6 +55,6 @@ def download_path def mark_completed completed! - # TODO: send email + ImportMailer.completed(identity, account).deliver_later end end diff --git a/app/models/export.rb b/app/models/export.rb index 9986f7539e..9560fe122a 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -20,20 +20,19 @@ def build_later def build processing! + zipfile = nil 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 - ExportMailer.completed(self).deliver_later - ensure - zipfile&.close - zipfile&.unlink end rescue => e update!(status: :failed) raise + ensure + zipfile&.close + zipfile&.unlink end def mark_completed diff --git a/app/views/mailers/import_mailer/completed.html.erb b/app/views/mailers/import_mailer/completed.html.erb index 914a182d73..b598ef0be0 100644 --- a/app/views/mailers/import_mailer/completed.html.erb +++ b/app/views/mailers/import_mailer/completed.html.erb @@ -1,6 +1,6 @@

Your Fizzy account import is complete

-

Your Fizzy account data has been successfully imported.

+

Your import to <%= @account.name %> is complete.

-

<%= link_to "View your account", root_url %>

+

<%= link_to "Go to your account", @landing_url %>

diff --git a/app/views/mailers/import_mailer/completed.text.erb b/app/views/mailers/import_mailer/completed.text.erb index 704df0f6f3..df9f0fd542 100644 --- a/app/views/mailers/import_mailer/completed.text.erb +++ b/app/views/mailers/import_mailer/completed.text.erb @@ -1,3 +1,3 @@ -Your Fizzy account data has been successfully imported. +Your import to <%= @account.name %> is complete. -View your account: <%= root_url %> +Go to your account: <%= @landing_url %> diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index 2d357da1f3..1a2f8fb99e 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -67,7 +67,7 @@ class Account::ImportTest < ActiveSupport::TestCase target_account = create_target_account # Pre-create a tag with a specific ID that will collide - colliding_id = SecureRandom.uuid + colliding_id = ActiveRecord::Type::Uuid.generate Tag.create!( id: colliding_id, account: target_account, @@ -189,7 +189,7 @@ def generate_export_zip(join_code: nil, tag_id: nil) # Export users with new UUIDs (to avoid collisions with fixtures) @source_account.users.each do |user| - new_id = SecureRandom.uuid + new_id = ActiveRecord::Type::Uuid.generate user_data = { "id" => new_id, "account_id" => @source_account.id, @@ -206,7 +206,7 @@ def generate_export_zip(join_code: nil, tag_id: nil) # Export tags with new UUIDs (to avoid collisions with fixtures) @source_account.tags.each do |tag| - new_id = tag_id || SecureRandom.uuid + new_id = tag_id || ActiveRecord::Type::Uuid.generate tag_data = { "id" => new_id, "account_id" => @source_account.id, From 9cf49560fa87e6347c2b1b64cfcb4ea3a4f3c94a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 15:26:13 +0100 Subject: [PATCH 24/25] Simplify the import test --- test/models/account/import_test.rb | 234 ++--------------------------- 1 file changed, 13 insertions(+), 221 deletions(-) diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index 1a2f8fb99e..e6bb219e4e 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -1,255 +1,47 @@ require "test_helper" class Account::ImportTest < ActiveSupport::TestCase - setup do - @identity = identities(:david) - @source_account = accounts("37s") - end - - test "process sets status to failed on error" do - import = create_import_with_file - Account::DataTransfer::Manifest.any_instance.stubs(:each_record_set).raises(StandardError.new("Test error")) - - assert_raises(StandardError) do - import.process - end - - assert import.failed? - end - - test "process imports account name from export" do - target_account = create_target_account - import = create_import_for_account(target_account) - - import.process - - assert_equal @source_account.name, target_account.reload.name - end - - 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.process - - # Users from the source account should be imported - assert target_account.users.count > 2 # system user + owner + imported users - end - - test "process preserves join code if unique" do - target_account = create_target_account - import = create_import_for_account(target_account) - - # Set up a unique code in the export - export_code = "UNIQ-CODE-1234" - Account::JoinCode.where(code: export_code).delete_all - - # 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.process - - # Join code update attempt is made (may or may not succeed based on uniqueness) - assert import_with_custom_join_code.completed? - end - - test "validate raises IntegrityError for missing required fields" do - target_account = create_target_account - import = create_import_with_invalid_data(target_account) - - assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do - import.validate - end - end - - test "process rolls back on ID collision" do - target_account = create_target_account - - # Pre-create a tag with a specific ID that will collide - colliding_id = ActiveRecord::Type::Uuid.generate - Tag.create!( - id: colliding_id, - account: target_account, - title: "Existing tag" - ) - - import = create_import_for_account(target_account, tag_id: colliding_id) - - assert_raises(ActiveRecord::RecordNotUnique) do - import.process - end - - # Import should be marked as failed - assert import.reload.failed? - end - - test "process marks import as completed on success" do - target_account = create_target_account - import = create_import_for_account(target_account) - - import.process - - assert import.completed? - end - test "export and import round-trip preserves account data" do source_account = accounts("37s") exporter = users(:david) identity = exporter.identity - # Capture original counts - original_counts = capture_counts(source_account) + source_account_digest = account_digest(source_account) - # Export export = Account::Export.create!(account: source_account, user: exporter) export.build + assert export.completed? - # Save export file before deleting account export_tempfile = Tempfile.new([ "export", ".zip" ]) export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) } - # Delete source account source_account.destroy! - # Create target account - target_account = Account.create!(name: "Import Target") - target_account.users.create!(role: :system, name: "System") - target_account.users.create!(role: :owner, name: "Owner", identity: identity, verified_at: Time.current) - - # Import + target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name }) import = Account::Import.create!(identity: identity, account: target_account) Current.set(account: target_account) do import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip") end + import.process - assert import.completed? - assert_equal original_counts.except(:users), capture_counts(target_account).except(:users) + assert import.completed? + assert_equal source_account_digest, account_digest(target_account) ensure export_tempfile&.close export_tempfile&.unlink end private - def create_target_account - account = Account.create!(name: "Import Target") - account.users.create!(role: :system, name: "System") - account.users.create!( - role: :owner, - name: "Importer", - identity: @identity, - verified_at: Time.current - ) - account - end - - def create_import_with_file - 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) - 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_invalid_data(target_account) - import = Account::Import.create!(identity: @identity, account: target_account) - 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, 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 with new UUIDs (to avoid collisions with fixtures) - @source_account.users.each do |user| - new_id = ActiveRecord::Type::Uuid.generate - 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 - - # Export tags with new UUIDs (to avoid collisions with fixtures) - @source_account.tags.each do |tag| - new_id = tag_id || ActiveRecord::Type::Uuid.generate - 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 - - def capture_counts(account) + def account_digest(account) { - boards: account.boards.count, - columns: Column.joins(:board).where(boards: { account_id: account.id }).count, - cards: account.cards.count, - comments: Comment.joins(:card).where(cards: { account_id: account.id }).count, - tags: account.tags.count, - users: account.users.count + name: account.name, + board_count: Board.where(account: account).count, + column_count: Column.where(account: account).count, + card_count: Card.where(account: account).count, + comment_count: Comment.where(account: account).count, + tag_count: Tag.where(account: account).count } end end From a742b5dc89c5c27d05c79f554d7ccc21980d7a79 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 29 Jan 2026 15:26:30 +0100 Subject: [PATCH 25/25] Cleanup code --- app/controllers/imports_controller.rb | 10 +++++++--- app/models/export.rb | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index a791c1929f..b34845b84c 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -1,8 +1,9 @@ class ImportsController < ApplicationController - disallow_account_scope only: %i[ new create ] - layout "public" + disallow_account_scope only: %i[ new create ] + before_action :set_import, only: %i[ show ] + def new end @@ -17,10 +18,13 @@ def create end def show - @import = Current.account.imports.find(params[:id]) end private + def set_import + @import = Current.account.imports.find(params[:id]) + end + def start_import(account) import = nil diff --git a/app/models/export.rb b/app/models/export.rb index 9560fe122a..dde40b28d4 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -29,7 +29,7 @@ def build end rescue => e update!(status: :failed) - raise + raise e ensure zipfile&.close zipfile&.unlink