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 @@
-

Export your data

-

Download an archive of your Fizzy data.

+

Export account data

+

Download a complete archive of all account data.

-

Export your account data

-

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

-

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

+

Export all account data

+

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

+

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

<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 438560635..b730afcd4 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -17,7 +17,7 @@
<%= render "account/settings/entropy", account: @account %> - <%= render "account/settings/export" %> + <%= render "account/settings/export" if Current.user.admin? || Current.user.owner? %> <%= render "account/settings/cancellation" %>
diff --git a/app/views/mailers/export_mailer/completed.html.erb b/app/views/mailers/export_mailer/completed.html.erb index c0d458fc2..cadf87dca 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 e5c274a9a..75f749183 100644 --- a/app/views/mailers/export_mailer/completed.text.erb +++ b/app/views/mailers/export_mailer/completed.text.erb @@ -1,3 +1,3 @@ Your Fizzy data export has finished processing and is ready to download. -Download your data: <%= account_export_url(@export) %> +Download your data: <%= export_download_url(@export) %> diff --git a/app/views/users/_data_export.html.erb b/app/views/users/_data_export.html.erb new file mode 100644 index 000000000..a6cd3bde8 --- /dev/null +++ b/app/views/users/_data_export.html.erb @@ -0,0 +1,19 @@ +
+

Export your data

+

Download an archive of your Fizzy data.

+
+ +
+ + + +

Export your data

+

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

+

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

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

<%= @page_title %>

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

Your export is ready. The download should start automatically.

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

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

