diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb
index ab40286af..dbe0a11c1 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 @@ class Account::ExportsController < ApplicationController
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 000000000..60df1e326
--- /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 c0286c736..e342ea34b 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 5385aeed6..045d954d0 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 e58f62fce..e8ab0dd1f 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
-
- 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!
-
- 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
-
+class Account::Export < Export
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"
+ 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 generate_zip
- raise ArgumentError, "Block is required" unless block_given?
+ 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
- Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
- Zip::File.open(tempfile.path, create: true) do |zip|
- yield zip
- 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 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_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/account/whole_account_export.rb b/app/models/account/whole_account_export.rb
deleted file mode 100644
index 8551463e1..000000000
--- 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 000000000..871d6d8b4
--- /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 e16d70670..842f7f6c8 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 d5fbcfb7b..4eb7b9e77 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 d44bb4b74..3d1946996 100644
--- a/app/views/account/settings/_export.html.erb
+++ b/app/views/account/settings/_export.html.erb
@@ -1,15 +1,15 @@
Download an archive of your Fizzy data. Download a complete archive of all account data.Export your data
- Export account data
+