diff --git a/app/controllers/account/cancellations_controller.rb b/app/controllers/account/cancellations_controller.rb new file mode 100644 index 0000000000..4bc21de62f --- /dev/null +++ b/app/controllers/account/cancellations_controller.rb @@ -0,0 +1,18 @@ +class Account::CancellationsController < ApplicationController + before_action :ensure_owner + + def create + Current.account.cancel + redirect_to account_settings_path, notice: "Your account is scheduled for deletion." + end + + def destroy + Current.account.reactivate + redirect_to account_settings_path, notice: "Account deletion has been canceled." + end + + private + def ensure_owner + head :forbidden unless Current.user.owner? + end +end diff --git a/app/jobs/account/incinerate_job.rb b/app/jobs/account/incinerate_job.rb new file mode 100644 index 0000000000..2c0a6b9cba --- /dev/null +++ b/app/jobs/account/incinerate_job.rb @@ -0,0 +1,14 @@ +class Account::IncinerateJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :incineration + + def perform + step :incineration do |step| + Account.up_for_incineration.find_each(start: step.cursor) do |account| + account.incinerate + step.advance! from: account.id + end + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index f9b2589c07..216adc9b95 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,7 +1,7 @@ class Account < ApplicationRecord - include Account::Storage, Entropic, MultiTenantable, Seedeable + include Account::Storage, Cancellable, Entropic, MultiTenantable, Seedeable - has_one :join_code + has_one :join_code, dependent: :destroy has_many :users, dependent: :destroy has_many :boards, dependent: :destroy has_many :cards, dependent: :destroy @@ -36,6 +36,10 @@ def system_user users.find_by!(role: :system) end + def incinerate + Incineration.new(self).perform + 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 new file mode 100644 index 0000000000..7f93b7b5ad --- /dev/null +++ b/app/models/account/cancellable.rb @@ -0,0 +1,48 @@ +module Account::Cancellable + extend ActiveSupport::Concern + + INCINERATION_GRACE_PERIOD = 30.days + + included do + has_one :cancellation, dependent: :destroy + + scope :up_for_incineration, -> { joins(:cancellation).where(cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) } + end + + def cancel(**attributes) + with_lock do + if cancellable? && active? + create_cancellation!(**attributes) + pause_subscription + end + end + end + + def reactivate + with_lock do + if cancelled? + resume_subscription + cancellation.destroy + end + end + end + + def cancelled? + cancellation.present? + end + + private + def active? + !cancelled? + end + + def cancellable? + Account.accepting_signups? + end + + def pause_subscription + end + + def resume_subscription + end +end diff --git a/app/models/account/cancellation.rb b/app/models/account/cancellation.rb new file mode 100644 index 0000000000..277fdc3dda --- /dev/null +++ b/app/models/account/cancellation.rb @@ -0,0 +1,3 @@ +class Account::Cancellation < ApplicationRecord + belongs_to :account +end diff --git a/app/models/account/incineration.rb b/app/models/account/incineration.rb new file mode 100644 index 0000000000..e1740927d5 --- /dev/null +++ b/app/models/account/incineration.rb @@ -0,0 +1,16 @@ +class Account::Incineration + attr_reader :account + + def initialize(account) + @account = account + end + + def perform + cancel_subscription + account.destroy + end + + private + def cancel_subscription + end +end diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb new file mode 100644 index 0000000000..f39955bb4e --- /dev/null +++ b/app/views/account/settings/_cancellation.html.erb @@ -0,0 +1,37 @@ +<% if Current.user.owner? %> +
+

Delete account

+

Permanently delete your Fizzy account and all its data.

+
+ + <% if Current.account.cancelled? %> +
+

+ Account scheduled for deletion
+ Your account will be permanently deleted in <%= distance_of_time_in_words_to_now(Current.account.cancellation.created_at + Account::Cancellable::INCINERATION_GRACE_PERIOD) %>. +

+
+ + <%= button_to "Cancel deletion", account_cancellation_path, method: :delete, class: "btn", data: { turbo_confirm: "Are you sure you want to cancel the account deletion?" } %> + <% else %> +
+ + + +

Delete your account?

+

This will schedule your account for permanent deletion.

+ +

This cannot be undone. Contact support if you need to recover your account within the 30-day grace period.

+ +
+ + <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> +
+
+
+ <% end %> +<% end %> diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index fc5e799b38..74e2744148 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -18,6 +18,7 @@
<%= render "account/settings/entropy", account: @account %> <%= render "account/settings/export" %> + <%= render "account/settings/cancellation" %>
diff --git a/config/queue.yml b/config/queue.yml index 3f5615fa8c..2441feb1f5 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,7 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ] + - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "incineration" ] threads: 3 processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1 diff --git a/config/recurring.yml b/config/recurring.yml index 6485278b3b..b995c77acd 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -30,6 +30,9 @@ production: &production cleanup_exports: command: "Account::Export.cleanup" schedule: every hour at minute 20 + incineration: + job: Account::IncinerateJob + schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> # Metrics diff --git a/config/routes.rb b/config/routes.rb index d84fa06e38..0d879851ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,7 @@ root "events#index" namespace :account do + resource :cancellation, only: [ :create, :destroy ] resource :entropy resource :join_code resource :settings diff --git a/db/migrate/20251224092315_create_account_cancellations.rb b/db/migrate/20251224092315_create_account_cancellations.rb new file mode 100644 index 0000000000..111839cf1f --- /dev/null +++ b/db/migrate/20251224092315_create_account_cancellations.rb @@ -0,0 +1,11 @@ +class CreateAccountCancellations < ActiveRecord::Migration[8.2] + def change + create_table :account_cancellations, id: :uuid do |t| + t.uuid :account_id, null: false, index: { unique: true } + t.text :reason + t.text :requested_by + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 645cb7a96f..38b70ab2db 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,11 +25,21 @@ t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_cancellations", 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 + t.text "reason" + t.text "requested_by" + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true + end + create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", default: "pending", null: false + t.string "type" t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_account_exports_on_account_id" @@ -41,6 +51,17 @@ 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.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| t.uuid "account_id", null: false t.string "code", null: false diff --git a/test/fixtures/account/cancellations.yml b/test/fixtures/account/cancellations.yml new file mode 100644 index 0000000000..a36829817a --- /dev/null +++ b/test/fixtures/account/cancellations.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + account_id: + reason: MyText + requested_by: MyText + +two: + account_id: + reason: MyText + requested_by: MyText diff --git a/test/models/account/cancellation_test.rb b/test/models/account/cancellation_test.rb new file mode 100644 index 0000000000..ac88d46df0 --- /dev/null +++ b/test/models/account/cancellation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::CancellationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end