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/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/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/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/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/jobs/import_account_data_job.rb b/app/jobs/import_account_data_job.rb new file mode 100644 index 0000000000..e131a41e91 --- /dev/null +++ b/app/jobs/import_account_data_job.rb @@ -0,0 +1,19 @@ +class ImportAccountDataJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :backend + + def perform(import) + 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/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/mailers/import_mailer.rb b/app/mailers/import_mailer.rb new file mode 100644 index 0000000000..5040a224e0 --- /dev/null +++ b/app/mailers/import_mailer.rb @@ -0,0 +1,9 @@ +class ImportMailer < ApplicationMailer + def completed(identity) + mail to: identity.email_address, subject: "Your Fizzy account import is complete" + end + + 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/data_transfer/account_record_set.rb b/app/models/account/data_transfer/account_record_set.rb new file mode 100644 index 0000000000..86bc790674 --- /dev/null +++ b/app/models/account/data_transfer/account_record_set.rb @@ -0,0 +1,58 @@ +class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet + ACCOUNT_ATTRIBUTES = %w[ + join_code + name + ] + + JOIN_CODE_ATTRIBUTES = %w[ + code + usage_count + usage_limit + ] + + def initialize(account) + super(account: account, model: Account) + end + + 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..0ba663e1ea --- /dev/null +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -0,0 +1,86 @@ +class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet + ATTRIBUTES = %w[ + account_id + body + created_at + id + name + record_id + record_type + updated_at + ].freeze + + def initialize(account) + super(account: account, model: ActionText::RichText) + end + + 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 + if missing.any? + 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/blob_file_record_set.rb b/app/models/account/data_transfer/blob_file_record_set.rb new file mode 100644 index 0000000000..c17070ed27 --- /dev/null +++ b/app/models/account/data_transfer/blob_file_record_set.rb @@ -0,0 +1,43 @@ +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) + 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/manifest.rb b/app/models/account/data_transfer/manifest.rb new file mode 100644 index 0000000000..ffa65ad2fe --- /dev/null +++ b/app/models/account/data_transfer/manifest.rb @@ -0,0 +1,55 @@ +class Account::DataTransfer::Manifest + Cursor = Struct.new(:record_class, :last_id) + + 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| + 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/record_set.rb b/app/models/account/data_transfer/record_set.rb new file mode 100644 index 0000000000..c2dc094264 --- /dev/null +++ b/app/models/account/data_transfer/record_set.rb @@ -0,0 +1,101 @@ +class Account::DataTransfer::RecordSet + class IntegrityError < StandardError; end + + IMPORT_BATCH_SIZE = 100 + + attr_reader :account, :model, :attributes + + def initialize(account:, model:, attributes: nil) + @account = account + @model = model + @attributes = (attributes || model.column_names).map(&:to_s) + 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 + model.where(account_id: account.id) + end + + def export_record(record) + 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) + 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) + 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) + JSON.parse(zip.read(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/user_record_set.rb b/app/models/account/data_transfer/user_record_set.rb new file mode 100644 index 0000000000..277dc82c34 --- /dev/null +++ b/app/models/account/data_transfer/user_record_set.rb @@ -0,0 +1,58 @@ +class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet + ATTRIBUTES = %w[ + id + email_address + name + role + active + verified_at + created_at + updated_at + ] + + def initialize(account) + super(account: account, model: User) + end + + 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/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 1fd2689d2c..6d78513246 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -1,77 +1,8 @@ -class Account::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 - ExportAccountDataJob.perform_later(self) - end - - def build - processing! - zipfile = generate_zip - - file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip" - mark_completed - - ExportMailer.completed(self).deliver_later - rescue => e - update!(status: :failed) - raise - ensure - zipfile&.close - zipfile&.unlink - end - - def mark_completed - update!(status: :completed, completed_at: Time.current) - end - - def accessible_to?(accessor) - accessor == user - end - +class Account::Export < Export private - def generate_zip - 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 - 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) } - end - rescue ActiveStorage::FileNotFoundError - # Skip attachments where the file is missing from storage + def populate_zip(zip) + 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 new file mode 100644 index 0000000000..a68a1b37bf --- /dev/null +++ b/app/models/account/import.rb @@ -0,0 +1,56 @@ +class Account::Import < ApplicationRecord + belongs_to :account + belongs_to :identity + + has_one_attached :file + + enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + + def process_later + end + + def process(start: nil, callback: nil) + processing! + ensure_downloaded + + Account::DataTransfer::ZipFile.open(download_path) do |zip| + Account::DataTransfer::Manifest.new(account).each_record_set(start: start&.record_set) do |record_set| + record_set.import(from: zip, start: start&.record_id, callback: callback) + end + end + + mark_completed + rescue => e + failed! + raise e + end + + def validate(start: nil, callback: nil) + processing! + ensure_downloaded + + Account::DataTransfer::ZipFile.open(download_path) do |zip| + Account::DataTransfer::Manifest.new(account).each_record_set(start: start&.record_set) do |record_set| + record_set.validate(from: zip, start: start&.record_id, callback: callback) + end + end + end + + private + def ensure_downloaded + unless download_path.exist? + download_path.open("wb") do |f| + file.download { |chunk| f.write(chunk) } + end + end + end + + def download_path + Pathname.new("/tmp/account-import-#{id}.zip") + end + + def mark_completed + completed! + # TODO: send email + end +end diff --git a/app/models/export.rb b/app/models/export.rb new file mode 100644 index 0000000000..9986f7539e --- /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! + + 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 + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + def accessible_to?(accessor) + accessor == user + end + + private + def with_account_context + Current.set(account: account) do + yield + end + 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/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/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/user/data_export.rb b/app/models/user/data_export.rb new file mode 100644 index 0000000000..4eb7b9e775 --- /dev/null +++ b/app/models/user/data_export.rb @@ -0,0 +1,29 @@ +class User::DataExport < 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/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 d44bb4b744..2b831837c1 100644 --- a/app/views/account/settings/_export.html.erb +++ b/app/views/account/settings/_export.html.erb @@ -1,19 +1,21 @@ -
-

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" } } %> - -
-
-
\ 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 %> -
-
+ +
- <%= 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 74e2744148..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 %> @@ -17,9 +17,9 @@
<%= 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" %>
-
+ <%= render "account/settings/subscription_panel" if Fizzy.saas? %> 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/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/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/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/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/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/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/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 new file mode 100644 index 0000000000..2359fbbefa --- /dev/null +++ b/app/views/users/_data_export.html.erb @@ -0,0 +1,21 @@ +
+
+

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/_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

+
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 97e34290cb..9f7af72ef6 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 @@ -20,6 +22,8 @@ resources :email_addresses, param: :token do resource :confirmation, module: :email_addresses end + + resources :data_exports, only: [ :create, :show ] end end @@ -165,6 +169,7 @@ resource :landing + namespace :my do resource :identity, only: :show resources :access_tokens 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/20251223000002_create_account_imports.rb b/db/migrate/20251223000002_create_account_imports.rb new file mode 100644 index 0000000000..5354ac72b0 --- /dev/null +++ b/db/migrate/20251223000002_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/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 86b9f29375..7848efe56c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -33,20 +33,20 @@ 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 + 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 + 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.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 + 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| @@ -293,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/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f998659..46e655abcb 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -33,20 +33,20 @@ 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 + 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 + 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.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 + 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| @@ -293,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 @@ -477,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" 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 diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb index 4806b65f06..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::Export.create!(account: Current.account, user: users(:david)) + export = Account::Export.create!(account: Current.account, user: users(:jason)) 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 37b0ea47e3..f4171f9da8 100644 --- a/test/models/account/export_test.rb +++ b/test/models/account/export_test.rb @@ -1,41 +1,14 @@ require "test_helper" class Account::ExportTest < ActiveSupport::TestCase - test "build_later enqueues ExportAccountDataJob" do + 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 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.stubs(:generate_zip).raises(StandardError.new("Test error")) @@ -52,44 +25,20 @@ class Account::ExportTest < ActiveSupport::TestCase 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 includes only accessible cards for user" do - user = users(:david) - export = Account::Export.create!(account: Current.account, user: user) + 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? - - # 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 + assert_equal "application/zip", export.file.content_type end end diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb new file mode 100644 index 0000000000..1abada2a3a --- /dev/null +++ b/test/models/account/import_test.rb @@ -0,0 +1,205 @@ +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 = SecureRandom.uuid + 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 + + 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 = 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 + + # 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 diff --git a/test/models/user/data_export_test.rb b/test/models/user/data_export_test.rb new file mode 100644 index 0000000000..4169c7f86c --- /dev/null +++ b/test/models/user/data_export_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +class User::DataExportTest < ActiveSupport::TestCase + test "build generates zip with card JSON files" do + export = User::DataExport.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 = User::DataExport.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 = User::DataExport.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 = User::DataExport.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 + + 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