+ <% end %> + +
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 771a8e924..6a5cc2c02 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -53,6 +53,7 @@ <%= render "users/theme" %> <%= render "users/transfer", user: @user %> <%= render "users/access_tokens" %> + <%= render "users/data_export" %> <% end %>
diff --git a/config/recurring.yml b/config/recurring.yml index 8aba572ca..981511d45 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 3c8c66589..a7aad9e45 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,8 @@ Rails.application.routes.draw do resources :email_addresses, param: :token do resource :confirmation, module: :email_addresses end + + resources :data_exports, only: [ :create, :show ] end end diff --git a/db/migrate/20251212164543_add_type_to_account_exports.rb b/db/migrate/20251212164543_add_type_to_account_exports.rb deleted file mode 100644 index 3931380c6..000000000 --- a/db/migrate/20251212164543_add_type_to_account_exports.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddTypeToAccountExports < ActiveRecord::Migration[8.2] - def change - add_column :account_exports, :type, :string - end -end diff --git a/db/migrate/20251223000001_rename_account_exports_to_exports.rb b/db/migrate/20251223000001_rename_account_exports_to_exports.rb new file mode 100644 index 000000000..f9823030c --- /dev/null +++ b/db/migrate/20251223000001_rename_account_exports_to_exports.rb @@ -0,0 +1,7 @@ +class RenameAccountExportsToExports < ActiveRecord::Migration[8.2] + def change + rename_table :account_exports, :exports + add_column :exports, :type, :string + add_index :exports, :type + end +end diff --git a/db/migrate/20251223000001_create_account_imports.rb b/db/migrate/20251223000002_create_account_imports.rb similarity index 100% rename from db/migrate/20251223000001_create_account_imports.rb rename to db/migrate/20251223000002_create_account_imports.rb diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 84bc6b8a6..c4713f8d1 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -132,7 +132,6 @@ ActiveRecord::Schema[8.2].define(version: 1) do 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 12f509924..a1f2a33bc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -33,18 +33,6 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true end - create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.uuid "account_id", null: false - t.datetime "completed_at" - t.datetime "created_at", null: false - t.string "status", default: "pending", null: false - t.string "type" - t.datetime "updated_at", null: false - t.uuid "user_id", null: false - t.index ["account_id"], name: "index_account_exports_on_account_id" - t.index ["user_id"], name: "index_account_exports_on_user_id" - end - create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true @@ -305,6 +293,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end + create_table "exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.string "status", default: "pending", null: false + t.string "type" + t.datetime "updated_at", null: false + t.uuid "user_id", null: false + t.index ["account_id"], name: "index_exports_on_account_id" + t.index ["type"], name: "index_exports_on_type" + t.index ["user_id"], name: "index_exports_on_user_id" + end + create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb index 1c4441cc5..1ebe7e5ea 100644 --- a/test/controllers/accounts/exports_controller_test.rb +++ b/test/controllers/accounts/exports_controller_test.rb @@ -2,12 +2,12 @@ require "test_helper" class Account::ExportsControllerTest < ActionDispatch::IntegrationTest setup do - sign_in_as :david + sign_in_as :jason end test "create creates an export record and enqueues job" do assert_difference -> { Account::Export.count }, 1 do - assert_enqueued_with(job: ExportAccountDataJob) do + assert_enqueued_with(job: ExportDataJob) do post account_exports_path end end @@ -20,14 +20,14 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest post account_exports_path export = Account::Export.last - assert_equal users(:david), export.user + assert_equal users(:jason), export.user assert_equal Current.account, export.account assert export.pending? end test "create rejects request when current export limit is reached" do Account::ExportsController::CURRENT_EXPORT_LIMIT.times do - Account::Export.create!(account: Current.account, user: users(:david)) + Account::Export.create!(account: Current.account, user: users(:jason)) end assert_no_difference -> { Account::Export.count } do @@ -39,7 +39,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest test "create allows request when exports are older than one day" do Account::ExportsController::CURRENT_EXPORT_LIMIT.times do - Account::Export.create!(account: Current.account, user: users(:david), created_at: 2.days.ago) + Account::Export.create!(account: Current.account, user: users(:jason), created_at: 2.days.ago) end assert_difference -> { Account::Export.count }, 1 do @@ -50,7 +50,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest end test "show displays completed export with download link" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = Account::Export.create!(account: Current.account, user: users(:jason)) export.build get account_export_path(export) @@ -67,7 +67,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest end test "show does not allow access to another user's export" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:kevin)) + export = Account::Export.create!(account: Current.account, user: users(:kevin)) export.build get account_export_path(export) @@ -75,4 +75,22 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "h2", "Download Expired" end + + test "create is forbidden for non-admin members" do + logout_and_sign_in_as :david + + post account_exports_path + + assert_response :forbidden + end + + test "show is forbidden for non-admin members" do + logout_and_sign_in_as :david + export = Account::Export.create!(account: Current.account, user: users(:jason)) + export.build + + get account_export_path(export) + + assert_response :forbidden + end end diff --git a/test/controllers/users/data_exports_controller_test.rb b/test/controllers/users/data_exports_controller_test.rb new file mode 100644 index 000000000..f89f121b1 --- /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 764f8aada..000000000 --- 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 000000000..8d86be46b --- /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 c981aa191..ac48780a3 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 306d2851a..f4171f9da 100644 --- a/test/models/account/export_test.rb +++ b/test/models/account/export_test.rb @@ -1,16 +1,16 @@ require "test_helper" class Account::ExportTest < ActiveSupport::TestCase - test "build_later enqueues ExportAccountDataJob" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + test "build_later enqueues ExportDataJob" do + export = Account::Export.create!(account: Current.account, user: users(:david)) - assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do + assert_enqueued_with(job: ExportDataJob, args: [ export ]) do export.build_later end end test "build sets status to failed on error" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = Account::Export.create!(account: Current.account, user: users(:david)) export.stubs(:generate_zip).raises(StandardError.new("Test error")) assert_raises(StandardError) do @@ -21,14 +21,24 @@ class Account::ExportTest < ActiveSupport::TestCase end test "cleanup deletes exports completed more than 24 hours ago" do - old_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) - recent_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) - pending_export = Account::SingleUserExport.create!(account: Current.account, user: users(:david), status: :pending) + old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago) + recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago) + pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending) - Account::Export.cleanup + Export.cleanup - assert_not Account::Export.exists?(old_export.id) - assert Account::Export.exists?(recent_export.id) - assert Account::Export.exists?(pending_export.id) + assert_not Export.exists?(old_export.id) + assert Export.exists?(recent_export.id) + assert Export.exists?(pending_export.id) + end + + test "build generates zip with account data" do + export = Account::Export.create!(account: Current.account, user: users(:david)) + + export.build + + assert export.completed? + assert export.file.attached? + assert_equal "application/zip", export.file.content_type end end diff --git a/test/models/account/whole_account_export_test.rb b/test/models/account/whole_account_export_test.rb deleted file mode 100644 index fb947189a..000000000 --- a/test/models/account/whole_account_export_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class Account::WholeAccountExportTest < ActiveSupport::TestCase - test "build generates zip with account data" do - skip "WholeAccountExport implementation incomplete" - end -end diff --git a/test/models/account/single_user_export_test.rb b/test/models/user/data_export_test.rb similarity index 70% rename from test/models/account/single_user_export_test.rb rename to test/models/user/data_export_test.rb index 8d24e16b9..4169c7f86 100644 --- a/test/models/account/single_user_export_test.rb +++ b/test/models/user/data_export_test.rb @@ -1,8 +1,8 @@ require "test_helper" -class Account::SingleUserExportTest < ActiveSupport::TestCase +class User::DataExportTest < ActiveSupport::TestCase test "build generates zip with card JSON files" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = User::DataExport.create!(account: Current.account, user: users(:david)) export.build @@ -12,7 +12,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase end test "build sets status to processing then completed" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = User::DataExport.create!(account: Current.account, user: users(:david)) export.build @@ -21,7 +21,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase end test "build sends email when completed" do - export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) + export = User::DataExport.create!(account: Current.account, user: users(:david)) assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do export.build @@ -30,7 +30,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase test "build includes only accessible cards for user" do user = users(:david) - export = Account::SingleUserExport.create!(account: Current.account, user: user) + export = User::DataExport.create!(account: Current.account, user: user) export.build @@ -59,4 +59,12 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase end end end + + test "build_later enqueues ExportDataJob" do + export = User::DataExport.create!(account: Current.account, user: users(:david)) + + assert_enqueued_with(job: ExportDataJob, args: [ export ]) do + export.build_later + end + end end