Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/controllers/account/cancellations_controller.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions app/jobs/account/incinerate_job.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions app/models/account.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions app/models/account/cancellable.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions app/models/account/cancellation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Account::Cancellation < ApplicationRecord
belongs_to :account
end
16 changes: 16 additions & 0 deletions app/models/account/incineration.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions app/views/account/settings/_cancellation.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<% if Current.user.owner? %>
<header class="margin-block-start-double">
<h2 class="divider txt-large txt-negative">Delete account</h2>
<p class="margin-none-block">Permanently delete your Fizzy account and all its data.</p>
</header>

<% if Current.account.cancelled? %>
<div class="panel fill-caution margin-block-half">
<p class="txt-medium margin-none">
<strong>Account scheduled for deletion</strong><br>
Your account will be permanently deleted in <%= distance_of_time_in_words_to_now(Current.account.cancellation.created_at + Account::Cancellable::INCINERATION_GRACE_PERIOD) %>.
</p>
</div>

<%= 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 %>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account...</button>

<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
<p class="margin-none-block-start">This will schedule your account for permanent deletion.</p>
<ul class="margin-block-half">
<li>Your subscription will be canceled immediately</li>
<li>All users will lose access</li>
<li>After 30 days, all data will be permanently deleted</li>
</ul>
<p class="txt-bold">This cannot be undone. Contact support if you need to recover your account within the 30-day grace period.</p>

<div class="flex gap justify-center">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
</div>
</dialog>
</div>
<% end %>
<% end %>
1 change: 1 addition & 0 deletions app/views/account/settings/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<div class="settings__panel settings__panel--entropy panel shadow center">
<%= render "account/settings/entropy", account: @account %>
<%= render "account/settings/export" %>
<%= render "account/settings/cancellation" %>
</div>
</section>

Expand Down
2 changes: 1 addition & 1 deletion config/queue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions config/recurring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
root "events#index"

namespace :account do
resource :cancellation, only: [ :create, :destroy ]
resource :entropy
resource :join_code
resource :settings
Expand Down
11 changes: 11 additions & 0 deletions db/migrate/20251224092315_create_account_cancellations.rb
Original file line number Diff line number Diff line change
@@ -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
23 changes: 22 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/fixtures/account/cancellations.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions test/models/account/cancellation_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require "test_helper"

class Account::CancellationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
Loading