diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css
index 252ef28357..95bd3d00f3 100644
--- a/app/assets/stylesheets/inputs.css
+++ b/app/assets/stylesheets/inputs.css
@@ -57,18 +57,12 @@
}
}
- .input--file {
+ .input--file,
+ .input--upload {
cursor: pointer;
- display: grid;
- inline-size: auto;
- place-items: center;
-
- > * {
- grid-area: 1 / 1;
- }
- img {
- border-radius: 0.4em;
+ &:has(input[type="file"]:focus-visible) {
+ outline: 0.15rem solid var(--color-selected-dark);
}
input[type="file"] {
@@ -88,10 +82,19 @@
opacity: 0;
}
}
+ }
- &:has(input[type="file"]:focus),
- &:has(input[type="file"]:focus-visible) {
- outline: 0.15rem solid var(--color-selected-dark);
+ .input--file {
+ display: grid;
+ inline-size: auto;
+ place-items: center;
+
+ > * {
+ grid-area: 1 / 1;
+ }
+
+ img {
+ border-radius: 0.4em;
}
&:is(.avatar) {
@@ -101,6 +104,25 @@
}
}
+ .input--upload {
+ --btn-border-color: var(--color-ink);
+ --btn-border-radius: 1ch;
+
+ border-style: dashed;
+ position: relative;
+
+ input[type="file"] {
+ inset: 0;
+ outline: none;
+ position: absolute;
+ }
+
+ &:has([data-upload-preview-target="fileName"]:not([hidden])) {
+ --btn-border-color: var(--color-positive);
+ --btn-color: var(--color-positive);
+ }
+ }
+
.input--select {
--input-border-radius: 2em;
--input-padding: 0.5em 1.8em 0.5em 1.2em;
diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css
index 91ff5750d7..fabea58a86 100644
--- a/app/assets/stylesheets/notifications.css
+++ b/app/assets/stylesheets/notifications.css
@@ -95,12 +95,12 @@
display: none;
.notifications--on & {
- display: block;
+ display: inline;
}
}
.notifications__off-message {
- display: block;
+ display: inline;
.notifications--on & {
display: none;
diff --git a/app/assets/stylesheets/profile-layout.css b/app/assets/stylesheets/profile-layout.css
deleted file mode 100644
index bb18c2655e..0000000000
--- a/app/assets/stylesheets/profile-layout.css
+++ /dev/null
@@ -1,16 +0,0 @@
-@layer components {
- .profile-layout {
- display: flex;
- gap: var(--inline-space);
-
- @media (min-width: 800px) {
- align-items: stretch;
- justify-content: center;
- }
-
- @media (max-width: 799px) {
- align-items: center;
- flex-direction: column;
- }
- }
-}
diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css
index cd67a4cc0e..2e234487a1 100644
--- a/app/assets/stylesheets/settings.css
+++ b/app/assets/stylesheets/settings.css
@@ -13,18 +13,21 @@
}
}
+ /* Sections & Panels
+ /* -------------------------------------------------------------------------- */
+
.settings__panel {
--panel-size: 100%;
--panel-padding: calc(var(--settings-spacer) / 1);
display: flex;
flex-direction: column;
- gap: calc(var(--settings-spacer) / 2);
+ gap: var(--panel-padding);
min-block-size: 100%;
min-inline-size: 0;
- @media (min-width: 960px) {
- --panel-padding: calc(var(--settings-spacer) * 1.5) calc(var(--settings-spacer) * 2);
+ @media (min-width: 640px) {
+ --panel-padding: calc(var(--settings-spacer) * 2);
}
}
@@ -36,6 +39,20 @@
}
}
+ .settings__section {
+ h2 {
+ font-size: var(--text-large);
+ }
+
+ > * + * {
+ margin-block-start: calc(var(--panel-padding) / 2);
+ }
+
+ &:is(:first-child):has(h2) {
+ margin-top: -0.33lh; /* Align h2 letters caps with panel padding */
+ }
+ }
+
/* Users
/* ------------------------------------------------------------------------ */
diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb
index ab40286af7..dbe0a11c10 100644
--- a/app/controllers/account/exports_controller.rb
+++ b/app/controllers/account/exports_controller.rb
@@ -1,4 +1,5 @@
class Account::ExportsController < ApplicationController
+ before_action :ensure_admin_or_owner
before_action :ensure_export_limit_not_exceeded, only: :create
before_action :set_export, only: :show
@@ -13,8 +14,12 @@ def create
end
private
+ def ensure_admin_or_owner
+ head :forbidden unless Current.user.admin? || Current.user.owner?
+ end
+
def ensure_export_limit_not_exceeded
- head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT
+ head :too_many_requests if Current.account.exports.current.count >= CURRENT_EXPORT_LIMIT
end
def set_export
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
new file mode 100644
index 0000000000..b34845b84c
--- /dev/null
+++ b/app/controllers/imports_controller.rb
@@ -0,0 +1,38 @@
+class ImportsController < ApplicationController
+ layout "public"
+
+ disallow_account_scope only: %i[ new create ]
+ before_action :set_import, only: %i[ show ]
+
+ def new
+ end
+
+ def create
+ signup = Signup.new(identity: Current.identity, full_name: "Import", skip_account_seeding: true)
+
+ if signup.complete
+ start_import(signup.account)
+ else
+ render :new, alert: "Couldn't create account."
+ end
+ end
+
+ def show
+ end
+
+ private
+ def set_import
+ @import = Current.account.imports.find(params[:id])
+ end
+
+ def start_import(account)
+ import = nil
+
+ Current.set(account: account) do
+ import = account.imports.create!(identity: Current.identity, file: params[:file])
+ import.process_later
+ end
+
+ redirect_to import_path(import, script_name: account.slug)
+ end
+end
diff --git a/app/controllers/users/data_exports_controller.rb b/app/controllers/users/data_exports_controller.rb
new file mode 100644
index 0000000000..60df1e3265
--- /dev/null
+++ b/app/controllers/users/data_exports_controller.rb
@@ -0,0 +1,33 @@
+class Users::DataExportsController < ApplicationController
+ before_action :set_user
+ before_action :ensure_current_user
+ before_action :ensure_export_limit_not_exceeded, only: :create
+ before_action :set_export, only: :show
+
+ CURRENT_EXPORT_LIMIT = 10
+
+ def show
+ end
+
+ def create
+ @user.data_exports.create!(account: Current.account).build_later
+ redirect_to @user, notice: "Export started. You'll receive an email when it's ready."
+ end
+
+ private
+ def set_user
+ @user = Current.account.users.find(params[:user_id])
+ end
+
+ def ensure_current_user
+ head :forbidden unless @user == Current.user
+ end
+
+ def ensure_export_limit_not_exceeded
+ head :too_many_requests if @user.data_exports.current.count >= CURRENT_EXPORT_LIMIT
+ end
+
+ def set_export
+ @export = @user.data_exports.completed.find_by(id: params[:id])
+ end
+end
diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js
index 14cc5bfee2..d7b0bd4783 100644
--- a/app/javascript/controllers/upload_preview_controller.js
+++ b/app/javascript/controllers/upload_preview_controller.js
@@ -1,14 +1,31 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = [ "image", "input" ]
+ static targets = [ "image", "input", "fileName", "placeholder" ]
previewImage() {
- const file = this.inputTarget.files[0]
-
- if (file) {
- this.imageTarget.src = URL.createObjectURL(file)
+ if (this.#file) {
+ this.imageTarget.src = URL.createObjectURL(this.#file)
this.imageTarget.onload = () => URL.revokeObjectURL(this.imageTarget.src)
}
}
+
+ previewFileName() {
+ this.#file ? this.#showFileName() : this.#showPlaceholder()
+ }
+
+ #showFileName() {
+ this.fileNameTarget.innerHTML = this.#file.name
+ this.fileNameTarget.removeAttribute("hidden")
+ this.placeholderTarget.setAttribute("hidden", true)
+ }
+
+ #showPlaceholder() {
+ this.placeholderTarget.removeAttribute("hidden")
+ this.fileNameTarget.setAttribute("hidden", true)
+ }
+
+ get #file() {
+ return this.inputTarget.files[0]
+ }
}
diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_data_job.rb
similarity index 72%
rename from app/jobs/export_account_data_job.rb
rename to app/jobs/export_data_job.rb
index c0286c7362..e342ea34b3 100644
--- a/app/jobs/export_account_data_job.rb
+++ b/app/jobs/export_data_job.rb
@@ -1,4 +1,4 @@
-class ExportAccountDataJob < ApplicationJob
+class ExportDataJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
diff --git a/app/jobs/import_account_data_job.rb b/app/jobs/import_account_data_job.rb
new file mode 100644
index 0000000000..863aa111fe
--- /dev/null
+++ b/app/jobs/import_account_data_job.rb
@@ -0,0 +1,23 @@
+class ImportAccountDataJob < ApplicationJob
+ include ActiveJob::Continuable
+
+ queue_as :backend
+
+ def perform(import)
+ step :validate do
+ import.validate \
+ start: step.cursor,
+ callback: proc do |record_set:, file:|
+ step.set!([ record_set.model.name, file ])
+ end
+ end
+
+ step :process do
+ import.process \
+ start: step.cursor,
+ callback: proc do |record_set:, files:|
+ step.set!([ record_set.model.name, files.last ])
+ end
+ end
+ end
+end
diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb
index 5385aeed6f..045d954d0e 100644
--- a/app/mailers/export_mailer.rb
+++ b/app/mailers/export_mailer.rb
@@ -1,8 +1,19 @@
class ExportMailer < ApplicationMailer
+ helper_method :export_download_url
+
def completed(export)
@export = export
@user = export.user
mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download"
end
+
+ private
+ def export_download_url(export)
+ if export.is_a?(User::DataExport)
+ user_data_export_url(export.user, export)
+ else
+ account_export_url(export)
+ end
+ end
end
diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb
new file mode 100644
index 0000000000..df557ad057
--- /dev/null
+++ b/app/mailers/import_mailer.rb
@@ -0,0 +1,12 @@
+class ImportMailer < ApplicationMailer
+ def completed(identity, account)
+ @account = account
+ @landing_url = landing_url(script_name: account.slug)
+ mail to: identity.email_address, subject: "Your Fizzy account import is complete"
+ end
+
+ def failed(identity, account)
+ @account = account
+ mail to: identity.email_address, subject: "Your Fizzy account import failed"
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 34b63b6889..7087a5b9b6 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -8,7 +8,9 @@ class Account < ApplicationRecord
has_many :webhooks, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :columns, dependent: :destroy
+ has_many :entropies, dependent: :destroy
has_many :exports, class_name: "Account::Export", dependent: :destroy
+ has_many :imports, class_name: "Account::Import", dependent: :destroy
before_create :assign_external_account_id
after_create :create_join_code
diff --git a/app/models/account/data_transfer/account_record_set.rb b/app/models/account/data_transfer/account_record_set.rb
new file mode 100644
index 0000000000..86bc790674
--- /dev/null
+++ b/app/models/account/data_transfer/account_record_set.rb
@@ -0,0 +1,58 @@
+class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet
+ ACCOUNT_ATTRIBUTES = %w[
+ join_code
+ name
+ ]
+
+ JOIN_CODE_ATTRIBUTES = %w[
+ code
+ usage_count
+ usage_limit
+ ]
+
+ def initialize(account)
+ super(account: account, model: Account)
+ end
+
+ private
+ def records
+ [ account ]
+ end
+
+ def export_record(account)
+ zip.add_file "data/account.json", account.as_json.merge(join_code: account.join_code.as_json).to_json
+ end
+
+ def files
+ [ "data/account.json" ]
+ end
+
+ def import_batch(files)
+ account_data = load(files.first)
+ join_code_data = account_data.delete("join_code")
+
+ account.update!(name: account_data.fetch("name"))
+ account.join_code.update!(join_code_data.slice("usage_count", "usage_limit"))
+ account.join_code.update(code: join_code_data.fetch("code"))
+ end
+
+ def validate_record(file_path)
+ data = load(file_path)
+
+ unless (ACCOUNT_ATTRIBUTES - data.keys).empty?
+ raise IntegrityError, "Account record missing required fields"
+ end
+
+ unless data.key?("join_code")
+ raise IntegrityError, "Account record missing 'join_code' field"
+ end
+
+ unless data["join_code"].is_a?(Hash)
+ raise IntegrityError, "'join_code' field must be a JSON object"
+ end
+
+ unless (JOIN_CODE_ATTRIBUTES - data["join_code"].keys).empty?
+ raise IntegrityError, "'join_code' field missing required keys"
+ end
+ end
+end
diff --git a/app/models/account/data_transfer/action_text_rich_text_record_set.rb b/app/models/account/data_transfer/action_text_rich_text_record_set.rb
new file mode 100644
index 0000000000..0ba663e1ea
--- /dev/null
+++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb
@@ -0,0 +1,86 @@
+class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet
+ ATTRIBUTES = %w[
+ account_id
+ body
+ created_at
+ id
+ name
+ record_id
+ record_type
+ updated_at
+ ].freeze
+
+ def initialize(account)
+ super(account: account, model: ActionText::RichText)
+ end
+
+ private
+ def records
+ ActionText::RichText.where(account: account)
+ end
+
+ def export_record(rich_text)
+ data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body))
+ zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json
+ end
+
+ def files
+ zip.glob("data/action_text_rich_texts/*.json")
+ end
+
+ def import_batch(files)
+ batch_data = files.map do |file|
+ data = load(file)
+ data["body"] = convert_gids_to_sgids(data["body"])
+ data.slice(*ATTRIBUTES).merge("account_id" => account.id)
+ end
+
+ ActionText::RichText.insert_all!(batch_data)
+ end
+
+ def validate_record(file_path)
+ data = load(file_path)
+ expected_id = File.basename(file_path, ".json")
+
+ unless data["id"].to_s == expected_id
+ raise IntegrityError, "ActionTextRichText record ID mismatch: expected #{expected_id}, got #{data['id']}"
+ end
+
+ missing = ATTRIBUTES - data.keys
+ if missing.any?
+ raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
+ end
+ end
+
+ def convert_sgids_to_gids(content)
+ return nil if content.blank?
+
+ content.send(:attachment_nodes).each do |node|
+ sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
+ record = sgid&.find
+ next if record&.account_id != account.id
+
+ node["gid"] = record.to_global_id.to_s
+ node.remove_attribute("sgid")
+ end
+
+ content.fragment.source.to_html
+ end
+
+ def convert_gids_to_sgids(html)
+ return html if html.blank?
+
+ fragment = Nokogiri::HTML.fragment(html)
+
+ fragment.css("action-text-attachment[gid]").each do |node|
+ gid = GlobalID.parse(node["gid"])
+ next unless gid
+
+ record = gid.find
+ node["sgid"] = record.attachable_sgid
+ node.remove_attribute("gid")
+ end
+
+ fragment.to_html
+ end
+end
diff --git a/app/models/account/data_transfer/blob_file_record_set.rb b/app/models/account/data_transfer/blob_file_record_set.rb
new file mode 100644
index 0000000000..c17070ed27
--- /dev/null
+++ b/app/models/account/data_transfer/blob_file_record_set.rb
@@ -0,0 +1,43 @@
+class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet
+ def initialize(account)
+ super(account: account, model: ActiveStorage::Blob)
+ end
+
+ private
+ def records
+ ActiveStorage::Blob.where(account: account)
+ end
+
+ def export_record(blob)
+ zip.add_file("storage/#{blob.key}", compress: false) do |out|
+ blob.download { |chunk| out.write(chunk) }
+ end
+ rescue ActiveStorage::FileNotFoundError
+ # Skip blobs where the file is missing from storage
+ end
+
+ def files
+ zip.glob("storage/*")
+ end
+
+ def import_batch(files)
+ files.each do |file|
+ key = File.basename(file)
+ blob = ActiveStorage::Blob.find_by(key: key, account: account)
+ next unless blob
+
+ zip.read(file) do |stream|
+ blob.upload(stream)
+ end
+ end
+ end
+
+ def validate_record(file_path)
+ key = File.basename(file_path)
+
+ unless zip.exists?("data/active_storage_blobs/#{key}.json") || ActiveStorage::Blob.exists?(key: key, account: account)
+ # File exists without corresponding blob record - could be orphaned or blob not yet imported
+ # We allow this since blob metadata is imported before files
+ end
+ end
+end
diff --git a/app/models/account/data_transfer/entropy_record_set.rb b/app/models/account/data_transfer/entropy_record_set.rb
new file mode 100644
index 0000000000..e620a8121b
--- /dev/null
+++ b/app/models/account/data_transfer/entropy_record_set.rb
@@ -0,0 +1,32 @@
+class Account::DataTransfer::EntropyRecordSet < Account::DataTransfer::RecordSet
+ def initialize(account)
+ super(account: account, model: Entropy)
+ end
+
+ private
+ def import_batch(files)
+ batch_data = files.map do |file|
+ data = load(file)
+ data.slice(*attributes).merge("account_id" => account.id)
+ end
+
+ container_keys = batch_data.map { |d| [ d["container_type"], d["container_id"] ] }
+ existing_containers = Entropy
+ .where(account_id: account.id)
+ .where(container_type: container_keys.map(&:first), container_id: container_keys.map(&:last))
+ .pluck(:container_type, :container_id)
+ .to_set
+
+ to_update, to_insert = batch_data.partition do |data|
+ existing_containers.include?([ data["container_type"], data["container_id"] ])
+ end
+
+ to_update.each do |data|
+ Entropy
+ .find_by(account_id: account.id, container_type: data["container_type"], container_id: data["container_id"])
+ .update!(data.slice("auto_postpone_period"))
+ end
+
+ Entropy.insert_all!(to_insert) if to_insert.any?
+ end
+end
diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb
new file mode 100644
index 0000000000..33b7e1e93d
--- /dev/null
+++ b/app/models/account/data_transfer/manifest.rb
@@ -0,0 +1,49 @@
+class Account::DataTransfer::Manifest
+ attr_reader :account
+
+ def initialize(account)
+ @account = account
+ end
+
+ def each_record_set(start: nil)
+ raise ArgumentError, "No block given" unless block_given?
+
+ started = start.nil?
+ record_class, last_id = start if start
+
+ record_sets.each do |record_set|
+ if started
+ yield record_set
+ elsif record_set.model.name == record_class
+ started = true
+ yield record_set, last_id
+ end
+ end
+ end
+
+ private
+ def record_sets
+ [
+ Account::DataTransfer::AccountRecordSet.new(account),
+ Account::DataTransfer::UserRecordSet.new(account),
+ *build_record_sets(::User::Settings, ::Tag, ::Board, ::Column),
+ Account::DataTransfer::EntropyRecordSet.new(account),
+ *build_record_sets(
+ ::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step,
+ ::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow,
+ ::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention,
+ ::Filter, ::Webhook::DelinquencyTracker, ::Event,
+ ::Notification, ::Notification::Bundle, ::Webhook::Delivery,
+ ::ActiveStorage::Blob, ::ActiveStorage::Attachment
+ ),
+ Account::DataTransfer::ActionTextRichTextRecordSet.new(account),
+ Account::DataTransfer::BlobFileRecordSet.new(account)
+ ]
+ end
+
+ def build_record_sets(*models)
+ models.map do |model|
+ Account::DataTransfer::RecordSet.new(account: account, model: model)
+ end
+ end
+end
diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb
new file mode 100644
index 0000000000..61359b0fda
--- /dev/null
+++ b/app/models/account/data_transfer/record_set.rb
@@ -0,0 +1,127 @@
+class Account::DataTransfer::RecordSet
+ class IntegrityError < StandardError; end
+
+ IMPORT_BATCH_SIZE = 100
+
+ attr_reader :account, :model, :attributes
+
+ def initialize(account:, model:, attributes: nil)
+ @account = account
+ @model = model
+ @attributes = (attributes || model.column_names).map(&:to_s)
+ end
+
+ def export(to:, start: nil)
+ with_zip(to) do
+ block = lambda do |record|
+ export_record(record)
+ end
+
+ records.respond_to?(:find_each) ? records.find_each(&block) : records.each(&block)
+ end
+ end
+
+ def import(from:, start: nil, callback: nil)
+ with_zip(from) do
+ file_list = files
+ file_list = skip_to(file_list, start) if start
+
+ file_list.each_slice(IMPORT_BATCH_SIZE) do |file_batch|
+ import_batch(file_batch)
+ callback&.call(record_set: self, files: file_batch)
+ end
+ end
+ end
+
+ def validate(from:, start: nil, callback: nil)
+ with_zip(from) do
+ file_list = files
+ file_list = skip_to(file_list, start) if start
+
+ file_list.each do |file_path|
+ validate_record(file_path)
+ callback&.call(record_set: self, file: file_path)
+ end
+ end
+ end
+
+ private
+ attr_reader :zip
+
+ def with_zip(zip)
+ old_zip = @zip
+ @zip = zip
+ yield
+ ensure
+ @zip = old_zip
+ end
+
+ def records
+ model.where(account_id: account.id)
+ end
+
+ def export_record(record)
+ zip.add_file "data/#{model_dir}/#{record.id}.json", record.to_json
+ end
+
+ def files
+ zip.glob("data/#{model_dir}/*.json")
+ end
+
+ def import_batch(files)
+ batch_data = files.map do |file|
+ data = load(file)
+ data.slice(*attributes).merge("account_id" => account.id)
+ end
+
+ model.insert_all!(batch_data)
+ end
+
+ def validate_record(file_path)
+ data = load(file_path)
+ expected_id = File.basename(file_path, ".json")
+
+ unless data["id"].to_s == expected_id
+ raise IntegrityError, "#{model} record ID mismatch: expected #{expected_id}, got #{data['id']}"
+ end
+
+ missing = attributes - data.keys
+ if missing.any?
+ raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
+ end
+
+ if model.exists?(id: data["id"])
+ raise IntegrityError, "#{model} record with ID #{data['id']} already exists"
+ end
+
+ record = model.new(data.slice(*attributes).merge("account_id" => account.id))
+ belongs_to_associations = model.reflect_on_all_associations(:belongs_to).map { |a| a.name.to_s }
+
+ record.validate
+ errors = record.errors.reject { |e| belongs_to_associations.include?(e.attribute.to_s) }
+
+ if errors.any?
+ raise IntegrityError, "Validation failed for #{model} record ID #{data['id']}: #{errors.map(&:full_message).join(', ')}"
+ end
+ end
+
+ def skip_to(file_list, last_id)
+ index = file_list.index(last_id)
+
+ if index
+ file_list[(index + 1)..]
+ else
+ file_list
+ end
+ end
+
+ def load(file_path)
+ JSON.parse(zip.read(file_path))
+ rescue ArgumentError => e
+ raise IntegrityError, e.message
+ end
+
+ def model_dir
+ model.table_name
+ end
+end
diff --git a/app/models/account/data_transfer/user_record_set.rb b/app/models/account/data_transfer/user_record_set.rb
new file mode 100644
index 0000000000..a0a140f2dd
--- /dev/null
+++ b/app/models/account/data_transfer/user_record_set.rb
@@ -0,0 +1,61 @@
+class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet
+ ATTRIBUTES = %w[
+ id
+ email_address
+ name
+ role
+ active
+ verified_at
+ created_at
+ updated_at
+ ]
+
+ def initialize(account)
+ super(account: account, model: User)
+ end
+
+ private
+ def records
+ User.where(account: account)
+ end
+
+ def export_record(user)
+ zip.add_file "data/users/#{user.id}.json", user.as_json.merge(email_address: user.identity&.email_address).to_json
+ end
+
+ def files
+ zip.glob("data/users/*.json")
+ end
+
+ def import_batch(files)
+ batch_data = files.map do |file|
+ user_data = load(file)
+ email_address = user_data.delete("email_address")
+
+ identity = Identity.find_or_create_by!(email_address: email_address) if email_address.present?
+
+ user_data.slice(*ATTRIBUTES).merge(
+ "account_id" => account.id,
+ "identity_id" => identity&.id
+ )
+ end
+
+ conflicting_identity_ids = batch_data.pluck("identity_id").compact
+ account.users.where(identity_id: conflicting_identity_ids).destroy_all
+
+ User.insert_all!(batch_data)
+ end
+
+ def validate_record(file_path)
+ data = load(file_path)
+ expected_id = File.basename(file_path, ".json")
+
+ unless data["id"].to_s == expected_id
+ raise IntegrityError, "User record ID mismatch: expected #{expected_id}, got #{data['id']}"
+ end
+
+ unless (ATTRIBUTES - data.keys).empty?
+ raise IntegrityError, "#{file_path} is missing required fields"
+ end
+ end
+end
diff --git a/app/models/account/data_transfer/zip_file.rb b/app/models/account/data_transfer/zip_file.rb
new file mode 100644
index 0000000000..b79771927d
--- /dev/null
+++ b/app/models/account/data_transfer/zip_file.rb
@@ -0,0 +1,57 @@
+class Account::DataTransfer::ZipFile
+ class << self
+ def create
+ raise ArgumentError, "No block given" unless block_given?
+
+ Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
+ Zip::File.open(tempfile.path, create: true) do |zip|
+ yield new(zip)
+ end
+ end
+ end
+
+ def open(path)
+ raise ArgumentError, "No block given" unless block_given?
+
+ Zip::File.open(path.to_s) do |zip|
+ yield new(zip)
+ end
+ end
+ end
+
+ def initialize(zip)
+ @zip = zip
+ end
+
+ def add_file(path, content = nil, compress: true, &block)
+ if block_given?
+ compression = compress ? nil : Zip::Entry::STORED
+ zip.get_output_stream(path, compression_method: compression, &block)
+ else
+ zip.get_output_stream(path) { |f| f.write(content) }
+ end
+ end
+
+ def glob(pattern)
+ zip.glob(pattern).map(&:name).sort
+ end
+
+ def read(file_path, &block)
+ entry = zip.find_entry(file_path)
+ raise ArgumentError, "File not found in zip: #{file_path}" unless entry
+ raise ArgumentError, "Cannot read directory entry: #{file_path}" if entry.directory?
+
+ if block_given?
+ yield entry.get_input_stream
+ else
+ entry.get_input_stream.read
+ end
+ end
+
+ def exists?(file_path)
+ zip.find_entry(file_path).present?
+ end
+
+ private
+ attr_reader :zip
+end
diff --git a/app/models/account/export.rb b/app/models/account/export.rb
index 1fd2689d2c..6d78513246 100644
--- a/app/models/account/export.rb
+++ b/app/models/account/export.rb
@@ -1,77 +1,8 @@
-class Account::Export < ApplicationRecord
- belongs_to :account
- belongs_to :user
-
- has_one_attached :file
-
- enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
-
- scope :current, -> { where(created_at: 24.hours.ago..) }
- scope :expired, -> { where(completed_at: ...24.hours.ago) }
-
- def self.cleanup
- expired.destroy_all
- end
-
- def build_later
- ExportAccountDataJob.perform_later(self)
- end
-
- def build
- processing!
- zipfile = generate_zip
-
- file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip"
- mark_completed
-
- ExportMailer.completed(self).deliver_later
- rescue => e
- update!(status: :failed)
- raise
- ensure
- zipfile&.close
- zipfile&.unlink
- end
-
- def mark_completed
- update!(status: :completed, completed_at: Time.current)
- end
-
- def accessible_to?(accessor)
- accessor == user
- end
-
+class Account::Export < Export
private
- def generate_zip
- Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
- Zip::File.open(tempfile.path, create: true) do |zip|
- exportable_cards.find_each do |card|
- add_card_to_zip(zip, card)
- end
- end
- end
- end
-
- def exportable_cards
- user.accessible_cards.includes(
- :board,
- creator: :identity,
- comments: { creator: :identity },
- rich_text_description: { embeds_attachments: :blob }
- )
- end
-
- def add_card_to_zip(zip, card)
- zip.get_output_stream("#{card.number}.json") do |f|
- f.write(card.export_json)
- end
-
- card.export_attachments.each do |attachment|
- zip.get_output_stream(attachment[:path], compression_method: Zip::Entry::STORED) do |f|
- attachment[:blob].download { |chunk| f.write(chunk) }
- end
- rescue ActiveStorage::FileNotFoundError
- # Skip attachments where the file is missing from storage
+ def populate_zip(zip)
+ Account::DataTransfer::Manifest.new(account).each_record_set do |record_set|
+ record_set.export(to: Account::DataTransfer::ZipFile.new(zip))
end
end
end
diff --git a/app/models/account/import.rb b/app/models/account/import.rb
new file mode 100644
index 0000000000..5ebb1288b1
--- /dev/null
+++ b/app/models/account/import.rb
@@ -0,0 +1,60 @@
+class Account::Import < ApplicationRecord
+ broadcasts_refreshes
+
+ belongs_to :account
+ belongs_to :identity
+
+ has_one_attached :file
+
+ enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
+
+ def process_later
+ ImportAccountDataJob.perform_later(self)
+ end
+
+ def process(start: nil, callback: nil)
+ processing!
+ ensure_downloaded
+
+ Account::DataTransfer::ZipFile.open(download_path) do |zip|
+ Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id|
+ record_set.import(from: zip, start: last_id, callback: callback)
+ end
+ end
+
+ mark_completed
+ rescue => e
+ failed!
+ ImportMailer.failed(identity, account).deliver_later
+ raise e
+ end
+
+ def validate(start: nil, callback: nil)
+ processing!
+ ensure_downloaded
+
+ Account::DataTransfer::ZipFile.open(download_path) do |zip|
+ Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id|
+ record_set.validate(from: zip, start: last_id, callback: callback)
+ end
+ end
+ end
+
+ private
+ def ensure_downloaded
+ unless download_path.exist?
+ download_path.open("wb") do |f|
+ file.download { |chunk| f.write(chunk) }
+ end
+ end
+ end
+
+ def download_path
+ Pathname.new("/tmp/account-import-#{id}.zip")
+ end
+
+ def mark_completed
+ completed!
+ ImportMailer.completed(identity, account).deliver_later
+ end
+end
diff --git a/app/models/export.rb b/app/models/export.rb
new file mode 100644
index 0000000000..dde40b28d4
--- /dev/null
+++ b/app/models/export.rb
@@ -0,0 +1,78 @@
+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!
+
+ zipfile = nil
+ with_account_context do
+ zipfile = generate_zip { |zip| populate_zip(zip) }
+ file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip"
+ mark_completed
+ ExportMailer.completed(self).deliver_later
+ end
+ rescue => e
+ update!(status: :failed)
+ raise e
+ ensure
+ zipfile&.close
+ zipfile&.unlink
+ end
+
+ def mark_completed
+ update!(status: :completed, completed_at: Time.current)
+ end
+
+ def accessible_to?(accessor)
+ accessor == user
+ end
+
+ private
+ def with_account_context
+ Current.set(account: account) do
+ yield
+ end
+ end
+
+ def populate_zip(zip)
+ raise NotImplementedError, "Subclasses must implement populate_zip"
+ end
+
+ def generate_zip
+ raise ArgumentError, "Block is required" unless block_given?
+
+ Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
+ Zip::File.open(tempfile.path, create: true) do |zip|
+ yield zip
+ end
+ end
+ end
+
+ def add_file_to_zip(zip, path, content = nil, **options)
+ zip.get_output_stream(path, **options) do |f|
+ if block_given?
+ yield f
+ elsif content
+ f.write(content)
+ else
+ raise ArgumentError, "Either content or a block must be provided"
+ end
+ end
+ end
+end
diff --git a/app/models/signup.rb b/app/models/signup.rb
index 041f461acc..2f096165de 100644
--- a/app/models/signup.rb
+++ b/app/models/signup.rb
@@ -3,7 +3,7 @@ class Signup
include ActiveModel::Attributes
include ActiveModel::Validations
- attr_accessor :full_name, :email_address, :identity
+ attr_accessor :full_name, :email_address, :identity, :skip_account_seeding
attr_reader :account, :user
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
@@ -65,7 +65,7 @@ def create_account
}
)
@user = @account.users.find_by!(role: :owner)
- @account.setup_customer_template
+ @account.setup_customer_template unless skip_account_seeding
end
def generate_account_name
diff --git a/app/models/user.rb b/app/models/user.rb
index e16d70670a..842f7f6c80 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,7 +14,7 @@ class User < ApplicationRecord
has_many :closures, dependent: :nullify
has_many :pins, dependent: :destroy
has_many :pinned_cards, through: :pins, source: :card
- has_many :exports, class_name: "Account::Export", dependent: :destroy
+ has_many :data_exports, class_name: "User::DataExport", dependent: :destroy
def deactivate
transaction do
diff --git a/app/models/user/data_export.rb b/app/models/user/data_export.rb
new file mode 100644
index 0000000000..4eb7b9e775
--- /dev/null
+++ b/app/models/user/data_export.rb
@@ -0,0 +1,29 @@
+class User::DataExport < Export
+ private
+ def populate_zip(zip)
+ exportable_cards.find_each do |card|
+ add_card_to_zip(zip, card)
+ end
+ end
+
+ def exportable_cards
+ user.accessible_cards.includes(
+ :board,
+ creator: :identity,
+ comments: { creator: :identity },
+ rich_text_description: { embeds_attachments: :blob }
+ )
+ end
+
+ def add_card_to_zip(zip, card)
+ add_file_to_zip(zip, "#{card.number}.json", card.export_json)
+
+ card.export_attachments.each do |attachment|
+ add_file_to_zip(zip, attachment[:path], compression_method: Zip::Entry::STORED) do |f|
+ attachment[:blob].download { |chunk| f.write(chunk) }
+ end
+ rescue ActiveStorage::FileNotFoundError
+ # Skip attachments where the file is missing from storage
+ end
+ end
+end
diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb
index f01c13dc5a..77fd79b0d6 100644
--- a/app/views/account/exports/show.html.erb
+++ b/app/views/account/exports/show.html.erb
@@ -10,20 +10,18 @@
<% end %>
-
-
<%= @page_title %>
+
+
<%= @page_title %>
<% if @export.present? %>
-
Your export is ready. The download should start automatically.
+
Your export is ready. The download should start automatically.
- <%= link_to rails_blob_path(@export.file, disposition: "attachment"),
+ <%= link_to "Download your data", 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 %>
+ data: { turbo: false, controller: "auto-click" } %>
<% else %>
-
That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.
+
That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-link" %>.
<% end %>
diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb
index daac70a4a1..4a24d4cd64 100644
--- a/app/views/account/settings/_entropy.html.erb
+++ b/app/views/account/settings/_entropy.html.erb
@@ -1,7 +1,8 @@
-
- Auto close
- Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.
-
-
-<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
+
+
+ Auto close
+ Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.
+
+ <%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
+
diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb
index d44bb4b744..ad2d8392a6 100644
--- a/app/views/account/settings/_export.html.erb
+++ b/app/views/account/settings/_export.html.erb
@@ -1,19 +1,21 @@
-
+
+
-
-
Begin export...
+
+
Begin export...
-
- 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.
+ We‘ll email you a link to download the file when it‘s ready. 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" } } %>
- Cancel
-
-
-
\ No newline at end of file
+
+ <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
+ Cancel
+
+
+
+
diff --git a/app/views/account/settings/_name.html.erb b/app/views/account/settings/_name.html.erb
index e3b12f325e..6ad8667811 100644
--- a/app/views/account/settings/_name.html.erb
+++ b/app/views/account/settings/_name.html.erb
@@ -1,10 +1,12 @@
-<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %>
-
<%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %>
+
+ <%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %>
+ <%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %>
- <% if Current.user.admin? %>
- <%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %>
- <%= icon_tag "arrow-right" %>
- Save changes
+ <% if Current.user.admin? %>
+ <%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %>
+ <%= icon_tag "arrow-right" %>
+ Save changes
+ <% end %>
<% end %>
<% end %>
-<% end %>
+
diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb
index 63b5b17637..aeee056389 100644
--- a/app/views/account/settings/_users.html.erb
+++ b/app/views/account/settings/_users.html.erb
@@ -1,24 +1,26 @@
-
- People on this account
-
+
+
+ People on this account
+
-<%= tag.div class: "flex flex-column gap settings__user-filter", data: {
- controller: "filter navigable-list",
- action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
- navigable_list_focus_on_selection_value: true,
- navigable_list_actionable_items_value: true
-} do %>
+ <%= tag.div class: "flex flex-column gap settings__user-filter", data: {
+ controller: "filter navigable-list",
+ action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
+ navigable_list_focus_on_selection_value: true,
+ navigable_list_actionable_items_value: true
+ } do %>
-
-
+
+
-
- <%= render partial: "account/settings/user", collection: users %>
-
-
+
+ <%= render partial: "account/settings/user", collection: users %>
+
+
- <%= link_to account_join_code_path, class: "btn btn--link center txt-small" do %>
- <%= icon_tag "add" %>
- Invite people
+ <%= link_to account_join_code_path, class: "btn btn--link center" do %>
+ <%= icon_tag "add" %>
+ Invite people
+ <% end %>
<% end %>
-<% end %>
+
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb
index 74e2744148..0347466818 100644
--- a/app/views/account/settings/show.html.erb
+++ b/app/views/account/settings/show.html.erb
@@ -9,7 +9,7 @@
<% end %>
-
+
<%= render "account/settings/name", account: @account %>
<%= render "account/settings/users", users: @users %>
@@ -17,9 +17,9 @@
<%= render "account/settings/entropy", account: @account %>
- <%= render "account/settings/export" %>
+ <%= render "account/settings/export" if Current.user.admin? || Current.user.owner? %>
<%= render "account/settings/cancellation" %>
-
+
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb
index 36beee3c86..403fe823aa 100644
--- a/app/views/boards/edit.html.erb
+++ b/app/views/boards/edit.html.erb
@@ -14,7 +14,7 @@
<% end %>
-
+
<%= form_with model: @board, class: "display-contents", data: {
controller: "form boards-form",
boards_form_self_removal_prompt_message_value: "Are you sure you want to remove yourself from this board? You won’t be able to get back in unless someone invites you.",
@@ -30,7 +30,7 @@
<% end %>
-
+
<%= render "boards/edit/auto_close", board: @board %>
<%= render "boards/edit/publication", board: @board %>
<%= render "boards/edit/delete", board: @board if Current.user.can_administer_board?(@board) %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb
new file mode 100644
index 0000000000..886a0b48f1
--- /dev/null
+++ b/app/views/imports/new.html.erb
@@ -0,0 +1,26 @@
+<% @page_title = "Import an account" %>
+
+
+
+ Import a Fizzy account
+ Upload the .zip file from your Fizzy export
+ Upload the .zip file from a Fizzy export to create a new account.
+ Create an account using data from a Fizzy export. Upload the exported .zip file below.
+
+
+ <%= form_with url: imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %>
+
+ Choose a file…
+
+ <%= form.file_field :file, accept: ".zip", required: true, data: { action: "upload-preview#previewFileName", upload_preview_target: "input" } %>
+
+
+
+ Start Import →
+
+ <% end %>
+
+
+<% content_for :footer do %>
+ <%= render "sessions/footer" %>
+<% end %>
diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb
new file mode 100644
index 0000000000..a879996e13
--- /dev/null
+++ b/app/views/imports/show.html.erb
@@ -0,0 +1,23 @@
+<% @page_title = "Import status" %>
+
+<%= turbo_stream_from @import %>
+
+
+
Import status
+
+ <% case @import.status %>
+ <% when "pending", "processing" %>
+
Your import is in progress. This may take a while for large accounts.
+ <% when "completed" %>
+
Your import has completed successfully!
+ <%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %>
+ <% when "failed" %>
+
Your import failed.
+
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.
+ <%= link_to "Try again", new_import_path, class: "btn btn--plain center txt-medium" %>
+ <% end %>
+
+
+<% content_for :footer do %>
+ <%= render "sessions/footer" %>
+<% end %>
diff --git a/app/views/mailers/export_mailer/completed.html.erb b/app/views/mailers/export_mailer/completed.html.erb
index c0d458fc27..cadf87dca7 100644
--- a/app/views/mailers/export_mailer/completed.html.erb
+++ b/app/views/mailers/export_mailer/completed.html.erb
@@ -1,6 +1,6 @@
Download your Fizzy data
Your Fizzy data export has finished processing and is ready to download.
-
<%= link_to "Download your data", account_export_url(@export) %>
+
<%= link_to "Download your data", export_download_url(@export) %>
diff --git a/app/views/mailers/export_mailer/completed.text.erb b/app/views/mailers/export_mailer/completed.text.erb
index e5c274a9af..75f7491839 100644
--- a/app/views/mailers/export_mailer/completed.text.erb
+++ b/app/views/mailers/export_mailer/completed.text.erb
@@ -1,3 +1,3 @@
Your Fizzy data export has finished processing and is ready to download.
-Download your data: <%= account_export_url(@export) %>
+Download your data: <%= export_download_url(@export) %>
diff --git a/app/views/mailers/import_mailer/completed.html.erb b/app/views/mailers/import_mailer/completed.html.erb
new file mode 100644
index 0000000000..b598ef0be0
--- /dev/null
+++ b/app/views/mailers/import_mailer/completed.html.erb
@@ -0,0 +1,6 @@
+
Your Fizzy account import is complete
+
Your import to <%= @account.name %> is complete.
+
+
<%= link_to "Go to your account", @landing_url %>
+
+
diff --git a/app/views/mailers/import_mailer/completed.text.erb b/app/views/mailers/import_mailer/completed.text.erb
new file mode 100644
index 0000000000..df9f0fd542
--- /dev/null
+++ b/app/views/mailers/import_mailer/completed.text.erb
@@ -0,0 +1,3 @@
+Your import to <%= @account.name %> is complete.
+
+Go to your account: <%= @landing_url %>
diff --git a/app/views/mailers/import_mailer/failed.html.erb b/app/views/mailers/import_mailer/failed.html.erb
new file mode 100644
index 0000000000..cd3999957e
--- /dev/null
+++ b/app/views/mailers/import_mailer/failed.html.erb
@@ -0,0 +1,6 @@
+
Your Fizzy account import failed
+
Unfortunately, we were unable to import your Fizzy account data.
+
+
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or contact support if the problem persists.
+
+
diff --git a/app/views/mailers/import_mailer/failed.text.erb b/app/views/mailers/import_mailer/failed.text.erb
new file mode 100644
index 0000000000..1a0f0c1713
--- /dev/null
+++ b/app/views/mailers/import_mailer/failed.text.erb
@@ -0,0 +1,7 @@
+Your Fizzy account import failed
+
+Unfortunately, we were unable to import your Fizzy account data.
+
+This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or contact support if the problem persists.
+
+Need help? Send us an email at support@fizzy.do
diff --git a/app/views/notifications/settings/_board.html.erb b/app/views/notifications/settings/_board.html.erb
index a5f4857522..bf9a41b94d 100644
--- a/app/views/notifications/settings/_board.html.erb
+++ b/app/views/notifications/settings/_board.html.erb
@@ -1,5 +1,5 @@
<%= turbo_frame_tag board, :involvement do %>
-
+
<%= board.name %>
diff --git a/app/views/notifications/settings/_email.html.erb b/app/views/notifications/settings/_email.html.erb
index a51aea099b..7ff1b8b4ed 100644
--- a/app/views/notifications/settings/_email.html.erb
+++ b/app/views/notifications/settings/_email.html.erb
@@ -1,12 +1,12 @@
-
- Email Notifications
- Get a single email with all your notifications every few hours, daily, or weekly.
- <%= form_with model: settings, url: notifications_settings_path,
- class: "flex flex-column gap-half",
+
+
+ Email Notifications
+ Get a single email with all your notifications every few hours, daily, or weekly.
+
+
+ <%= form_with model: settings, url: notifications_settings_path,
method: :patch, local: true, data: { controller: "form" } do |form| %>
-
- <%= form.label :bundle_email_frequency, "Email me about new notifications..." %>
- <%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %>
-
+ <%= form.label :bundle_email_frequency, "Email me about new notifications..." %>
+ <%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %>
<% end %>
diff --git a/app/views/notifications/settings/_push_notifications.html.erb b/app/views/notifications/settings/_push_notifications.html.erb
index 047f57a159..d4e03d885b 100644
--- a/app/views/notifications/settings/_push_notifications.html.erb
+++ b/app/views/notifications/settings/_push_notifications.html.erb
@@ -1,6 +1,11 @@
-
-
Push notifications are ON
-
Push notifications are OFF
+
+
+
+ Push notifications are
+ ON
+ OFF
+
+
@@ -23,4 +28,4 @@
<%= render partial: "notifications/settings/install" %>
-
+
diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb
index 5972e7fdad..adf5af6811 100644
--- a/app/views/notifications/settings/show.html.erb
+++ b/app/views/notifications/settings/show.html.erb
@@ -5,14 +5,16 @@
<% end %>
-
-
Boards
-
- <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %>
-
+
+
+ Boards
+
+ <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %>
+
+
-
+
<%= render "notifications/settings/push_notifications" %>
<%= render "notifications/settings/email", settings: @settings %>
diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb
index 09383b72aa..e3843321e4 100644
--- a/app/views/sessions/menus/show.html.erb
+++ b/app/views/sessions/menus/show.html.erb
@@ -1,15 +1,15 @@
<% @page_title = "Choose an account" %>
<% cache [ Current.identity, @accounts ] do %>
-
<% if @accounts.any? %>
-
+
Your Fizzy accounts
-
+ <%= link_to new_signup_path, class: "btn center txt-small margin-block-start", data: { turbo_prefetch: false } do %>
+
Sign up for a new Fizzy account
+ <% end %>
+
+ <%= link_to new_import_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
+
Import an exported account
+ <% end %>
+
<% end %>
<% content_for :footer do %>
diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb
index 285f87149e..75bffa95c9 100644
--- a/app/views/users/_access_tokens.html.erb
+++ b/app/views/users/_access_tokens.html.erb
@@ -1,4 +1,6 @@
-
-
Developer
-
Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
-
\ No newline at end of file
+
+
+ Developer
+ Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
+
+
diff --git a/app/views/users/_data_export.html.erb b/app/views/users/_data_export.html.erb
new file mode 100644
index 0000000000..566d41f0b9
--- /dev/null
+++ b/app/views/users/_data_export.html.erb
@@ -0,0 +1,21 @@
+
+
+
+
+
Begin export...
+
+
+ Export your data
+ This will generate a ZIP archive of all cards you have access to.
+ We‘ll email you a link to download the file when it‘s ready. The link will expire after 24 hours.
+
+
+ <%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
+ Cancel
+
+
+
+
diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb
index 0f7db95472..47300abef5 100644
--- a/app/views/users/_theme.html.erb
+++ b/app/views/users/_theme.html.erb
@@ -1,5 +1,7 @@
-
- Appearance
+
+
diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb
index 13f895ebc7..0744e862cb 100644
--- a/app/views/users/_transfer.html.erb
+++ b/app/views/users/_transfer.html.erb
@@ -1,17 +1,14 @@
-
+
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
-
-
-
-
-
-
+
-
+
+
+
<%= tag.button class: "btn", data: { action: "dialog#open", controller: "tooltip" } do %>
<%= icon_tag "qr-code" %>
@@ -35,4 +32,4 @@
Copy auto-login link
<% end %>
-
\ No newline at end of file
+
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 0000000000..28bdb98610
--- /dev/null
+++ b/app/views/users/data_exports/show.html.erb
@@ -0,0 +1,27 @@
+<% if @export.present? %>
+ <% @page_title = "Download Export" %>
+<% else %>
+ <% @page_title = "Download Expired" %>
+<% end %>
+
+<% content_for :header do %>
+
+<% end %>
+
+
+
<%= @page_title %>
+
+ <% if @export.present? %>
+
Your export is ready. The download should start automatically.
+
+ <%= link_to "Download your data", rails_blob_path(@export.file, disposition: "attachment"),
+ id: "download-link",
+ class: "btn btn--link",
+ data: { turbo: false, controller: "auto-click" } %>
+ <% else %>
+
That download link has expired. You'll need to <%= link_to "request a new export", user_path(@user), class: "txt-link" %>.
+ <% end %>
+
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 771a8e924c..0158eacaca 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -1,8 +1,8 @@
<% @page_title = @user.name %>
<% me_or_you = Current.user == @user ? "me" : @user.first_name %>
-
-
+
+
<% if Current.user == @user %>
<%= link_to edit_user_path(@user), class: "user-edit-link btn", data: { controller: "tooltip" } do %>
@@ -46,14 +46,15 @@
<% end %>
<% end %>
-
+
<% if Current.user == @user %>
-
+
<%= 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 8aba572ca4..981511d451 100644
--- a/config/recurring.yml
+++ b/config/recurring.yml
@@ -25,7 +25,7 @@ production: &production
command: "MagicLink.cleanup"
schedule: every 4 hours
cleanup_exports:
- command: "Account::Export.cleanup"
+ command: "Export.cleanup"
schedule: every hour at minute 20
incineration:
class: "Account::IncinerateDueJob"
diff --git a/config/routes.rb b/config/routes.rb
index 97e34290cb..9f7af72ef6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,8 @@
resources :exports, only: [ :create, :show ]
end
+ resources :imports, only: %i[ new create show ]
+
resources :users do
scope module: :users do
resource :avatar
@@ -20,6 +22,8 @@
resources :email_addresses, param: :token do
resource :confirmation, module: :email_addresses
end
+
+ resources :data_exports, only: [ :create, :show ]
end
end
@@ -165,6 +169,7 @@
resource :landing
+
namespace :my do
resource :identity, only: :show
resources :access_tokens
diff --git a/db/migrate/20251223000001_rename_account_exports_to_exports.rb b/db/migrate/20251223000001_rename_account_exports_to_exports.rb
new file mode 100644
index 0000000000..f9823030c3
--- /dev/null
+++ b/db/migrate/20251223000001_rename_account_exports_to_exports.rb
@@ -0,0 +1,7 @@
+class RenameAccountExportsToExports < ActiveRecord::Migration[8.2]
+ def change
+ rename_table :account_exports, :exports
+ add_column :exports, :type, :string
+ add_index :exports, :type
+ end
+end
diff --git a/db/migrate/20251223000002_create_account_imports.rb b/db/migrate/20251223000002_create_account_imports.rb
new file mode 100644
index 0000000000..5354ac72b0
--- /dev/null
+++ b/db/migrate/20251223000002_create_account_imports.rb
@@ -0,0 +1,14 @@
+class CreateAccountImports < ActiveRecord::Migration[8.2]
+ def change
+ create_table :account_imports, id: :uuid do |t|
+ t.uuid :identity_id, null: false
+ t.uuid :account_id
+ t.string :status, default: "pending", null: false
+ t.datetime :completed_at
+ t.timestamps
+
+ t.index :identity_id
+ t.index :account_id
+ end
+ end
+end
diff --git a/db/queue_schema.rb b/db/queue_schema.rb
index 84bc6b8a6f..c4713f8d13 100644
--- a/db/queue_schema.rb
+++ b/db/queue_schema.rb
@@ -132,7 +132,6 @@
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end
- # If these FKs are removed, make sure to periodically run `RecurringExecution.clear_in_batches`
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
diff --git a/db/schema.rb b/db/schema.rb
index 86b9f29375..7848efe56c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -33,20 +33,20 @@
t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end
- create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
- t.uuid "account_id", null: false
+ create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "value", default: 0, null: false
+ t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
+ end
+
+ create_table "account_imports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.uuid "account_id"
t.datetime "completed_at"
t.datetime "created_at", null: false
+ t.uuid "identity_id", null: false
t.string "status", default: "pending", null: false
t.datetime "updated_at", null: false
- t.uuid "user_id", null: false
- t.index ["account_id"], name: "index_account_exports_on_account_id"
- t.index ["user_id"], name: "index_account_exports_on_user_id"
- end
-
- create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
- t.bigint "value", default: 0, null: false
- t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
+ t.index ["account_id"], name: "index_account_imports_on_account_id"
+ t.index ["identity_id"], name: "index_account_imports_on_identity_id"
end
create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -293,6 +293,19 @@
t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable"
end
+ create_table "exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.uuid "account_id", null: false
+ t.datetime "completed_at"
+ t.datetime "created_at", null: false
+ t.string "status", default: "pending", null: false
+ t.string "type"
+ t.datetime "updated_at", null: false
+ t.uuid "user_id", null: false
+ t.index ["account_id"], name: "index_exports_on_account_id"
+ t.index ["type"], name: "index_exports_on_type"
+ t.index ["user_id"], name: "index_exports_on_user_id"
+ end
+
create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb
index b76f998659..46e655abcb 100644
--- a/db/schema_sqlite.rb
+++ b/db/schema_sqlite.rb
@@ -33,20 +33,20 @@
t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true
end
- create_table "account_exports", id: :uuid, force: :cascade do |t|
- t.uuid "account_id", null: false
+ create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t|
+ t.bigint "value", default: 0, null: false
+ t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
+ end
+
+ create_table "account_imports", id: :uuid, force: :cascade do |t|
+ t.uuid "account_id"
t.datetime "completed_at"
t.datetime "created_at", null: false
+ t.uuid "identity_id", null: false
t.string "status", limit: 255, default: "pending", null: false
t.datetime "updated_at", null: false
- t.uuid "user_id", null: false
- t.index ["account_id"], name: "index_account_exports_on_account_id"
- t.index ["user_id"], name: "index_account_exports_on_user_id"
- end
-
- create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t|
- t.bigint "value", default: 0, null: false
- t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
+ t.index ["account_id"], name: "index_account_imports_on_account_id"
+ t.index ["identity_id"], name: "index_account_imports_on_identity_id"
end
create_table "account_join_codes", id: :uuid, force: :cascade do |t|
@@ -293,6 +293,19 @@
t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable"
end
+ create_table "exports", id: :uuid, force: :cascade do |t|
+ t.uuid "account_id", null: false
+ t.datetime "completed_at"
+ t.datetime "created_at", null: false
+ t.string "status", limit: 255, default: "pending", null: false
+ t.string "type"
+ t.datetime "updated_at", null: false
+ t.uuid "user_id", null: false
+ t.index ["account_id"], name: "index_exports_on_account_id"
+ t.index ["type"], name: "index_exports_on_type"
+ t.index ["user_id"], name: "index_exports_on_user_id"
+ end
+
create_table "filters", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "created_at", null: false
@@ -477,7 +490,7 @@
t.string "operation", limit: 255, null: false
t.uuid "recordable_id"
t.string "recordable_type", limit: 255
- t.string "request_id", limit: 255
+ t.string "request_id"
t.uuid "user_id"
t.index ["account_id"], name: "index_storage_entries_on_account_id"
t.index ["blob_id"], name: "index_storage_entries_on_blob_id"
diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb
index cb999172ec..f93bd3ebae 100644
--- a/saas/app/views/admin/stats/show.html.erb
+++ b/saas/app/views/admin/stats/show.html.erb
@@ -5,7 +5,7 @@
<% end %>
-
+
-
+
10 Most Recent Signups
@@ -118,7 +118,7 @@
-
+
Top 20 Accounts by Card Count
diff --git a/test/controllers/accounts/exports_controller_test.rb b/test/controllers/accounts/exports_controller_test.rb
index 4806b65f06..1ebe7e5eae 100644
--- a/test/controllers/accounts/exports_controller_test.rb
+++ b/test/controllers/accounts/exports_controller_test.rb
@@ -2,12 +2,12 @@
class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
setup do
- sign_in_as :david
+ sign_in_as :jason
end
test "create creates an export record and enqueues job" do
assert_difference -> { Account::Export.count }, 1 do
- assert_enqueued_with(job: ExportAccountDataJob) do
+ assert_enqueued_with(job: ExportDataJob) do
post account_exports_path
end
end
@@ -20,14 +20,14 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
post account_exports_path
export = Account::Export.last
- assert_equal users(:david), export.user
+ assert_equal users(:jason), export.user
assert_equal Current.account, export.account
assert export.pending?
end
test "create rejects request when current export limit is reached" do
Account::ExportsController::CURRENT_EXPORT_LIMIT.times do
- Account::Export.create!(account: Current.account, user: users(:david))
+ Account::Export.create!(account: Current.account, user: users(:jason))
end
assert_no_difference -> { Account::Export.count } do
@@ -39,7 +39,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
test "create allows request when exports are older than one day" do
Account::ExportsController::CURRENT_EXPORT_LIMIT.times do
- Account::Export.create!(account: Current.account, user: users(:david), created_at: 2.days.ago)
+ Account::Export.create!(account: Current.account, user: users(:jason), created_at: 2.days.ago)
end
assert_difference -> { Account::Export.count }, 1 do
@@ -50,7 +50,7 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
end
test "show displays completed export with download link" do
- export = Account::Export.create!(account: Current.account, user: users(:david))
+ export = Account::Export.create!(account: Current.account, user: users(:jason))
export.build
get account_export_path(export)
@@ -75,4 +75,22 @@ class Account::ExportsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_select "h2", "Download Expired"
end
+
+ test "create is forbidden for non-admin members" do
+ logout_and_sign_in_as :david
+
+ post account_exports_path
+
+ assert_response :forbidden
+ end
+
+ test "show is forbidden for non-admin members" do
+ logout_and_sign_in_as :david
+ export = Account::Export.create!(account: Current.account, user: users(:jason))
+ export.build
+
+ get account_export_path(export)
+
+ assert_response :forbidden
+ end
end
diff --git a/test/controllers/users/data_exports_controller_test.rb b/test/controllers/users/data_exports_controller_test.rb
new file mode 100644
index 0000000000..f89f121b11
--- /dev/null
+++ b/test/controllers/users/data_exports_controller_test.rb
@@ -0,0 +1,87 @@
+require "test_helper"
+
+class Users::DataExportsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :david
+ @user = users(:david)
+ end
+
+ test "create creates an export record and enqueues job" do
+ assert_difference -> { User::DataExport.count }, 1 do
+ assert_enqueued_with(job: ExportDataJob) do
+ post user_data_exports_path(@user)
+ end
+ end
+
+ assert_redirected_to @user
+ assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice]
+ end
+
+ test "create associates export with user and account" do
+ post user_data_exports_path(@user)
+
+ export = User::DataExport.last
+ assert_equal @user, export.user
+ assert_equal Current.account, export.account
+ assert export.pending?
+ end
+
+ test "create rejects request when current export limit is reached" do
+ Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do
+ @user.data_exports.create!(account: Current.account)
+ end
+
+ assert_no_difference -> { User::DataExport.count } do
+ post user_data_exports_path(@user)
+ end
+
+ assert_response :too_many_requests
+ end
+
+ test "create allows request when exports are older than one day" do
+ Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do
+ @user.data_exports.create!(account: Current.account, created_at: 2.days.ago)
+ end
+
+ assert_difference -> { User::DataExport.count }, 1 do
+ post user_data_exports_path(@user)
+ end
+
+ assert_redirected_to @user
+ end
+
+ test "show displays completed export with download link" do
+ export = @user.data_exports.create!(account: Current.account)
+ export.build
+
+ get user_data_export_path(@user, export)
+
+ assert_response :success
+ assert_select "a#download-link"
+ end
+
+ test "show displays a warning if the export is missing" do
+ get user_data_export_path(@user, "not-really-an-export")
+
+ assert_response :success
+ assert_select "h2", "Download Expired"
+ end
+
+ test "create is forbidden for other users" do
+ other_user = users(:kevin)
+
+ post user_data_exports_path(other_user)
+
+ assert_response :forbidden
+ end
+
+ test "show is forbidden for other users" do
+ other_user = users(:kevin)
+ export = other_user.data_exports.create!(account: Current.account)
+ export.build
+
+ get user_data_export_path(other_user, export)
+
+ assert_response :forbidden
+ end
+end
diff --git a/test/fixtures/account/exports.yml b/test/fixtures/account/exports.yml
deleted file mode 100644
index 764f8aada6..0000000000
--- a/test/fixtures/account/exports.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-pending_export:
- id: <%= ActiveRecord::FixtureSet.identify("pending_export", :uuid) %>
- account: 37s_uuid
- user: david_uuid
- status: pending
-
-completed_export:
- id: <%= ActiveRecord::FixtureSet.identify("completed_export", :uuid) %>
- account: 37s_uuid
- user: david_uuid
- status: completed
- completed_at: <%= 1.hour.ago.to_fs(:db) %>
diff --git a/test/fixtures/exports.yml b/test/fixtures/exports.yml
new file mode 100644
index 0000000000..8d86be46b1
--- /dev/null
+++ b/test/fixtures/exports.yml
@@ -0,0 +1,29 @@
+pending_account_export:
+ id: <%= ActiveRecord::FixtureSet.identify("pending_account_export", :uuid) %>
+ account: 37s_uuid
+ user: david_uuid
+ type: Account::Export
+ status: pending
+
+completed_account_export:
+ id: <%= ActiveRecord::FixtureSet.identify("completed_account_export", :uuid) %>
+ account: 37s_uuid
+ user: david_uuid
+ type: Account::Export
+ status: completed
+ completed_at: <%= 1.hour.ago.to_fs(:db) %>
+
+pending_user_data_export:
+ id: <%= ActiveRecord::FixtureSet.identify("pending_user_data_export", :uuid) %>
+ account: 37s_uuid
+ user: david_uuid
+ type: User::DataExport
+ status: pending
+
+completed_user_data_export:
+ id: <%= ActiveRecord::FixtureSet.identify("completed_user_data_export", :uuid) %>
+ account: 37s_uuid
+ user: david_uuid
+ type: User::DataExport
+ status: completed
+ completed_at: <%= 1.hour.ago.to_fs(:db) %>
diff --git a/test/mailers/export_mailer_test.rb b/test/mailers/export_mailer_test.rb
index c981aa191f..ac48780a32 100644
--- a/test/mailers/export_mailer_test.rb
+++ b/test/mailers/export_mailer_test.rb
@@ -1,7 +1,7 @@
require "test_helper"
class ExportMailerTest < ActionMailer::TestCase
- test "completed" do
+ test "completed for account export" do
export = Account::Export.create!(account: Current.account, user: users(:david))
email = ExportMailer.completed(export)
@@ -13,4 +13,17 @@ class ExportMailerTest < ActionMailer::TestCase
assert_equal "Your Fizzy data export is ready for download", email.subject
assert_match %r{/exports/#{export.id}}, email.body.encoded
end
+
+ test "completed for user data export" do
+ export = User::DataExport.create!(account: Current.account, user: users(:david))
+ email = ExportMailer.completed(export)
+
+ assert_emails 1 do
+ email.deliver_now
+ end
+
+ assert_equal [ "david@37signals.com" ], email.to
+ assert_equal "Your Fizzy data export is ready for download", email.subject
+ assert_match %r{/users/#{export.user.id}/data_exports/#{export.id}}, email.body.encoded
+ end
end
diff --git a/test/models/account/export_test.rb b/test/models/account/export_test.rb
index 37b0ea47e3..8d3b433fe0 100644
--- a/test/models/account/export_test.rb
+++ b/test/models/account/export_test.rb
@@ -1,41 +1,14 @@
require "test_helper"
class Account::ExportTest < ActiveSupport::TestCase
- test "build_later enqueues ExportAccountDataJob" do
+ test "build_later enqueues ExportDataJob" do
export = Account::Export.create!(account: Current.account, user: users(:david))
- assert_enqueued_with(job: ExportAccountDataJob, args: [ export ]) do
+ assert_enqueued_with(job: ExportDataJob, args: [ export ]) do
export.build_later
end
end
- test "build generates zip with card JSON files" do
- export = Account::Export.create!(account: Current.account, user: users(:david))
-
- export.build
-
- assert export.completed?
- assert export.file.attached?
- assert_equal "application/zip", export.file.content_type
- end
-
- test "build sets status to processing then completed" do
- export = Account::Export.create!(account: Current.account, user: users(:david))
-
- export.build
-
- assert export.completed?
- assert_not_nil export.completed_at
- end
-
- test "build sends email when completed" do
- export = Account::Export.create!(account: Current.account, user: users(:david))
-
- assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
- export.build
- end
- end
-
test "build sets status to failed on error" do
export = Account::Export.create!(account: Current.account, user: users(:david))
export.stubs(:generate_zip).raises(StandardError.new("Test error"))
@@ -52,43 +25,37 @@ class Account::ExportTest < ActiveSupport::TestCase
recent_export = Account::Export.create!(account: Current.account, user: users(:david), status: :completed, completed_at: 23.hours.ago)
pending_export = Account::Export.create!(account: Current.account, user: users(:david), status: :pending)
- Account::Export.cleanup
+ Export.cleanup
- assert_not Account::Export.exists?(old_export.id)
- assert Account::Export.exists?(recent_export.id)
- assert Account::Export.exists?(pending_export.id)
+ assert_not Export.exists?(old_export.id)
+ assert Export.exists?(recent_export.id)
+ assert Export.exists?(pending_export.id)
end
- test "build includes only accessible cards for user" do
- user = users(:david)
- export = Account::Export.create!(account: Current.account, user: user)
+ test "build generates zip with account data" do
+ export = Account::Export.create!(account: Current.account, user: users(:david))
export.build
assert export.completed?
assert export.file.attached?
+ assert_equal "application/zip", export.file.content_type
+ end
- # Verify zip contents
- Tempfile.create([ "test", ".zip" ]) do |temp|
- temp.binmode
- export.file.download { |chunk| temp.write(chunk) }
- temp.rewind
+ test "build includes blob files in zip" do
+ blob = ActiveStorage::Blob.create_and_upload!(
+ io: file_fixture("moon.jpg").open,
+ filename: "moon.jpg",
+ content_type: "image/jpeg"
+ )
+ export = Account::Export.create!(account: Current.account, user: users(:david))
- Zip::File.open(temp.path) do |zip|
- json_files = zip.glob("*.json")
- assert json_files.any?, "Zip should contain at least one JSON file"
+ export.build
- # 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")
+ assert export.completed?
+ export.file.open do |file|
+ Zip::File.open(file.path) do |zip|
+ assert zip.find_entry("storage/#{blob.key}"), "Expected blob file in zip"
end
end
end
diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb
new file mode 100644
index 0000000000..e6bb219e4e
--- /dev/null
+++ b/test/models/account/import_test.rb
@@ -0,0 +1,47 @@
+require "test_helper"
+
+class Account::ImportTest < ActiveSupport::TestCase
+ test "export and import round-trip preserves account data" do
+ source_account = accounts("37s")
+ exporter = users(:david)
+ identity = exporter.identity
+
+ source_account_digest = account_digest(source_account)
+
+ export = Account::Export.create!(account: source_account, user: exporter)
+ export.build
+
+ assert export.completed?
+
+ export_tempfile = Tempfile.new([ "export", ".zip" ])
+ export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) }
+
+ source_account.destroy!
+
+ target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name })
+ import = Account::Import.create!(identity: identity, account: target_account)
+ Current.set(account: target_account) do
+ import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip")
+ end
+
+ import.process
+
+ assert import.completed?
+ assert_equal source_account_digest, account_digest(target_account)
+ ensure
+ export_tempfile&.close
+ export_tempfile&.unlink
+ end
+
+ private
+ def account_digest(account)
+ {
+ name: account.name,
+ board_count: Board.where(account: account).count,
+ column_count: Column.where(account: account).count,
+ card_count: Card.where(account: account).count,
+ comment_count: Comment.where(account: account).count,
+ tag_count: Tag.where(account: account).count
+ }
+ end
+end
diff --git a/test/models/user/data_export_test.rb b/test/models/user/data_export_test.rb
new file mode 100644
index 0000000000..4169c7f86c
--- /dev/null
+++ b/test/models/user/data_export_test.rb
@@ -0,0 +1,70 @@
+require "test_helper"
+
+class User::DataExportTest < ActiveSupport::TestCase
+ test "build generates zip with card JSON files" do
+ export = User::DataExport.create!(account: Current.account, user: users(:david))
+
+ export.build
+
+ assert export.completed?
+ assert export.file.attached?
+ assert_equal "application/zip", export.file.content_type
+ end
+
+ test "build sets status to processing then completed" do
+ export = User::DataExport.create!(account: Current.account, user: users(:david))
+
+ export.build
+
+ assert export.completed?
+ assert_not_nil export.completed_at
+ end
+
+ test "build sends email when completed" do
+ export = User::DataExport.create!(account: Current.account, user: users(:david))
+
+ assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do
+ export.build
+ end
+ end
+
+ test "build includes only accessible cards for user" do
+ user = users(:david)
+ export = User::DataExport.create!(account: Current.account, user: user)
+
+ export.build
+
+ assert export.completed?
+ assert export.file.attached?
+
+ Tempfile.create([ "test", ".zip" ]) do |temp|
+ temp.binmode
+ export.file.download { |chunk| temp.write(chunk) }
+ temp.rewind
+
+ Zip::File.open(temp.path) do |zip|
+ json_files = zip.glob("*.json")
+ assert json_files.any?, "Zip should contain at least one JSON file"
+
+ json_content = JSON.parse(zip.read(json_files.first.name))
+ assert json_content.key?("number")
+ assert json_content.key?("title")
+ assert json_content.key?("board")
+ assert json_content.key?("creator")
+ assert json_content["creator"].key?("id")
+ assert json_content["creator"].key?("name")
+ assert json_content["creator"].key?("email")
+ assert json_content.key?("description")
+ assert json_content.key?("comments")
+ end
+ end
+ end
+
+ test "build_later enqueues ExportDataJob" do
+ export = User::DataExport.create!(account: Current.account, user: users(:david))
+
+ assert_enqueued_with(job: ExportDataJob, args: [ export ]) do
+ export.build_later
+ end
+ end
+end