Add "data export" feature

- Adds a button in Account Settings where you can request a ZIP export of your
  Fizzy data
- Export files are created in the background. When ready, a link to
  download them is sent to the requester.
- Exports expire after 24 hours. And are limited to 10 per day.
This commit is contained in:
Kevin McConnell
2025-12-01 10:10:51 +00:00
parent 6fb7de88d7
commit e16cc21b0a
28 changed files with 565 additions and 3 deletions
+1
View File
@@ -33,6 +33,7 @@ gem "platform_agent"
gem "aws-sdk-s3", require: false
gem "web-push"
gem "net-http-persistent"
gem "rubyzip", require: "zip"
gem "mittens"
gem "useragent", bc: "useragent"
+1
View File
@@ -503,6 +503,7 @@ DEPENDENCIES
rouge
rqrcode
rubocop-rails-omakase
rubyzip
selenium-webdriver
solid_cable (>= 3.0)
solid_cache (~> 1.0)
+1
View File
@@ -632,6 +632,7 @@ DEPENDENCIES
rouge
rqrcode
rubocop-rails-omakase
rubyzip
selenium-webdriver
sentry-rails
sentry-ruby
@@ -0,0 +1,24 @@
class Account::ExportsController < ApplicationController
before_action :ensure_export_limit_not_exceeded, only: :create
before_action :set_export, only: :show
CURRENT_EXPORT_LIMIT = 10
def show
end
def create
Current.account.exports.create!(user: Current.user).build_later
redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready."
end
private
def ensure_export_limit_not_exceeded
head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT
end
def set_export
@export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user)
end
end
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.click()
}
}
+7
View File
@@ -0,0 +1,7 @@
class ExportAccountDataJob < ApplicationJob
queue_as :backend
def perform(export)
export.build
end
end
+8
View File
@@ -0,0 +1,8 @@
class ExportMailer < ApplicationMailer
def completed(export)
@export = export
@user = export.user
mail to: @user.identity.email_address, subject: "Your Fizzy export is ready"
end
end
+1
View File
@@ -8,6 +8,7 @@ class Account < ApplicationRecord
has_many :webhooks, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :columns, dependent: :destroy
has_many :exports, class_name: "Account::Export", dependent: :destroy
has_many_attached :uploads
+75
View File
@@ -0,0 +1,75 @@
class Account::Export < ApplicationRecord
belongs_to :account
belongs_to :user
has_one_attached :file
enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
scope :current, -> { where(created_at: 24.hours.ago..) }
scope :expired, -> { where(completed_at: ...24.hours.ago) }
def self.cleanup
expired.destroy_all
end
def build_later
ExportAccountDataJob.perform_later(self)
end
def build
processing!
zipfile = generate_zip
file.attach(
io: File.open(zipfile.path),
filename: "export-#{id}.zip",
content_type: "application/zip"
)
mark_completed
ExportMailer.completed(self).deliver_later
rescue => e
update!(status: :failed)
raise
ensure
zipfile&.close
zipfile&.unlink
end
def mark_completed
update!(status: :completed, completed_at: Time.current)
end
private
def generate_zip
Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
Zip::File.open(tempfile.path, create: true) do |zip|
exportable_cards.find_each do |card|
add_card_to_zip(zip, card)
end
end
end
end
def exportable_cards
user.accessible_cards.includes(
:board,
creator: :identity,
comments: { creator: :identity },
rich_text_description: { embeds_attachments: :blob }
)
end
def add_card_to_zip(zip, card)
zip.get_output_stream("#{card.number}.json") do |f|
f.write(card.export_json)
end
card.export_attachments.each do |attachment|
zip.get_output_stream(attachment[:path]) do |f|
attachment[:blob].download { |chunk| f.write(chunk) }
end
end
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
+76
View File
@@ -0,0 +1,76 @@
module Card::Exportable
extend ActiveSupport::Concern
include ActionView::Helpers::TagHelper
def export_json
{
number: number,
title: title,
board: board.name,
status: export_status,
creator: export_user(creator),
description: export_html(description),
created_at: created_at.iso8601,
updated_at: updated_at.iso8601,
comments: comments.chronologically.map do |comment|
{
id: comment.id,
body: export_html(comment.body),
creator: export_user(comment.creator),
created_at: comment.created_at.iso8601
}
end
}.to_json
end
def export_attachments
collect_attachments.map do |attachment|
{ path: export_attachment_path(attachment.blob), blob: attachment.blob }
end
end
private
def export_html(rich_text)
return "" if rich_text.blank?
rich_text.body.render_attachments do |attachment|
blob = attachment.attachable
path = export_attachment_path(blob)
if blob.image?
tag.img(src: path, alt: blob.filename)
else
tag.a(blob.filename, href: path)
end
end.to_html
end
def export_user(user)
{
id: user.id,
name: user.name,
email: user.identity&.email_address
}
end
def export_attachment_path(blob)
"#{number}/#{blob.key}_#{blob.filename}"
end
def collect_attachments
attachments.to_a + comments.flat_map { |c| c.attachments.to_a }
end
def export_status
case
when closed?
"Done"
when postponed?
"Not now"
when column.present?
column.name
else
"Maybe?"
end
end
end
+1
View File
@@ -16,6 +16,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
scope :with_avatars, -> { preload(:account, :avatar_attachment) }
+37
View File
@@ -0,0 +1,37 @@
<% if @export.present? %>
<% @page_title = "Download Export" %>
<% else %>
<% @page_title = "Download Expired" %>
<% end %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<%= icon_tag "arrow-left" %>
<strong>Back to Account Settings</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
</span>
<% end %>
</div>
<% end %>
<div class="panel panel--wide shadow center flex flex-column gap">
<header>
<h2 class="txt-large margin-none font-weight-black"><%= @page_title %></h2>
</header>
<% if @export.present? %>
<p>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--primary",
data: { turbo: false, controller: "auto-click" } do %>
Download Export
<% end %>
<% else %>
<p>Your download link has expired. You'll need to request a new export.</p>
<% end %>
</div>
@@ -0,0 +1,22 @@
<div class="settings__panel panel shadow center">
<header>
<h2 class="divider txt-large">Data Export</h2>
<p class="margin-none-block-start">You can request a ZIP file containing all your Fizzy data.</p>
</header>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn btn--primary" data-action="dialog#open">Export</button>
<dialog class="dialog panel shadow" data-dialog-target="dialog">
<p class="margin-none"><strong>Data Export</strong></p>
<p>We will create a downloadable ZIP file of all the Fizzy data that you have access to in this account.</p>
<p>This can take a few minutes. When the file is ready, we'll email you a link to download it.</p>
<p>The download link will expire after 24 hours. But you can always request another export later.</p>
<div class="flex gap justify-end">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to "Export", account_exports_path, method: :post, class: "btn btn--primary", form: { data: { action: "submit->dialog#close" } } %>
</div>
</dialog>
</div>
</div>
+1
View File
@@ -16,4 +16,5 @@
</div>
<%= render "account/settings/entropy", account: @account %>
<%= render "account/settings/export" %>
</section>
@@ -0,0 +1,6 @@
<h1 class="title">Your export is ready</h1>
<p class="subtitle">Your Fizzy data export has finished processing and is ready to download.</p>
<p><%= link_to "Download your export", account_export_url(@export) %></p>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p>
@@ -0,0 +1,3 @@
Your Fizzy data export has finished processing and is ready to download.
Download your export: <%= account_export_url(@export) %>
+3
View File
@@ -27,6 +27,9 @@ production: &production
cleanup_magic_links:
command: "MagicLink.cleanup"
schedule: every 4 hours
cleanup_exports:
command: "Account::Export.cleanup"
schedule: every hour at minute 20
<% if Fizzy.saas? %>
# Metrics
+1
View File
@@ -5,6 +5,7 @@ Rails.application.routes.draw do
resource :join_code
resource :settings
resource :entropy
resources :exports, only: [ :create, :show ]
end
resources :users do
@@ -0,0 +1,14 @@
class CreateAccountExports < ActiveRecord::Migration[8.2]
def change
create_table :account_exports, id: :uuid do |t|
t.uuid :account_id, null: false
t.uuid :user_id, null: false
t.string :status, default: "pending", null: false
t.datetime :completed_at
t.timestamps
t.index :account_id
t.index :user_id
end
end
end
Generated
+12 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -25,6 +25,17 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do
t.index ["user_id"], name: "index_accesses_on_user_id"
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.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
+12 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -25,6 +25,17 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "account_exports", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "completed_at"
t.datetime "created_at", null: false
t.string "status", limit: 255, default: "pending", null: false
t.datetime "updated_at", null: false
t.uuid "user_id", null: false
t.index ["account_id"], name: "index_account_exports_on_account_id"
t.index ["user_id"], name: "index_account_exports_on_user_id"
end
create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t|
t.bigint "value", default: 0, null: false
t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
@@ -0,0 +1,78 @@
require "test_helper"
class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :david
end
test "create creates an export record and enqueues job" do
assert_difference -> { Account::Export.count }, 1 do
assert_enqueued_with(job: ExportAccountDataJob) do
post account_exports_path
end
end
assert_redirected_to account_settings_path
assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice]
end
test "create associates export with current user" do
post account_exports_path
export = Account::Export.last
assert_equal users(:david), 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))
end
assert_no_difference -> { Account::Export.count } do
post account_exports_path
end
assert_response :too_many_requests
end
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)
end
assert_difference -> { Account::Export.count }, 1 do
post account_exports_path
end
assert_redirected_to account_settings_path
end
test "show displays completed export with download link" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
get account_export_path(export)
assert_response :success
assert_select "a#download-link"
end
test "show displays a warning if the export is missing" do
get account_export_path("not-really-an-export")
assert_response :success
assert_select "h2", "Download Expired"
end
test "show does not allow access to another user's export" do
export = Account::Export.create!(account: Current.account, user: users(:kevin))
export.build
get account_export_path(export)
assert_response :success
assert_select "h2", "Download Expired"
end
end
+12
View File
@@ -0,0 +1,12 @@
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) %>
+16
View File
@@ -0,0 +1,16 @@
require "test_helper"
class ExportMailerTest < ActionMailer::TestCase
test "completed" do
export = Account::Export.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 export is ready", email.subject
assert_match %r{/exports/#{export.id}}, email.body.encoded
end
end
@@ -0,0 +1,11 @@
class ExportMailerPreview < ActionMailer::Preview
def completed
export = Account::Export.new(
id: "preview-export-id",
account: Account.first,
user: User.first
)
ExportMailer.completed(export)
end
end
+95
View File
@@ -0,0 +1,95 @@
require "test_helper"
class Account::ExportTest < ActiveSupport::TestCase
test "build_later enqueues ExportAccountDataJob" do
export = Account::Export.create!(account: Current.account, user: users(:david))
assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do
export.build_later
end
end
test "build generates zip with card JSON files" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
assert export.completed?
assert export.file.attached?
assert_equal "application/zip", export.file.content_type
end
test "build sets status to processing then completed" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
assert export.completed?
assert_not_nil export.completed_at
end
test "build sends email when completed" do
export = Account::Export.create!(account: Current.account, user: users(:david))
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
export.build
end
end
test "build sets status to failed on error" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.stubs(:generate_zip).raises(StandardError.new("Test error"))
assert_raises(StandardError) do
export.build
end
assert export.failed?
end
test "cleanup deletes exports completed more than 24 hours ago" do
old_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 25.hours.ago)
recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago)
pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending)
Account::Export.cleanup
assert_not Account::Export.exists?(old_export.id)
assert Account::Export.exists?(recent_export.id)
assert Account::Export.exists?(pending_export.id)
end
test "build includes only accessible cards for user" do
user = users(:david)
export = Account::Export.create!(account: Current.account, user: user)
export.build
assert export.completed?
assert export.file.attached?
# Verify zip contents
Tempfile.create([ "test", ".zip" ]) do |temp|
temp.binmode
export.file.download { |chunk| temp.write(chunk) }
temp.rewind
Zip::File.open(temp.path) do |zip|
json_files = zip.glob("*.json")
assert json_files.any?, "Zip should contain at least one JSON file"
# Verify structure of a JSON file
json_content = JSON.parse(zip.read(json_files.first.name))
assert json_content.key?("number")
assert json_content.key?("title")
assert json_content.key?("board")
assert json_content.key?("creator")
assert json_content["creator"].key?("id")
assert json_content["creator"].key?("name")
assert json_content["creator"].key?("email")
assert json_content.key?("description")
assert json_content.key?("comments")
end
end
end
end
+39
View File
@@ -0,0 +1,39 @@
require "test_helper"
class Card::ExportableTest < ActiveSupport::TestCase
test "export_json returns card data as JSON" do
card = cards(:logo)
json = JSON.parse(card.export_json)
assert_equal 1, json["number"]
assert_equal "The logo isn't big enough", json["title"]
assert_equal "Writebook", json["board"]
assert_equal "Triage", json["status"]
assert_equal users(:david).id, json["creator"]["id"]
assert_equal "David", json["creator"]["name"]
assert_equal "david@37signals.com", json["creator"]["email"]
assert_equal "", json["description"]
assert_equal 5, json["comments"].count
assert_equal card.created_at.iso8601, json["created_at"]
assert_equal card.updated_at.iso8601, json["updated_at"]
end
test "export_attachments returns attachment paths and blobs" do
card = cards(:logo)
blob = ActiveStorage::Blob.create_and_upload!(
io: file_fixture("moon.jpg").open,
filename: "moon.jpg",
content_type: "image/jpeg"
)
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
card.update!(description: "<p>Here is an image:</p>#{attachment_html}")
attachments = card.export_attachments
assert_equal 1, attachments.count
assert_equal file_fixture("moon.jpg").binread, attachments.first[:blob].download
assert attachments.first[:path].start_with?("#{card.number}/")
end
end