Convert Account::SingleUserExport to User::DataExport

This commit is contained in:
Stanko K.R.
2026-01-09 16:15:32 +01:00
parent 161efb0b8e
commit 19ae555c2e
31 changed files with 643 additions and 395 deletions
@@ -1,4 +1,5 @@
class Account::ExportsController < ApplicationController class Account::ExportsController < ApplicationController
before_action :ensure_admin_or_owner
before_action :ensure_export_limit_not_exceeded, only: :create before_action :ensure_export_limit_not_exceeded, only: :create
before_action :set_export, only: :show before_action :set_export, only: :show
@@ -13,8 +14,12 @@ class Account::ExportsController < ApplicationController
end end
private private
def ensure_admin_or_owner
head :forbidden unless Current.user.admin? || Current.user.owner?
end
def ensure_export_limit_not_exceeded 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 end
def set_export def set_export
@@ -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
@@ -1,4 +1,4 @@
class ExportAccountDataJob < ApplicationJob class ExportDataJob < ApplicationJob
queue_as :backend queue_as :backend
discard_on ActiveJob::DeserializationError discard_on ActiveJob::DeserializationError
+11
View File
@@ -1,8 +1,19 @@
class ExportMailer < ApplicationMailer class ExportMailer < ApplicationMailer
helper_method :export_download_url
def completed(export) def completed(export)
@export = export @export = export
@user = export.user @user = export.user
mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download" mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download"
end 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 end
+242 -67
View File
@@ -1,79 +1,254 @@
class Account::Export < ApplicationRecord class Account::Export < Export
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
private private
def with_url_options
ActiveStorage::Current.set(url_options: { host: "localhost" }) { yield }
end
def populate_zip(zip) 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 end
def generate_zip def export_account(zip)
raise ArgumentError, "Block is required" unless block_given? 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| def export_users(zip)
Zip::File.open(tempfile.path, create: true) do |zip| account.users.find_each do |user|
yield zip data = user.as_json.except("identity_id").merge(
end "email_address" => user.identity&.email_address
)
add_file_to_zip(zip, "data/users/#{user.id}.json", JSON.pretty_generate(data))
end end
end end
def add_file_to_zip(zip, path, content = nil, **options) def export_tags(zip)
zip.get_output_stream(path, **options) do |f| account.tags.find_each do |tag|
if block_given? add_file_to_zip(zip, "data/tags/#{tag.id}.json", JSON.pretty_generate(tag.as_json))
yield f end
elsif content end
f.write(content)
else def export_entropies(zip)
raise ArgumentError, "Either content or a block must be provided" 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 end
rescue ActiveStorage::FileNotFoundError
# Skip blobs where the file is missing from storage
end end
end end
end end
-254
View File
@@ -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
+79
View File
@@ -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
+1 -1
View File
@@ -14,7 +14,7 @@ class User < ApplicationRecord
has_many :closures, dependent: :nullify has_many :closures, dependent: :nullify
has_many :pins, dependent: :destroy has_many :pins, dependent: :destroy
has_many :pinned_cards, through: :pins, source: :card 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 def deactivate
transaction do transaction do
@@ -1,4 +1,4 @@
class Account::SingleUserExport < Account::Export class User::DataExport < Export
private private
def populate_zip(zip) def populate_zip(zip)
exportable_cards.find_each do |card| exportable_cards.find_each do |card|
+5 -5
View File
@@ -1,15 +1,15 @@
<header class="margin-block-start-double"> <header class="margin-block-start-double">
<h2 class="divider txt-large">Export your data</h2> <h2 class="divider txt-large">Export account data</h2>
<p class="margin-none-block">Download an archive of your Fizzy data.</p> <p class="margin-none-block">Download a complete archive of all account data.</p>
</header> </header>
<div data-controller="dialog" data-dialog-modal-value="true"> <div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn" data-action="dialog#open">Begin export...</button> <button type="button" class="btn" data-action="dialog#open">Begin export...</button>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog"> <dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large">Export your account data</h2> <h2 class="margin-none txt-large">Export all account data</h2>
<p class="margin-none-block-start">This will kick off a request to generate a ZIP archive of all the data in boards you have access to.</p> <p class="margin-none-block-start">This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.</p>
<p>When the file is ready, well email you a link to download it. The link will expire after 24 hours.</p> <p>When the file is ready, we'll email you a link to download it. The link will expire after 24 hours.</p>
<div class="flex gap justify-center"> <div class="flex gap justify-center">
<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
+1 -1
View File
@@ -17,7 +17,7 @@
<div class="settings__panel settings__panel--entropy panel shadow center"> <div class="settings__panel settings__panel--entropy panel shadow center">
<%= render "account/settings/entropy", account: @account %> <%= 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/cancellation" %>
</div> </div>
</section> </section>
@@ -1,6 +1,6 @@
<h1 class="title">Download your Fizzy data</h1> <h1 class="title">Download your Fizzy data</h1>
<p class="subtitle">Your Fizzy data export has finished processing and is ready to download.</p> <p class="subtitle">Your Fizzy data export has finished processing and is ready to download.</p>
<p><%= link_to "Download your data", account_export_url(@export) %></p> <p><%= link_to "Download your data", export_download_url(@export) %></p>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p> <p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p>
@@ -1,3 +1,3 @@
Your Fizzy data export has finished processing and is ready to download. 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) %>
+19
View File
@@ -0,0 +1,19 @@
<header class="margin-block-start-double">
<h2 class="divider txt-large">Export your data</h2>
<p class="margin-none-block">Download an archive of your Fizzy data.</p>
</header>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn" data-action="dialog#open">Begin export...</button>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large">Export your data</h2>
<p class="margin-none-block-start">This will generate a ZIP archive of all cards you have access to.</p>
<p>When ready, we'll email you a download link (expires in 24 hours).</p>
<div class="flex gap justify-center">
<%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
</div>
</dialog>
</div>
@@ -0,0 +1,29 @@
<% if @export.present? %>
<% @page_title = "Download Export" %>
<% else %>
<% @page_title = "Download Expired" %>
<% end %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @user.name, user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>
<div class="panel panel--wide shadow center flex flex-column gap">
<h2 class="txt-large margin-none font-weight-black"><%= @page_title %></h2>
<% if @export.present? %>
<p class="margin-none-block-start">Your export is ready. The download should start automatically.</p>
<%= 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 %>
<p class="margin-none-block-start">That download link has expired. You'll need to <%= link_to "request a new export", user_path(@user), class: "txt-lnk" %>.</p>
<% end %>
</div>
+1
View File
@@ -53,6 +53,7 @@
<%= render "users/theme" %> <%= render "users/theme" %>
<%= render "users/transfer", user: @user %> <%= render "users/transfer", user: @user %>
<%= render "users/access_tokens" %> <%= render "users/access_tokens" %>
<%= render "users/data_export" %>
</section> </section>
<% end %> <% end %>
</div> </div>
+1 -1
View File
@@ -25,7 +25,7 @@ production: &production
command: "MagicLink.cleanup" command: "MagicLink.cleanup"
schedule: every 4 hours schedule: every 4 hours
cleanup_exports: cleanup_exports:
command: "Account::Export.cleanup" command: "Export.cleanup"
schedule: every hour at minute 20 schedule: every hour at minute 20
incineration: incineration:
class: "Account::IncinerateDueJob" class: "Account::IncinerateDueJob"
+2
View File
@@ -22,6 +22,8 @@ Rails.application.routes.draw do
resources :email_addresses, param: :token do resources :email_addresses, param: :token do
resource :confirmation, module: :email_addresses resource :confirmation, module: :email_addresses
end end
resources :data_exports, only: [ :create, :show ]
end end
end end
@@ -1,5 +0,0 @@
class AddTypeToAccountExports < ActiveRecord::Migration[8.2]
def change
add_column :account_exports, :type, :string
end
end
@@ -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
-1
View File
@@ -132,7 +132,6 @@ ActiveRecord::Schema[8.2].define(version: 1) do
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end 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_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_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 add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
Generated
+13 -12
View File
@@ -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 t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end 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| 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.bigint "value", default: 0, null: false
t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
@@ -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" t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable"
end 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| create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false t.uuid "account_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -2,12 +2,12 @@ require "test_helper"
class Account::ExportsControllerTest < ActionDispatch::IntegrationTest class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in_as :david sign_in_as :jason
end end
test "create creates an export record and enqueues job" do test "create creates an export record and enqueues job" do
assert_difference -> { Account::Export.count }, 1 do assert_difference -> { Account::Export.count }, 1 do
assert_enqueued_with(job: ExportAccountDataJob) do assert_enqueued_with(job: ExportDataJob) do
post account_exports_path post account_exports_path
end end
end end
@@ -20,14 +20,14 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
post account_exports_path post account_exports_path
export = Account::Export.last export = Account::Export.last
assert_equal users(:david), export.user assert_equal users(:jason), export.user
assert_equal Current.account, export.account assert_equal Current.account, export.account
assert export.pending? assert export.pending?
end end
test "create rejects request when current export limit is reached" do test "create rejects request when current export limit is reached" do
Account::ExportsController::CURRENT_EXPORT_LIMIT.times 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 end
assert_no_difference -> { Account::Export.count } do 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 test "create allows request when exports are older than one day" do
Account::ExportsController::CURRENT_EXPORT_LIMIT.times 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 end
assert_difference -> { Account::Export.count }, 1 do assert_difference -> { Account::Export.count }, 1 do
@@ -50,7 +50,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
end end
test "show displays completed export with download link" do 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 export.build
get account_export_path(export) get account_export_path(export)
@@ -67,7 +67,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
end end
test "show does not allow access to another user's export" do 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 export.build
get account_export_path(export) get account_export_path(export)
@@ -75,4 +75,22 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
assert_select "h2", "Download Expired" assert_select "h2", "Download Expired"
end 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 end
@@ -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
-12
View File
@@ -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) %>
+29
View File
@@ -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) %>
+14 -1
View File
@@ -1,7 +1,7 @@
require "test_helper" require "test_helper"
class ExportMailerTest < ActionMailer::TestCase class ExportMailerTest < ActionMailer::TestCase
test "completed" do test "completed for account export" do
export = Account::Export.create!(account: Current.account, user: users(:david)) export = Account::Export.create!(account: Current.account, user: users(:david))
email = ExportMailer.completed(export) 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_equal "Your Fizzy data export is ready for download", email.subject
assert_match %r{/exports/#{export.id}}, email.body.encoded assert_match %r{/exports/#{export.id}}, email.body.encoded
end 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 end
+21 -11
View File
@@ -1,16 +1,16 @@
require "test_helper" require "test_helper"
class Account::ExportTest < ActiveSupport::TestCase class Account::ExportTest < ActiveSupport::TestCase
test "build_later enqueues ExportAccountDataJob" do test "build_later enqueues ExportDataJob" do
export = Account::SingleUserExport.create!(account: Current.account, user: users(:david)) 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 export.build_later
end end
end end
test "build sets status to failed on error" do 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")) export.stubs(:generate_zip).raises(StandardError.new("Test error"))
assert_raises(StandardError) do assert_raises(StandardError) do
@@ -21,14 +21,24 @@ class Account::ExportTest < ActiveSupport::TestCase
end end
test "cleanup deletes exports completed more than 24 hours ago" do 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) old_export = Account::Export.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) recent_export = Account::Export.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) 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_not Export.exists?(old_export.id)
assert Account::Export.exists?(recent_export.id) assert Export.exists?(recent_export.id)
assert Account::Export.exists?(pending_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
end end
@@ -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
@@ -1,8 +1,8 @@
require "test_helper" require "test_helper"
class Account::SingleUserExportTest < ActiveSupport::TestCase class User::DataExportTest < ActiveSupport::TestCase
test "build generates zip with card JSON files" do 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 export.build
@@ -12,7 +12,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase
end end
test "build sets status to processing then completed" do 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 export.build
@@ -21,7 +21,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase
end end
test "build sends email when completed" do 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 assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
export.build export.build
@@ -30,7 +30,7 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase
test "build includes only accessible cards for user" do test "build includes only accessible cards for user" do
user = users(:david) user = users(:david)
export = Account::SingleUserExport.create!(account: Current.account, user: user) export = User::DataExport.create!(account: Current.account, user: user)
export.build export.build
@@ -59,4 +59,12 @@ class Account::SingleUserExportTest < ActiveSupport::TestCase
end end
end 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 end