diff --git a/AGENTS.md b/AGENTS.md index 80bb887a2..d1c837079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,13 @@ Key recurring tasks (via `config/recurring.yml`): - Search records denormalized for performance - Models in `app/models/search/` +### Imports and exports + +Allow people to move between OSS and SAAS Fizzy instances: +- Exports/Imports can be wirtten to/read from local or S3 storage depending on the config of the instance (both myst be supported) +- Must be able to handle very large ZIP files (500+GB) +- Models in `app/models/account/data_transfer/`, `app/models/zip_file` + ## Tools ### Chrome MCP (Local Dev) diff --git a/Gemfile b/Gemfile index 8c6d39d0f..23b1223ce 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" -gem "rubyzip", require: "zip" +gem "zip_kit" gem "mittens" gem "useragent", bc: "useragent" diff --git a/Gemfile.lock b/Gemfile.lock index ea92feb39..86a856e37 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -479,6 +479,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.4) + zip_kit (6.3.4) PLATFORMS aarch64-linux @@ -523,7 +524,6 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase - rubyzip selenium-webdriver solid_cable (>= 3.0) solid_cache (~> 1.0) @@ -538,6 +538,7 @@ DEPENDENCIES web-console web-push webmock + zip_kit BUNDLED WITH 2.7.2 diff --git a/Gemfile.saas b/Gemfile.saas index 05e6bbabd..e022ab6b2 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -8,7 +8,7 @@ gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" -gem "audits1984", bc: "audits1984" +gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 6dcb7b9ee..8de89e1fb 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -1,10 +1,12 @@ GIT remote: https://github.com/basecamp/audits1984 - revision: c790eaec0716c8df56b3d0ab5bf5d75e3e1b0ed0 + revision: 422b8ff25820c828b7cf09aff4923fd8cc2c6795 + branch: flavorjones/coworker-api specs: audits1984 (0.1.7) console1984 importmap-rails (>= 1.2.1) + jbuilder rinku rouge turbo-rails @@ -626,6 +628,7 @@ GEM railties yabeda (~> 0.8) zeitwerk (2.7.4) + zip_kit (6.3.4) PLATFORMS aarch64-linux @@ -676,7 +679,6 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase - rubyzip selenium-webdriver sentry-rails sentry-ruby @@ -703,6 +705,7 @@ DEPENDENCIES yabeda-prometheus-mmap yabeda-puma-plugin yabeda-rails + zip_kit BUNDLED WITH 2.7.2 diff --git a/app/assets/images/funnel.svg b/app/assets/images/funnel.svg new file mode 100644 index 000000000..26f3684d7 --- /dev/null +++ b/app/assets/images/funnel.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/person.svg b/app/assets/images/person.svg index 1e29ed27d..b126d685d 100644 --- a/app/assets/images/person.svg +++ b/app/assets/images/person.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 24e5b8727..13975ae62 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,6 +1,12 @@ @layer reset, base, components, modules, utilities, native, platform; :root { + /* Insets - The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ + --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); + --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); + --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); + --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); + /* Spacing */ --inline-space: 1ch; --inline-space-half: calc(var(--inline-space) / 2); diff --git a/app/assets/stylesheets/android.css b/app/assets/stylesheets/android.css index f30158e8e..7bf6c0085 100644 --- a/app/assets/stylesheets/android.css +++ b/app/assets/stylesheets/android.css @@ -3,5 +3,12 @@ .hide-on-android { display: none; } + + /* Filters + /* ------------------------------------------------------------------------ */ + + .filters { + --text-x-small: 1rem; + } } } diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index ac897184f..89638fc12 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -107,11 +107,6 @@ > * { grid-area: 1/1; } - - @media (max-width: 639px) { - --btn-size: 3em; - --icon-size: 75%; - } } /* Make a normal button circular on mobile */ @@ -125,7 +120,7 @@ inline-size: var(--btn-size); kbd, - span:last-of-type { + span:last-of-type:not(.icon) { display: none; } } @@ -224,17 +219,15 @@ .btn--back { --btn-border-size: 0; - font-size: var(--text-medium); - @media (max-width: 639px) { - padding: 0.5em; - strong, kbd { display: none; } } @media (min-width: 640px) { + font-size: var(--text-medium); + .icon--arrow-left { display: none; } diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 1083c2ffc..7cb14e15b 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -232,7 +232,7 @@ overflow-y: auto; .is-expanded & { - padding: var(--column-padding); + padding: var(--column-padding) var(--column-padding) calc(var(--column-padding) + var(--custom-safe-inset-bottom)); /* Use the rest of the column height for scrolling */ @media (max-width: 639px) { diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 0baec71e6..cbaa66c91 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -62,6 +62,8 @@ } .card__board { + align-items: center; + align-self: start; background-color: var(--card-color); border-radius: var(--border-radius) 0 var(--border-radius) 0; color: var(--color-ink-inverted); diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css index 73e446185..8e28654bb 100644 --- a/app/assets/stylesheets/events.css +++ b/app/assets/stylesheets/events.css @@ -220,14 +220,14 @@ .events__column-header { background-color: var(--color-canvas); grid-row-start: 1; - inset-block-start: calc(var(--block-space) * -1); - margin-block-end: var(--events-gap); - padding-block: calc(var(--events-gap) * 3) var(--events-gap); + inset-block-start: var(--custom-safe-inset-top); + margin-block: calc(var(--events-gap) * 2) var(--events-gap); + padding-block: var(--events-gap); position: sticky; z-index: var(--z-events-column-header); @media (max-width: 639px) { - margin-inline: calc(var(--main-padding) * -1); + margin-inline: calc(var(--main-padding) * -0.5); padding-inline: var(--main-padding); } } @@ -239,12 +239,16 @@ } .events__maximize-button { - inset: calc(var(--events-gap) * 3) 0 auto auto; + inset: 50% var(--events-gap) auto auto; outline-offset: -2px; position: absolute; - transform: translateY(-10%); + transform: translateY(-50%); z-index: 1; + @media (max-width: 639px) { + inset-inline-end: 0; + } + @media (any-hover: hover ) { opacity: 0; @@ -287,6 +291,7 @@ .event { --column-gap: 0.7ch; + --event-padding: 0.6em; background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); border-radius: 0.2em; @@ -296,7 +301,7 @@ max-inline-size: 100%; min-inline-size: 0; overflow: clip; - padding: 0.6em; + padding: var(--event-padding); position: relative; z-index: 0; @@ -321,12 +326,15 @@ z-index: 0; } + .card__header { + inline-size: 100%; + margin-block-start: calc(-1 * var(--event-padding)); + } + .card__board { background-color: transparent; color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); font-size: 0.7em; - padding: 0; - translate: 0 -0.3em; } .card__board-name { @@ -373,16 +381,11 @@ } .event__icon { + color: var(--card-color); display: grid; - margin: -0.3em -0.3em auto auto; + margin-inline-start: auto; place-content: center; - - .icon { - background-color: var(--card-color); - block-size: 1em; - inline-size: 1em; - grid-area: 1 / 1; - } + translate: calc(var(--event-padding) / 2); } .event__timestamp { diff --git a/app/assets/stylesheets/expandable.css b/app/assets/stylesheets/expandable.css new file mode 100644 index 000000000..f2fe31c23 --- /dev/null +++ b/app/assets/stylesheets/expandable.css @@ -0,0 +1,13 @@ +@layer components { + .expandable-on-native { + body:not([data-platform~=native]) & { + &::details-content { + display: contents; + } + + summary { + display: none; + } + } + } +} diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/header.css index 5a3c7ee83..c54636d13 100644 --- a/app/assets/stylesheets/header.css +++ b/app/assets/stylesheets/header.css @@ -13,7 +13,7 @@ "menu menu menu" "actions-start title actions-end"; max-inline-size: 100dvw; - padding-block: calc(var(--block-space-half) + env(safe-area-inset-top)) var(--block-space-half); + padding-block: calc(var(--block-space-half) + var(--custom-safe-inset-top)) var(--block-space-half); padding-inline: var(--main-padding); position: relative; z-index: var(--z-nav); diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 252ef2835..95bd3d00f 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/ios.css b/app/assets/stylesheets/ios.css index 3a05e5956..2242e7c77 100644 --- a/app/assets/stylesheets/ios.css +++ b/app/assets/stylesheets/ios.css @@ -1,7 +1,27 @@ @layer platform { + :root:has([data-platform~=ios]) { + &[data-text-size=xsmall] { font-size: 14px; } + &[data-text-size=small] { font-size: 15px; } + &[data-text-size=medium] { font-size: 16px; } + &[data-text-size=large] { font-size: 17px; } + &[data-text-size=xlarge] { font-size: 19px; } + &[data-text-size=xxlarge] { font-size: 21px; } + &[data-text-size=xxxlarge] { font-size: 23px; } + } + [data-platform~=ios] { .hide-on-ios { display: none; } + + /* Events + /* ------------------------------------------------------------------------ */ + + .events__column-header { + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + background-color: oklch(from var(--color-canvas) l c h / 0.5); + border-radius: 10em; + } } } diff --git a/app/assets/stylesheets/layout.css b/app/assets/stylesheets/layout.css index 6ea479eb6..f789f7ceb 100644 --- a/app/assets/stylesheets/layout.css +++ b/app/assets/stylesheets/layout.css @@ -6,6 +6,13 @@ &.public { grid-template-rows: auto 1fr auto; } + + &.compact-on-touch { + @media (any-hover: none) { + grid-template-rows: auto 1fr auto; + min-height: unset; + } + } } /* Required for the card column page on mobile, but not needed otherwise */ @@ -23,8 +30,8 @@ margin-inline: auto; max-inline-size: 100dvw; padding-inline: - calc(var(--main-padding) + env(safe-area-inset-left)) - calc(var(--main-padding) + env(safe-area-inset-right)); + calc(var(--main-padding) + var(--custom-safe-inset-left)) + calc(var(--main-padding) + var(--custom-safe-inset-right)); text-align: center; } diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css index 65144a22b..6ec846c3d 100644 --- a/app/assets/stylesheets/lightbox.css +++ b/app/assets/stylesheets/lightbox.css @@ -12,7 +12,11 @@ max-height: unset; max-width: unset; overflow: hidden; - padding: var(--lightbox-padding); + padding: + calc(var(--lightbox-padding) + var(--custom-safe-inset-top)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-right)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-bottom)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-left)); text-align: center; &::backdrop { @@ -32,6 +36,9 @@ /* Open state */ &[open], &[open]::backdrop { + align-items: center; + display: flex; + justify-content: center; opacity: 1; @starting-style { @@ -47,12 +54,15 @@ .lightbox__actions { display: flex; gap: 1ch; - inset: var(--lightbox-padding) var(--lightbox-padding) auto auto; + inset: + calc(var(--lightbox-padding) + var(--custom-safe-inset-top)) + calc(var(--lightbox-padding) + var(--custom-safe-inset-right)) + auto + auto; position: absolute; } .lightbox__figure { - align-self: stretch; animation-fill-mode: forwards; animation: slide-down var(--dialog-duration); display: flex; @@ -72,6 +82,10 @@ &:empty { display: none; } + + &[tabindex="-1"]:focus-visible { + outline: unset; + } } .lightbox__image { diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 8226a5261..c67d05d14 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -1,11 +1,5 @@ @layer native { [data-platform~=native] { - /* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ - --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); - --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); - --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); - --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); - --footer-height: 0; -webkit-tap-highlight-color: transparent; @@ -14,6 +8,15 @@ display: none; } + /* Layout + /* ------------------------------------------------------------------------ */ + + &:not(.contained-scrolling) { + #main { + padding-block-end: var(--custom-safe-inset-bottom); + } + } + /* Header /* ------------------------------------------------------------------------ */ @@ -21,7 +24,12 @@ :not(:has(.header__title, .header__actions)), :not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child) ) { - display: none; + block-size: var(--custom-safe-inset-top); + padding: unset; + + * { + display: none; + } } .header__actions { @@ -48,6 +56,11 @@ } } + .card-perma__bg { + border-start-start-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); + border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); + } + .card-perma__closure-message { margin-block: var(--block-space); translate: unset; diff --git a/app/assets/stylesheets/notifications.css b/app/assets/stylesheets/notifications.css index 91ff5750d..fabea58a8 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/popup.css b/app/assets/stylesheets/popup.css index aa0b8c1da..6e2720637 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -27,13 +27,13 @@ &:where(.popup--align-left), &.orient-left { inset-inline: auto 0; - transform: translateX(0); + transform: translateX(var(--orient-offset, 0px)); } &:where(.popup--align-right), &.orient-right { inset-inline: 0 auto; - transform: translateX(0); + transform: translateX(var(--orient-offset, 0px)); } form { diff --git a/app/assets/stylesheets/profile-layout.css b/app/assets/stylesheets/profile-layout.css deleted file mode 100644 index bb18c2655..000000000 --- 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 cd67a4cc0..0e39c33ee 100644 --- a/app/assets/stylesheets/settings.css +++ b/app/assets/stylesheets/settings.css @@ -13,29 +13,48 @@ } } + /* 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); } } .settings__panel--users { - max-height: 80dvh; + @media (min-width: 640px) { + max-height: 80dvh; + } @media (min-width: 960px) { max-height: calc(100dvh - 12rem); } } + .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/assets/stylesheets/tooltips.css b/app/assets/stylesheets/tooltips.css index f6ff54ffc..ab142afdc 100644 --- a/app/assets/stylesheets/tooltips.css +++ b/app/assets/stylesheets/tooltips.css @@ -20,12 +20,12 @@ &.orient-right { inset-inline: 0 auto; - translate: 0 -100%; + translate: var(--orient-offset, 0px) -100%; } &.orient-left { inset-inline: auto 0; - translate: 0 -100%; + translate: var(--orient-offset, 0px) -100%; } } diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb index ab40286af..dbe0a11c1 100644 --- a/app/controllers/account/exports_controller.rb +++ b/app/controllers/account/exports_controller.rb @@ -1,4 +1,5 @@ class Account::ExportsController < ApplicationController + before_action :ensure_admin_or_owner before_action :ensure_export_limit_not_exceeded, only: :create before_action :set_export, only: :show @@ -13,8 +14,12 @@ class Account::ExportsController < ApplicationController end private + def ensure_admin_or_owner + head :forbidden unless Current.user.admin? || Current.user.owner? + end + def ensure_export_limit_not_exceeded - head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT + head :too_many_requests if Current.account.exports.current.count >= CURRENT_EXPORT_LIMIT end def set_export diff --git a/app/controllers/account/imports_controller.rb b/app/controllers/account/imports_controller.rb new file mode 100644 index 000000000..1a62f83a0 --- /dev/null +++ b/app/controllers/account/imports_controller.rb @@ -0,0 +1,44 @@ +class Account::ImportsController < ApplicationController + layout "public" + + disallow_account_scope only: %i[ new create ] + allow_unauthorized_access only: :show + before_action :set_import, only: %i[ show ] + before_action :ensure_accessed_by_owner, 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 ensure_accessed_by_owner + head :forbidden unless @import.identity == Current.identity + 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 account_import_path(import, script_name: account.slug) + end +end diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb index f8da07da3..d46c6a48d 100644 --- a/app/controllers/cards/pins_controller.rb +++ b/app/controllers/cards/pins_controller.rb @@ -9,14 +9,22 @@ class Cards::PinsController < ApplicationController @pin = @card.pin_by Current.user broadcast_add_pin_to_tray - render_pin_button_replacement + + respond_to do |format| + format.turbo_stream { render_pin_button_replacement } + format.json { head :no_content } + end end def destroy @pin = @card.unpin_by Current.user broadcast_remove_pin_from_tray - render_pin_button_replacement + + respond_to do |format| + format.turbo_stream { render_pin_button_replacement } + format.json { head :no_content } + end end private diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb index 4468c75d5..18b58910f 100644 --- a/app/controllers/my/pins_controller.rb +++ b/app/controllers/my/pins_controller.rb @@ -1,6 +1,15 @@ class My::PinsController < ApplicationController def index - @pins = Current.user.pins.includes(:card).ordered.limit(20) + @pins = user_pins fresh_when etag: [ @pins, @pins.collect(&:card) ] end + + private + def user_pins + Current.user.pins.includes(:card).ordered.limit(pins_limit) + end + + def pins_limit + request.format.json? ? 100 : 20 + end end diff --git a/app/controllers/prompts/boards/users_controller.rb b/app/controllers/prompts/boards/users_controller.rb index e5f8775a6..7950531ea 100644 --- a/app/controllers/prompts/boards/users_controller.rb +++ b/app/controllers/prompts/boards/users_controller.rb @@ -2,7 +2,7 @@ class Prompts::Boards::UsersController < ApplicationController include BoardScoped def index - @users = @board.users.alphabetically + @users = @board.users.active.alphabetically if stale? etag: @users render layout: false diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 32257d941..d2b25594c 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -46,7 +46,7 @@ class Sessions::MagicLinksController < ApplicationController respond_to do |format| format.html { redirect_to after_sign_in_url(magic_link) } - format.json { render json: { session_token: session_token } } + format.json { render json: { session_token: session_token, requires_signup_completion: requires_signup_completion?(magic_link) } } end end @@ -68,7 +68,7 @@ class Sessions::MagicLinksController < ApplicationController end def after_sign_in_url(magic_link) - if magic_link.for_sign_up? + if requires_signup_completion?(magic_link) new_signup_completion_path else after_authentication_url @@ -82,4 +82,8 @@ class Sessions::MagicLinksController < ApplicationController format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } end end + + def requires_signup_completion?(magic_link) + magic_link.for_sign_up? + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d0de9e84c..cabcfa143 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -20,7 +20,11 @@ class SessionsController < ApplicationController def destroy terminate_session - redirect_to_logout_url + + respond_to do |format| + format.html { redirect_to_logout_url } + format.json { head :no_content } + end end private diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb index 4fcaaabd3..0d45f2f8a 100644 --- a/app/controllers/signups/completions_controller.rb +++ b/app/controllers/signups/completions_controller.rb @@ -11,10 +11,9 @@ class Signups::CompletionsController < ApplicationController @signup = Signup.new(signup_params) if @signup.complete - flash[:welcome_letter] = true - redirect_to landing_url(script_name: @signup.account.slug) + welcome_to_account else - render :new, status: :unprocessable_entity + invalid_signup end end @@ -22,4 +21,22 @@ class Signups::CompletionsController < ApplicationController def signup_params params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity) end + + def welcome_to_account + respond_to do |format| + format.html do + flash[:welcome_letter] = true + redirect_to landing_url(script_name: @signup.account.slug) + end + + format.json { render json: { account_id: @signup.account.id }, status: :created } + end + end + + def invalid_signup + respond_to do |format| + format.html { render :new, status: :unprocessable_entity } + format.json { render json: { errors: @signup.errors.full_messages }, status: :unprocessable_entity } + end + 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 000000000..60df1e326 --- /dev/null +++ b/app/controllers/users/data_exports_controller.rb @@ -0,0 +1,33 @@ +class Users::DataExportsController < ApplicationController + before_action :set_user + before_action :ensure_current_user + before_action :ensure_export_limit_not_exceeded, only: :create + before_action :set_export, only: :show + + CURRENT_EXPORT_LIMIT = 10 + + def show + end + + def create + @user.data_exports.create!(account: Current.account).build_later + redirect_to @user, notice: "Export started. You'll receive an email when it's ready." + end + + private + def set_user + @user = Current.account.users.find(params[:user_id]) + end + + def ensure_current_user + head :forbidden unless @user == Current.user + end + + def ensure_export_limit_not_exceeded + head :too_many_requests if @user.data_exports.current.count >= CURRENT_EXPORT_LIMIT + end + + def set_export + @export = @user.data_exports.completed.find_by(id: params[:id]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 946a0900e..9e0a72f81 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,7 +11,7 @@ module ApplicationHelper end def back_link_to(label, url, action, **options) - link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do + link_to url, class: "btn btn--back btn--circle-mobile", data: { controller: "hotkey", action: action }, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 9b9ee89b3..5f12b9259 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -4,7 +4,7 @@ module BoardsHelper end def link_to_edit_board(board) - link_to edit_board_path(board), class: "btn", + link_to edit_board_path(board), class: "btn btn--circle-mobile", data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index a2e14af29..246859539 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -24,7 +24,7 @@ module WebhooksHelper def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), - class: [ "btn", { "btn--reversed": board.webhooks.any? } ], + class: [ "btn btn--circle-mobile", { "btn--reversed": board.webhooks.any? } ], data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index e432a054f..939fe8441 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -15,8 +15,8 @@ export default class extends Controller { async connect() { this.mediaQuery = window.matchMedia(this.desktopBreakpointValue) - this.handleDesktop = this.#handleDesktop.bind(this) - this.mediaQuery.addEventListener("change", this.handleDesktop) + this.handlePlatform = this.#handlePlatform.bind(this) + this.mediaQuery.addEventListener("change", this.handlePlatform) await this.#restoreColumnsDisablingTransitions() this.#setupIntersectionObserver() @@ -27,7 +27,7 @@ export default class extends Controller { this._intersectionObserver.disconnect() this._intersectionObserver = null } - this.mediaQuery.removeEventListener("change", this.handleDesktop) + this.mediaQuery.removeEventListener("change", this.handlePlatform) } toggle({ target }) { @@ -56,7 +56,7 @@ export default class extends Controller { async #restoreColumnsDisablingTransitions() { this.#disableTransitions() this.#restoreColumns() - this.#handleDesktop() + this.#handlePlatform() await nextFrame() this.#enableTransitions() @@ -163,7 +163,7 @@ export default class extends Controller { return this.mediaQuery?.matches } - #handleDesktop() { + #handlePlatform() { this.#isDesktop ? this.#handleDesktopMode() : this.#handleMobileMode() } @@ -181,6 +181,7 @@ export default class extends Controller { this.#collapseAllExcept(expandedColumn) } else { this.#collapseAllExcept(this.maybeColumnTarget) + this.#expand(this.maybeColumnTarget, false) } } diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index 037c5ec7b..b52eb2ea6 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -26,7 +26,7 @@ export default class extends Controller { this.dialogTarget.showModal() } else { this.dialogTarget.show() - orient(this.dialogTarget) + orient({ target: this.dialogTarget, anchor: this.element }) } this.loadLazyFrames() @@ -46,7 +46,7 @@ export default class extends Controller { this.dialogTarget.close() this.dialogTarget.setAttribute("aria-hidden", "true") this.dialogTarget.blur() - orient(this.dialogTarget, false) + orient({ target: this.dialogTarget, reset: true }) this.dispatch("close") } diff --git a/app/javascript/controllers/expandable_on_native_controller.js b/app/javascript/controllers/expandable_on_native_controller.js new file mode 100644 index 000000000..a58ddfa9a --- /dev/null +++ b/app/javascript/controllers/expandable_on_native_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" +import { isNative } from "helpers/platform_helpers" + +export default class extends Controller { + static get shouldLoad() { + return isNative() + } + + static values = { autoExpandSelector: String } + + connect() { + if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) { + this.element.open = true + } + } +} diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index fc86b18f7..5197cb063 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -30,7 +30,7 @@ export default class extends Controller { reset() { this.zoomedImageTarget.src = "" - this.captionTarget.innerText = "" + this.captionTarget.innerHTML = " " this.dispatch('closed') } diff --git a/app/javascript/controllers/tooltip_controller.js b/app/javascript/controllers/tooltip_controller.js index 0b242f157..57958cd15 100644 --- a/app/javascript/controllers/tooltip_controller.js +++ b/app/javascript/controllers/tooltip_controller.js @@ -15,11 +15,11 @@ export default class extends Controller { } mouseEnter(event) { - orient(this.#tooltipElement) + orient({ target: this.#tooltipElement, anchor: this.element }) } mouseOut(event) { - orient(this.#tooltipElement, false) + orient({ target: this.#tooltipElement, reset: true }) } get #tooltipElement() { diff --git a/app/javascript/controllers/touch_placeholder_controller.js b/app/javascript/controllers/touch_placeholder_controller.js new file mode 100644 index 000000000..f1798873e --- /dev/null +++ b/app/javascript/controllers/touch_placeholder_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" +import { isTouchDevice } from "helpers/platform_helpers" + +export default class extends Controller { + static get shouldLoad() { + return isTouchDevice() + } + + static values = { placeholder: String } + + connect() { + if (this.hasPlaceholderValue) { + this.element.placeholder = this.placeholderValue + } + } +} diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js index 14cc5bfee..d7b0bd478 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/javascript/helpers/orientation_helpers.js b/app/javascript/helpers/orientation_helpers.js index b79d66360..f823de09a 100644 --- a/app/javascript/helpers/orientation_helpers.js +++ b/app/javascript/helpers/orientation_helpers.js @@ -1,24 +1,40 @@ const EDGE_THRESHOLD = 16 -export function orient(el, orient = true) { - el.classList.remove("orient-left", "orient-right") +export function orient({ target, anchor = null, reset = false }) { + target.classList.remove("orient-left", "orient-right") + target.style.removeProperty("--orient-offset") - if (!orient) return + if (reset) return - const rightSpace = spaceOnRight(el) - const leftSpace = spaceOnLeft(el) + const targetGeometry = geometry(target) + const anchorGeometry = geometry(anchor) + const shouldOrientLeft = targetGeometry.spaceOnRight < EDGE_THRESHOLD && targetGeometry.spaceOnRight < targetGeometry.spaceOnLeft + const shouldOrientRight = targetGeometry.spaceOnLeft < EDGE_THRESHOLD && targetGeometry.spaceOnLeft < targetGeometry.spaceOnRight - if (rightSpace < EDGE_THRESHOLD && rightSpace < leftSpace) { - el.classList.add("orient-left") - } else if (leftSpace < EDGE_THRESHOLD && leftSpace < rightSpace) { - el.classList.add("orient-right") + if (shouldOrientLeft) { + orientLeft({ el: target, targetGeometry, anchorGeometry }) + } else if (shouldOrientRight) { + orientRight({ el: target, targetGeometry, anchorGeometry }) } } -function spaceOnLeft(el) { - return el.getBoundingClientRect().left +function orientLeft({ el, targetGeometry, anchorGeometry }) { + const offset = Math.min(0, anchorGeometry.spaceOnLeft + anchorGeometry.width - targetGeometry.width) * -1 + el.classList.add("orient-left") + el.style.setProperty("--orient-offset", `${offset}px`) } -function spaceOnRight(el) { - return window.innerWidth - el.getBoundingClientRect().right +function orientRight({ el, targetGeometry, anchorGeometry }) { + const offset = Math.max(0, anchorGeometry.spaceOnLeft + targetGeometry.width - window.innerWidth) * -1 + el.classList.add("orient-right") + el.style.setProperty("--orient-offset", `${offset}px`) +} + +function geometry(el) { + const rect = el.getBoundingClientRect() + return { + spaceOnLeft: rect.left, + spaceOnRight: window.innerWidth - rect.right, + width: rect.width + } } diff --git a/app/javascript/helpers/platform_helpers.js b/app/javascript/helpers/platform_helpers.js index 636e4b968..b5056d5a6 100644 --- a/app/javascript/helpers/platform_helpers.js +++ b/app/javascript/helpers/platform_helpers.js @@ -13,3 +13,7 @@ export function isAndroid() { export function isMobile() { return isIos() || isAndroid() } + +export function isNative() { + return /Hotwire Native/.test(navigator.userAgent) +} diff --git a/app/jobs/account/data_import_job.rb b/app/jobs/account/data_import_job.rb new file mode 100644 index 000000000..67862d4c7 --- /dev/null +++ b/app/jobs/account/data_import_job.rb @@ -0,0 +1,20 @@ +class Account::DataImportJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :backend + discard_on Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError + + def perform(import) + step :check do |step| + import.check \ + start: step.cursor, + callback: ->(record_set:, file:) { step.set!([ record_set.model.name, file ]) } + end + + step :process do |step| + import.process \ + start: step.cursor, + callback: ->(record_set:, files:) { step.set!([ record_set.model.name, files.last ]) } + end + end +end diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/data_export_job.rb similarity index 72% rename from app/jobs/export_account_data_job.rb rename to app/jobs/data_export_job.rb index c0286c736..fc4bff904 100644 --- a/app/jobs/export_account_data_job.rb +++ b/app/jobs/data_export_job.rb @@ -1,4 +1,4 @@ -class ExportAccountDataJob < ApplicationJob +class DataExportJob < ApplicationJob queue_as :backend discard_on ActiveJob::DeserializationError diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb index 5385aeed6..045d954d0 100644 --- a/app/mailers/export_mailer.rb +++ b/app/mailers/export_mailer.rb @@ -1,8 +1,19 @@ class ExportMailer < ApplicationMailer + helper_method :export_download_url + def completed(export) @export = export @user = export.user mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download" end + + private + def export_download_url(export) + if export.is_a?(User::DataExport) + user_data_export_url(export.user, export) + else + account_export_url(export) + end + end end diff --git a/app/mailers/import_mailer.rb b/app/mailers/import_mailer.rb new file mode 100644 index 000000000..c626ddb2e --- /dev/null +++ b/app/mailers/import_mailer.rb @@ -0,0 +1,10 @@ +class ImportMailer < ApplicationMailer + def completed(identity, account) + @account = account + mail to: identity.email_address, subject: "Your Fizzy account import is done" + end + + def failed(identity) + 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 34b63b688..92590d9f9 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -8,7 +8,12 @@ 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 + + scope :importing, -> { left_joins(:imports).where(account_imports: { status: %i[pending processing failed] }) } + scope :active, -> { where.missing(:cancellation).and(where.not(id: importing)) } before_create :assign_external_account_id after_create :create_join_code @@ -36,6 +41,14 @@ class Account < ApplicationRecord users.find_by!(role: :system) end + def active? + !cancelled? && !importing? + end + + def importing? + imports.where(status: %i[pending processing failed]).exists? + end + private def assign_external_account_id self.external_account_id ||= ExternalIdSequence.next diff --git a/app/models/account/cancellable.rb b/app/models/account/cancellable.rb index f10624245..71e5ac733 100644 --- a/app/models/account/cancellable.rb +++ b/app/models/account/cancellable.rb @@ -4,8 +4,6 @@ module Account::Cancellable included do has_one :cancellation, dependent: :destroy - scope :active, -> { where.missing(:cancellation) } - define_callbacks :cancel define_callbacks :reactivate end @@ -32,10 +30,6 @@ module Account::Cancellable end end - def active? - !cancelled? - end - def cancelled? cancellation.present? end 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 000000000..b7869eb73 --- /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 check_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 000000000..ec2f4e17f --- /dev/null +++ b/app/models/account/data_transfer/action_text_rich_text_record_set.rb @@ -0,0 +1,95 @@ +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 check_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 + + check_associations_dont_exist(data) + 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 = begin + sgid&.find + rescue ActiveRecord::RecordNotFound + nil + end + + if record&.account_id == account.id + node["gid"] = record.to_global_id.to_s + node.remove_attribute("sgid") + end + 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"]) + + if gid + record = gid.find + node["sgid"] = record.attachable_sgid + node.remove_attribute("gid") + end + end + + fragment.to_html + end +end diff --git a/app/models/account/data_transfer/active_storage_blob_record_set.rb b/app/models/account/data_transfer/active_storage_blob_record_set.rb new file mode 100644 index 000000000..10ee326ad --- /dev/null +++ b/app/models/account/data_transfer/active_storage_blob_record_set.rb @@ -0,0 +1,22 @@ +class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet + def initialize(account) + super( + account: account, + model: ActiveStorage::Blob, + attributes: ActiveStorage::Blob.column_names - %w[service_name] + ) + end + + private + def import_batch(files) + batch_data = files.map do |file| + data = load(file) + data.slice(*attributes).merge( + "account_id" => account.id, + "service_name" => ActiveStorage::Blob.service.name + ) + end + + model.insert_all!(batch_data) + 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 000000000..4bbfcba10 --- /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) + if blob.service.exist?(blob.key) + zip.add_file("storage/#{blob.key}", compress: false) do |out| + blob.download { |chunk| out.write(chunk) } + end + end + 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 check_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 000000000..e620a8121 --- /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 000000000..b5976e3d5 --- /dev/null +++ b/app/models/account/data_transfer/manifest.rb @@ -0,0 +1,50 @@ +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 + ), + Account::DataTransfer::ActiveStorageBlobRecordSet.new(account), + *build_record_sets(::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 000000000..b7955932a --- /dev/null +++ b/app/models/account/data_transfer/record_set.rb @@ -0,0 +1,144 @@ +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 check(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| + check_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).tap do |record_data| + record_data["updated_at"] = Time.current if record_data.key?("updated_at") + end + end + + model.insert_all!(batch_data) + end + + def check_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 + + check_associations_dont_exist(data) + end + + def check_associations_dont_exist(data) + model.reflect_on_all_associations(:belongs_to).each do |association| + foreign_key = association.foreign_key.to_s + + if associated_id = data[foreign_key] + check_association_doesnt_exist(data, association, associated_id) + end + end + end + + def check_association_doesnt_exist(data, association, associated_id) + if association.polymorphic? + type_column = association.foreign_type.to_s + associated_class = data[type_column].constantize + else + associated_class = association.klass + end + + if associated_class.exists?(id: associated_id) + raise IntegrityError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}" + 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 000000000..e7ac41c56 --- /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 check_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/export.rb b/app/models/account/export.rb index 1fd2689d2..653480d0f 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -1,77 +1,12 @@ -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 + def filename + "fizzy-account-#{account_id}-export-#{id}.zip" 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: zip) end end end diff --git a/app/models/account/import.rb b/app/models/account/import.rb new file mode 100644 index 000000000..814e6b00c --- /dev/null +++ b/app/models/account/import.rb @@ -0,0 +1,81 @@ +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 + + scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) } + + def self.cleanup + expired.each(&:cleanup) + end + + def process_later + Account::DataImportJob.perform_later(self) + end + + def check(start: nil, callback: nil) + processing! + + ZipFile.read_from(file.blob) do |zip| + Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id| + record_set.check(from: zip, start: last_id, callback: callback) + end + end + rescue => e + mark_as_failed + raise e + end + + def process(start: nil, callback: nil) + processing! + + ZipFile.read_from(file.blob) 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 + + add_importer_to_all_access_boards + reconcile_account_storage + + mark_completed + rescue => e + mark_as_failed + raise e + end + + def cleanup + destroy + account.destroy if failed? + end + + private + def mark_completed + update!(status: :completed, completed_at: Time.current) + ImportMailer.completed(identity, account).deliver_later + end + + def mark_as_failed + failed! + ImportMailer.failed(identity).deliver_later + end + + def add_importer_to_all_access_boards + importer = account.users.find_by!(identity: identity) + + account.boards.all_access.find_each do |board| + board.accesses.grant_to(importer) + end + end + + def reconcile_account_storage + account.boards.each(&:reconcile_storage) + account.reconcile_storage + account.materialize_storage + end +end diff --git a/app/models/card.rb b/app/models/card.rb index d422afa85..c3c0b9173 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -58,6 +58,7 @@ class Card < ApplicationRecord transaction do card.update!(board: new_board) card.events.update_all(board_id: new_board.id) + Event.where(eventable: card.comments).update_all(board_id: new_board.id) end end diff --git a/app/models/card/exportable.rb b/app/models/card/exportable.rb index ffff82581..b147c47d9 100644 --- a/app/models/card/exportable.rb +++ b/app/models/card/exportable.rb @@ -67,7 +67,7 @@ module Card::Exportable end def collect_attachments - attachments.to_a + comments.flat_map { |c| c.attachments.to_a } + (attachments.to_a + comments.flat_map { |c| c.attachments.to_a }).uniq(&:blob_id) end def export_status diff --git a/app/models/entropy.rb b/app/models/entropy.rb index 902e7732f..9a93ba4e8 100644 --- a/app/models/entropy.rb +++ b/app/models/entropy.rb @@ -2,5 +2,5 @@ class Entropy < ApplicationRecord belongs_to :account, default: -> { container.account } belongs_to :container, polymorphic: true - after_commit -> { container.cards.touch_all } + after_commit -> { container.cards.touch_all if container } end diff --git a/app/models/export.rb b/app/models/export.rb new file mode 100644 index 000000000..101c9a379 --- /dev/null +++ b/app/models/export.rb @@ -0,0 +1,62 @@ +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 + DataExportJob.perform_later(self) + end + + def build + processing! + + with_context do + ZipFile.create_for(file, filename: filename) do |zip| + populate_zip(zip) + end + mark_completed + ExportMailer.completed(self).deliver_later + end + rescue => e + update!(status: :failed) + raise e + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + def accessible_to?(accessor) + accessor == user + end + + private + def filename + "fizzy-export-#{id}.zip" + end + + def with_context + Current.set(account: account) do + old_url_options = ActiveStorage::Current.url_options + ActiveStorage::Current.url_options = Rails.application.routes.default_url_options + + yield + ensure + ActiveStorage::Current.url_options = old_url_options + end + end + + def populate_zip(zip) + raise NotImplementedError, "Subclasses must implement populate_zip" + end +end diff --git a/app/models/signup.rb b/app/models/signup.rb index 041f461ac..2f096165d 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 @@ class Signup } ) @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 e16d70670..842f7f6c8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,7 +14,7 @@ class User < ApplicationRecord has_many :closures, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card - has_many :exports, class_name: "Account::Export", dependent: :destroy + has_many :data_exports, class_name: "User::DataExport", dependent: :destroy def deactivate transaction do diff --git a/app/models/user/data_export.rb b/app/models/user/data_export.rb new file mode 100644 index 000000000..2a160fa08 --- /dev/null +++ b/app/models/user/data_export.rb @@ -0,0 +1,33 @@ +class User::DataExport < Export + private + def filename + "fizzy-user-data-export-#{id}.zip" + end + + 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) + zip.add_file("#{card.number}.json", card.export_json) + + card.export_attachments.each do |attachment| + zip.add_file(attachment[:path], compress: false) 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/models/zip_file.rb b/app/models/zip_file.rb new file mode 100644 index 000000000..dc5b7ee8d --- /dev/null +++ b/app/models/zip_file.rb @@ -0,0 +1,99 @@ +class ZipFile + class InvalidFileError < StandardError; end + + class << self + def create_for(attachment, filename:) + raise ArgumentError, "No block given" unless block_given? + + reflection = attachment.record.class.reflect_on_attachment(attachment.name) + service_name = reflection.options[:service_name] || ActiveStorage::Blob.service.name + service = ActiveStorage::Blob.services.fetch(service_name) + + if s3_service?(service) + create_for_s3(attachment, filename: filename, service: service) { |zip| yield zip } + else + create_for_disk(attachment, filename: filename) { |zip| yield zip } + end + end + + def read_from(blob) + raise ArgumentError, "No block given" unless block_given? + + if s3_service?(blob.service) + read_from_s3(blob) { |zip| yield zip } + else + read_from_disk(blob) { |zip| yield zip } + end + end + + private + def s3_service?(service) + # The S3 service doesn't get loaded in development unless it's used + defined?(ActiveStorage::Service::S3Service) && service.is_a?(ActiveStorage::Service::S3Service) + end + + def create_for_s3(attachment, filename:, service:) + blob = ActiveStorage::Blob.create_before_direct_upload!( + filename: filename, + content_type: "application/zip", + byte_size: 0, + checksum: "pending" + ) + + writer = Writer.new + + # Use S3's upload_stream directly for write-based streaming. + # ActiveStorage's upload method expects a read-based IO, but ZipKit + # needs a write-based stream. The TransferManager's upload_stream + # yields a writable IO that we can stream directly to. + service.send(:upload_stream, + key: blob.key, + content_type: "application/zip", + part_size: 100.megabytes + ) do |write_stream| + write_stream.binmode + writer.stream_to(write_stream) + yield writer + writer.close + end + + blob.update!(byte_size: writer.byte_size, checksum: writer.checksum) + attachment.attach(blob) + rescue Aws::S3::MultipartUploadError => e + if e.errors.any? + raise e.errors.first + else + raise e + end + end + + def create_for_disk(attachment, filename:) + tempfile = Tempfile.new([ "export", ".zip" ]) + tempfile.binmode + + writer = Writer.new(tempfile) + yield writer + writer.close + + tempfile.rewind + attachment.attach(io: tempfile, filename: filename, content_type: "application/zip") + ensure + tempfile&.close + tempfile&.unlink + end + + def read_from_s3(blob) + url = blob.url(expires_in: 6.hour) + remote_io = RemoteIO.new(url) + reader = Reader.new(remote_io) + yield reader + end + + def read_from_disk(blob) + blob.open do |file| + reader = Reader.new(file) + yield reader + end + end + end +end diff --git a/app/models/zip_file/reader.rb b/app/models/zip_file/reader.rb new file mode 100644 index 000000000..2dc563f96 --- /dev/null +++ b/app/models/zip_file/reader.rb @@ -0,0 +1,28 @@ +class ZipFile::Reader + def initialize(io) + @io = io + @reader = ZipKit::FileReader.read_zip_structure(io: io) + rescue ZipKit::FileReader::InvalidStructure => e + raise ZipFile::InvalidFileError, e.message + end + + def read(file_path) + entry = @reader.find { |e| e.filename == file_path } + raise ArgumentError, "File not found in zip: #{file_path}" unless entry + raise ArgumentError, "Cannot read directory entry: #{file_path}" if entry.filename.end_with?("/") + + if block_given? + yield ZipFile::Reader::IO.new(entry, @io) + else + entry.extractor_from(@io).extract + end + end + + def glob(pattern) + @reader.map(&:filename).select { |name| File.fnmatch(pattern, name) }.sort + end + + def exists?(file_path) + @reader.any? { |e| e.filename == file_path } + end +end diff --git a/app/models/zip_file/reader/io.rb b/app/models/zip_file/reader/io.rb new file mode 100644 index 000000000..cf28212aa --- /dev/null +++ b/app/models/zip_file/reader/io.rb @@ -0,0 +1,34 @@ +class ZipFile::Reader::IO + def initialize(entry, io) + @entry = entry + @io = io + @extractor = @entry.extractor_from(@io) + end + + def read(length = nil, buffer = nil) + return nil if @extractor.eof? + + data = @extractor.extract(length) + return nil if data.nil? + + if buffer + buffer.replace(data) + buffer + else + data + end + end + + def eof? + @extractor.eof? + end + + def rewind + @extractor = @entry.extractor_from(@io) + 0 + end + + def size + @entry.uncompressed_size + end +end diff --git a/app/models/zip_file/remote_io.rb b/app/models/zip_file/remote_io.rb new file mode 100644 index 000000000..1cb00831a --- /dev/null +++ b/app/models/zip_file/remote_io.rb @@ -0,0 +1,44 @@ +class ZipFile::RemoteIO < ZipKit::RemoteIO + protected + def request_range(range) + with_http do |http| + request = Net::HTTP::Get.new(@uri) + request.range = range + response = http.request(request) + + case response.code + when "206", "200" + response.body + else + raise "Remote at #{@uri} replied with code #{response.code}" + end + end + end + + def request_object_size + with_http do |http| + request = Net::HTTP::Get.new(@uri) + request.range = 0..0 + response = http.request(request) + + case response.code + when "206" + content_range_header_value = response["Content-Range"] + content_range_header_value.split("/").last.to_i + when "200" + response["Content-Length"].to_i + else + raise "Remote at #{@uri} replied with code #{response.code}" + end + end + end + + private + def with_http + http = Net::HTTP.new(@uri.hostname, @uri.port) + http.use_ssl = @uri.scheme == "https" + # FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.start { yield http } + end +end diff --git a/app/models/zip_file/writer.rb b/app/models/zip_file/writer.rb new file mode 100644 index 000000000..8291c4948 --- /dev/null +++ b/app/models/zip_file/writer.rb @@ -0,0 +1,54 @@ +class ZipFile::Writer + attr_reader :byte_size + + def initialize(io = nil) + @entries = [] + @byte_size = 0 + @output_io = io + @streamer = nil + @digest = Digest::MD5.new + end + + def stream_to(io) + @output_io = io + end + + def write(data) + @output_io.write(data) + @byte_size += data.bytesize + @digest.update(data) + data.bytesize + end + + def add_file(path, content = nil, compress: true) + @entries << path + write_method = compress ? :write_deflated_file : :write_stored_file + + if block_given? + streamer.public_send(write_method, path) { |sink| yield sink } + else + streamer.public_send(write_method, path) { |sink| sink.write(content) } + end + end + + def glob(pattern) + @entries.select { |e| File.fnmatch(pattern, e) }.sort + end + + def exists?(path) + @entries.include?(path) + end + + def close + streamer.close + end + + def checksum + Base64.strict_encode64(@digest.digest) + end + + private + def streamer + @streamer ||= ZipKit::Streamer.new(@output_io) + end +end diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb index f01c13dc5..77fd79b0d 100644 --- a/app/views/account/exports/show.html.erb +++ b/app/views/account/exports/show.html.erb @@ -10,20 +10,18 @@ <% end %> -
Your export is ready. The download should start automatically.
+That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.
+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_account_import_path, class: "btn btn--plain center txt-medium" %> + <% end %> +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.
-Download an archive of your Fizzy data.
-