From 16dea041582a1e721c60257989b888bbe925946c Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 15 Jun 2026 15:48:54 -0400 Subject: [PATCH] Add archive and deletion options for people with only a user account AWBW carries test data on production that touches many records, and there was no safe way to remove people or to wind down an account. This adds a graduated set of actions so admins can pick the least destructive one that fits: - Archive (default): soft-deletes the person and their user via the discard gem, blocking login and hiding them from default listings while staying fully reversible. Chosen over paranoia because it does not override destroy or add a default scope, so existing hard-delete cascades keep working unchanged. - Delete: hard-deletes a person plus their user (and the user's ahoy visits/events) only when nothing of substance is attached. - Permanently delete (?admin=true): purges the person, user, and all associated data for cleaning test records off production; guarded by a browser confirmation. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 4 +- Gemfile | 3 + Gemfile.lock | 4 + app/controllers/people_controller.rb | 34 ++++++- app/controllers/users_controller.rb | 1 + app/models/person.rb | 1 + app/models/user.rb | 10 ++- app/policies/person_policy.rb | 29 ++++-- app/services/person_archival_service.rb | 22 +++++ app/services/person_deletion_service.rb | 48 ++++++++++ app/views/people/index.html.erb | 7 +- app/views/people/show.html.erb | 32 +++++++ config/locales/devise.en.yml | 1 + config/routes.rb | 2 + ...00_add_discarded_at_to_people_and_users.rb | 17 ++++ db/schema.rb | 6 +- spec/models/user_spec.rb | 6 ++ spec/policies/person_policy_spec.rb | 52 +++++++++++ spec/requests/people_archive_spec.rb | 90 +++++++++++++++++++ spec/services/person_archival_service_spec.rb | 44 +++++++++ spec/services/person_deletion_service_spec.rb | 63 +++++++++++++ 21 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 app/services/person_archival_service.rb create mode 100644 app/services/person_deletion_service.rb create mode 100644 db/migrate/20260615120000_add_discarded_at_to_people_and_users.rb create mode 100644 spec/requests/people_archive_spec.rb create mode 100644 spec/services/person_archival_service_spec.rb create mode 100644 spec/services/person_deletion_service_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 9174a1f46..93bc76f40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~75 files | -| `app/services/` | Service objects for complex logic | ~23 files | +| `app/services/` | Service objects for complex logic | ~25 files | | `app/jobs/` | SolidQueue background jobs | 3 files | | `app/models/concerns/` | Shared model modules | 14 concerns | @@ -183,6 +183,8 @@ end - `WorkshopVariationFromIdeaService` — Variation creation from ideas - `TaggingSearchService` — Search and filter tagging data - `PersonFromUserService` — Create Person from User account +- `PersonArchivalService` — Soft-delete (discard) / restore a person together with their user +- `PersonDeletionService` — Permanently delete a person + user (`destroy_with_user!`) or a person + user + all associated data (`full_destroy!`, for purging test data) - `BulkInviteService` — Bulk send welcome instructions and reset created_at for users - `FormBuilderService` — Builds configurable forms from composable sections with per-field visibility - `ModelDeduper` — Deduplication logic diff --git a/Gemfile b/Gemfile index 73474e43e..272daed78 100644 --- a/Gemfile +++ b/Gemfile @@ -64,6 +64,9 @@ gem "action_policy", "~> 0.7.6" gem "active_storage_validations", "~> 3.0" +# Soft-delete / archive support (kept/discarded scopes, no default scope) +gem "discard", "~> 2.0" + gem "solid_cache" # Payments diff --git a/Gemfile.lock b/Gemfile.lock index 4fe868068..e72ba2f70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -184,6 +184,8 @@ GEM responders warden (~> 1.2.3) diff-lcs (1.6.2) + discard (2.0.0) + activerecord (>= 7.0, < 9.0) docile (1.4.1) domain_name (0.6.20240107) dotenv (3.2.0) @@ -785,6 +787,7 @@ DEPENDENCIES country_select debug (~> 1.11) devise (~> 5.0.4) + discard (~> 2.0) dotenv-rails draper factory_bot_rails @@ -888,6 +891,7 @@ CHECKSUMS device_detector (1.1.3) sha256=c5fe3fe42cab2e8aa01f193b2074b8bb1510373ce47127206f28c7dea75a9c79 devise (5.0.4) sha256=d605f2b85854e74e56ee789e2d398702bc2d06e6bcd894717a670a3199c74cc1 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + discard (2.0.0) sha256=0fc520786b4d7b9b1ea8b2b3dadbb13c3e41d2ce7d297d04869baf1c9e30d2c0 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 42f32653d..f1963df30 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -1,6 +1,6 @@ class PeopleController < ApplicationController include AhoyTracking, TagAssignable - before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio ] + before_action :set_person, only: %i[ show edit update destroy archive unarchive workshop_logs checkout bio ] def index authorize! @@ -13,6 +13,7 @@ def index sectorable_items: :sector, affiliations: :organization ).references(:user)) + base_scope = ActiveModel::Type::Boolean.new.cast(params[:archived]) ? base_scope.discarded : base_scope.kept filtered = base_scope.search_by_params(params.to_unsafe_h) .order(:first_name, :last_name) @count_display = filtered.count @@ -217,11 +218,36 @@ def update end def destroy - authorize! @person - @person.destroy + if ActiveModel::Type::Boolean.new.cast(params[:admin]) + authorize! @person, to: :full_destroy? + PersonDeletionService.new(@person).full_destroy! + notice = "Person, their user, and all associated data were permanently deleted." + else + authorize! @person, to: :destroy? + PersonDeletionService.new(@person).destroy_with_user! + notice = "Person was successfully deleted." + end + + respond_to do |format| + format.html { redirect_to people_path, status: :see_other, notice: notice } + end + end + + def archive + authorize! @person, to: :archive? + PersonArchivalService.new(@person).archive! + + respond_to do |format| + format.html { redirect_to @person, status: :see_other, notice: "Person was archived." } + end + end + + def unarchive + authorize! @person, to: :archive? + PersonArchivalService.new(@person).restore! respond_to do |format| - format.html { redirect_to people_path, status: :see_other, notice: "Person was successfully destroyed." } + format.html { redirect_to @person, status: :see_other, notice: "Person was restored." } end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index cb828a465..5e87b30d0 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,6 +12,7 @@ def index per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(User.includes(:created_by, :updated_by, person: { avatar_attachment: :blob })) + base_scope = ActiveModel::Type::Boolean.new.cast(params[:archived]) ? base_scope.discarded : base_scope.kept filtered = base_scope.search_by_params(params).order(:first_name, :last_name) @users_count = filtered.count @users = filtered.paginate(page: params[:page], per_page: per_page) diff --git a/app/models/person.rb b/app/models/person.rb index 9cd7c2710..607067614 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -1,4 +1,5 @@ class Person < ApplicationRecord + include Discard::Model include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable pay_customer default_payment_processor: :stripe diff --git a/app/models/user.rb b/app/models/user.rb index 7d7c4bab6..af5151456 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord + include Discard::Model + # Include default devise modules. Others available are: # :confirmable, :timeoutable and :omniauthable devise :database_authenticatable, :recoverable, :confirmable, @@ -82,7 +84,7 @@ class User < ApplicationRecord attributes user: "organizations.name" end - scope :has_access, -> { where(locked_at: nil, inactive: [ false, nil ]).where.not(confirmed_at: nil) } + scope :has_access, -> { kept.where(locked_at: nil, inactive: [ false, nil ]).where.not(confirmed_at: nil) } def self.search_by_params(params) results = is_a?(ActiveRecord::Relation) ? self : all @@ -118,7 +120,11 @@ def remote_search_label end def active_for_authentication? - super && !inactive? + super && !inactive? && !discarded? + end + + def inactive_message + discarded? ? :archived : super end def bookmark_for(record) diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 1f12418ba..4f4367e19 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -25,8 +25,21 @@ def update? admin? end + # Plain delete: allowed only when the person has nothing of substance beyond a + # user account (and trivial profile data, which cascades away on destroy). def destroy? - admin? && record.persisted? && !has_associated_data? + admin? && record.persisted? && !has_significant_associated_data? + end + + # Full purge of person + user + all associated data. Used to clean test data + # off production; the UI requires a browser confirmation. + def full_destroy? + admin? && record.persisted? + end + + # Soft-delete (archive) the person and their user. + def archive? + admin? && record.persisted? end def search? @@ -38,7 +51,7 @@ def search? relation_scope do |relation| next relation if admin? - relation.searchable.with_active_affiliations.where_user_not_locked + relation.kept.searchable.with_active_affiliations.where_user_not_locked end private @@ -48,9 +61,13 @@ def owner? record.user == user end - def has_associated_data? - record.user.present? || - record.affiliations.exists? || - record.stories_as_spotlighted_facilitator.exists? + def has_significant_associated_data? + record.affiliations.exists? || + record.stories_as_spotlighted_facilitator.exists? || + record.event_registrations.exists? || + record.event_staffs.exists? || + record.scholarships.exists? || + record.grants.exists? || + record.form_submissions.exists? end end diff --git a/app/services/person_archival_service.rb b/app/services/person_archival_service.rb new file mode 100644 index 000000000..d8ccc2ec0 --- /dev/null +++ b/app/services/person_archival_service.rb @@ -0,0 +1,22 @@ +class PersonArchivalService + def initialize(person) + @person = person + end + + # Soft-delete (archive) the person and their user together. A discarded user + # is blocked from authenticating and hidden from default listings. + def archive! + ActiveRecord::Base.transaction do + @person.user&.discard! + @person.discard! + end + end + + # Reverse an archive, bringing the person and their user back. + def restore! + ActiveRecord::Base.transaction do + @person.user&.undiscard! + @person.undiscard! + end + end +end diff --git a/app/services/person_deletion_service.rb b/app/services/person_deletion_service.rb new file mode 100644 index 000000000..2068736ad --- /dev/null +++ b/app/services/person_deletion_service.rb @@ -0,0 +1,48 @@ +class PersonDeletionService + def initialize(person) + @person = person + end + + # Permanently delete a person whose only associated record is a user account. + # Gated by PersonPolicy#destroy?, which guarantees no significant associated + # data exists, so the person's dependent: :destroy cascades are trivial. The + # user and its ahoy tracking records (visits/events) are removed alongside it. + def destroy_with_user! + ActiveRecord::Base.transaction do + destroy_user_with_tracking(@person.user) + @person.destroy! + end + end + + # Permanently delete the person, their user, and ALL associated data. Intended + # for purging test records from production, so it clears the associations that + # would otherwise block a destroy (spotlighted stories use restrict_with_error) + # before cascading the rest via dependent: :destroy. + def full_destroy! + ActiveRecord::Base.transaction do + # Intentional bulk nullify: detach the person from any stories that + # spotlight them so the restrict_with_error guard does not block deletion. + @person.stories_as_spotlighted_facilitator.update_all(spotlighted_facilitator_id: nil) + destroy_user_with_tracking(@person.user) + @person.destroy! + end + end + + private + + def destroy_user_with_tracking(user) + return if user.nil? + + purge_ahoy_records(user) + user.destroy! + end + + # Ahoy visits/events reference the user by id without an association, so remove + # them explicitly. Events are deleted both by user_id and by their parent visit + # so none are left orphaned. + def purge_ahoy_records(user) + visit_ids = Ahoy::Visit.where(user_id: user.id).pluck(:id) + Ahoy::Event.where(user_id: user.id).or(Ahoy::Event.where(visit_id: visit_ids)).delete_all + Ahoy::Visit.where(user_id: user.id).delete_all + end +end diff --git a/app/views/people/index.html.erb b/app/views/people/index.html.erb index 30120897f..56b50d78a 100644 --- a/app/views/people/index.html.erb +++ b/app/views/people/index.html.erb @@ -5,7 +5,12 @@

People

-
+
+ <% if ActiveModel::Type::Boolean.new.cast(params[:archived]) %> + <%= link_to "View active", people_path, class: "admin-only btn btn-secondary-outline" %> + <% else %> + <%= link_to "View archived", people_path(archived: true), class: "admin-only btn btn-secondary-outline" %> + <% end %> <% if allowed_to?(:new?, Person) %> <%= link_to "New Person", new_person_path, diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index 4754fa67b..ad79ef8f9 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -33,6 +33,38 @@ <%= render "bookmarks/editable_bookmark_button", resource: @person.object %>
+ <% if allowed_to?(:archive?, @person) %> +
+ <% if @person.discarded? %> + + Archived + + <%= link_to unarchive_person_path(@person), class: "btn btn-secondary px-3 py-1 text-sm", + data: { turbo_method: :patch } do %> + Restore + <% end %> + <% else %> + <%= link_to archive_person_path(@person), class: "btn btn-secondary px-3 py-1 text-sm", + data: { turbo_method: :patch } do %> + Archive + <% end %> + <% end %> + <% if allowed_to?(:destroy?, @person) %> + <%= link_to person_path(@person), class: "btn btn-danger-outline px-3 py-1 text-sm", + data: { turbo_method: :delete, + turbo_confirm: "Delete this person and their user account? This cannot be undone." } do %> + Delete + <% end %> + <% end %> + <% if allowed_to?(:full_destroy?, @person) %> + <%= link_to person_path(@person, admin: true), class: "btn btn-danger px-3 py-1 text-sm", + data: { turbo_method: :delete, + turbo_confirm: "PERMANENTLY delete this person, their user account, and ALL associated data (registrations, affiliations, scholarships, grants, form submissions, etc.)?\n\nThis is intended for purging test data and CANNOT be undone." } do %> + Permanently delete + <% end %> + <% end %> +
+ <% end %>
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index 61c351fd3..3b279e11d 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -8,6 +8,7 @@ en: send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." failure: already_authenticated: "You are already signed in." + archived: "Invalid email or password. Please email us or fill out our Contact Us form for assistance." inactive: "Invalid email or password. Please email us or fill out our Contact Us form for assistance." invalid: "Invalid email or password. Please email us or fill out our Contact Us form for assistance." locked: "Please email us or fill out our Contact Us form for assistance." diff --git a/config/routes.rb b/config/routes.rb index 193fdc7ed..f27f12054 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -148,6 +148,8 @@ get :workshop_logs get :checkout get :bio + patch :archive + patch :unarchive end resources :comments, only: [ :index, :create, :update ] end diff --git a/db/migrate/20260615120000_add_discarded_at_to_people_and_users.rb b/db/migrate/20260615120000_add_discarded_at_to_people_and_users.rb new file mode 100644 index 000000000..efbe4ad6e --- /dev/null +++ b/db/migrate/20260615120000_add_discarded_at_to_people_and_users.rb @@ -0,0 +1,17 @@ +class AddDiscardedAtToPeopleAndUsers < ActiveRecord::Migration[8.1] + def up + add_column :people, :discarded_at, :datetime unless column_exists?(:people, :discarded_at) + add_index :people, :discarded_at unless index_exists?(:people, :discarded_at) + + add_column :users, :discarded_at, :datetime unless column_exists?(:users, :discarded_at) + add_index :users, :discarded_at unless index_exists?(:users, :discarded_at) + end + + def down + remove_index :people, :discarded_at if index_exists?(:people, :discarded_at) + remove_column :people, :discarded_at if column_exists?(:people, :discarded_at) + + remove_index :users, :discarded_at if index_exists?(:users, :discarded_at) + remove_column :users, :discarded_at if column_exists?(:users, :discarded_at) + end +end diff --git a/db/schema.rb b/db/schema.rb index e2244464c..02db617bd 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.1].define(version: 2026_06_13_190000) do +ActiveRecord::Schema[8.1].define(version: 2026_06_15_120000) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -903,6 +903,7 @@ t.datetime "created_at", null: false t.integer "created_by_id" t.date "date_of_birth" + t.datetime "discarded_at" t.string "display_name_preference" t.string "email" t.string "email_2" @@ -941,6 +942,7 @@ t.integer "updated_by_id" t.string "youtube_url" t.index ["created_by_id"], name: "index_people_on_created_by_id" + t.index ["discarded_at"], name: "index_people_on_discarded_at" t.index ["updated_by_id"], name: "index_people_on_updated_by_id" end @@ -1200,6 +1202,7 @@ t.integer "created_by_id" t.datetime "current_sign_in_at", precision: nil t.string "current_sign_in_ip" + t.datetime "discarded_at" t.string "email", default: "", null: false t.string "email_type" t.string "encrypted_password", default: "", null: false @@ -1240,6 +1243,7 @@ t.index ["agency_id"], name: "index_users_on_agency_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["created_by_id"], name: "index_users_on_created_by_id" + t.index ["discarded_at"], name: "index_users_on_discarded_at" t.index ["email"], name: "index_users_on_email", unique: true t.index ["favorite_event_id"], name: "index_users_on_favorite_event_id" t.index ["person_id"], name: "index_users_on_person_id" diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e85f9e426..09976c5cf 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -213,6 +213,12 @@ user = create(:user, locked: true) expect(user.active_for_authentication?).to be false end + + it "returns false when discarded (archived)" do + user = create(:user) + user.discard! + expect(user.active_for_authentication?).to be false + end end describe "#first_name_or_email" do diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index c299741f0..cab115900 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -111,6 +111,58 @@ def policy_for(record: nil, user:) end end + describe "#destroy?" do + context "with admin and a person that only has a user" do + subject { policy_for(record: create(:person), user: admin_user) } + + it { is_expected.to be_allowed_to(:destroy?) } + end + + context "with admin and a person that has significant associated data" do + let(:person) { create(:person) } + before { create(:affiliation, person: person) } + subject { policy_for(record: person, user: admin_user) } + + it { is_expected.not_to be_allowed_to(:destroy?) } + end + + context "with a regular user" do + subject { policy_for(record: create(:person), user: regular_user) } + + it { is_expected.not_to be_allowed_to(:destroy?) } + end + end + + describe "#full_destroy?" do + context "with admin, even when the person has associated data" do + let(:person) { create(:person) } + before { create(:affiliation, person: person) } + subject { policy_for(record: person, user: admin_user) } + + it { is_expected.to be_allowed_to(:full_destroy?) } + end + + context "with a regular user" do + subject { policy_for(record: create(:person), user: regular_user) } + + it { is_expected.not_to be_allowed_to(:full_destroy?) } + end + end + + describe "#archive?" do + context "with an admin" do + subject { policy_for(record: create(:person), user: admin_user) } + + it { is_expected.to be_allowed_to(:archive?) } + end + + context "with a regular user" do + subject { policy_for(record: create(:person), user: regular_user) } + + it { is_expected.not_to be_allowed_to(:archive?) } + end + end + describe "relation_scope" do context "with admin user" do let(:policy) { policy_for(record: Person, user: admin_user) } diff --git a/spec/requests/people_archive_spec.rb b/spec/requests/people_archive_spec.rb new file mode 100644 index 000000000..a861e6d9c --- /dev/null +++ b/spec/requests/people_archive_spec.rb @@ -0,0 +1,90 @@ +require "rails_helper" + +RSpec.describe "People archiving and deletion", type: :request do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + describe "PATCH /people/:id/archive" do + it "archives the person and their user" do + person = create(:person) + + patch archive_person_path(person) + + expect(response).to redirect_to(person_path(person)) + expect(person.reload).to be_discarded + expect(person.user.reload).to be_discarded + end + end + + describe "PATCH /people/:id/unarchive" do + it "restores an archived person and their user" do + person = create(:person) + PersonArchivalService.new(person).archive! + + patch unarchive_person_path(person) + + expect(response).to redirect_to(person_path(person)) + expect(person.reload).to be_kept + expect(person.user.reload).to be_kept + end + end + + describe "DELETE /people/:id" do + context "when the person only has a user account" do + it "permanently deletes the person and their user" do + person = create(:person) + user = person.user + + delete person_path(person) + + expect(response).to redirect_to(people_path) + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + end + end + + context "when the person has significant associated data" do + it "is not authorized and leaves the record intact" do + person = create(:person) + create(:affiliation, person: person) + + delete person_path(person) + + expect(response).to redirect_to(root_path) + expect(Person.exists?(person.id)).to be true + end + end + + context "with admin=true (full deletion)" do + it "permanently deletes the person, user, and all associated data" do + person = create(:person) + user = person.user + affiliation = create(:affiliation, person: person) + + delete person_path(person, admin: true) + + expect(response).to redirect_to(people_path) + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + expect(Affiliation.exists?(affiliation.id)).to be false + end + end + end + + describe "GET /people (archived filter)" do + it "hides archived people by default and shows them when archived=true" do + active = create(:person, first_name: "Active", last_name: "Person") + archived = create(:person, first_name: "Archived", last_name: "Person") + PersonArchivalService.new(archived).archive! + + get people_path, headers: { "Turbo-Frame" => "people_results" } + expect(response.body).to include("Active Person") + expect(response.body).not_to include("Archived Person") + + get people_path(archived: true), headers: { "Turbo-Frame" => "people_results" } + expect(response.body).to include("Archived Person") + expect(response.body).not_to include("Active Person") + end + end +end diff --git a/spec/services/person_archival_service_spec.rb b/spec/services/person_archival_service_spec.rb new file mode 100644 index 000000000..a644ac6e7 --- /dev/null +++ b/spec/services/person_archival_service_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +RSpec.describe PersonArchivalService do + describe "#archive!" do + it "discards the person and their user together" do + person = create(:person) + user = person.user + + described_class.new(person).archive! + + expect(person.reload).to be_discarded + expect(user.reload).to be_discarded + end + + it "discards a person without a user" do + person = create(:person, user: nil) + + described_class.new(person).archive! + + expect(person.reload).to be_discarded + end + + it "blocks a discarded user from authenticating" do + person = create(:person) + + described_class.new(person).archive! + + expect(person.user.reload.active_for_authentication?).to be false + end + end + + describe "#restore!" do + it "undiscards the person and their user together" do + person = create(:person) + user = person.user + described_class.new(person).archive! + + described_class.new(person).restore! + + expect(person.reload).to be_kept + expect(user.reload).to be_kept + end + end +end diff --git a/spec/services/person_deletion_service_spec.rb b/spec/services/person_deletion_service_spec.rb new file mode 100644 index 000000000..b25c0897b --- /dev/null +++ b/spec/services/person_deletion_service_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe PersonDeletionService do + describe "#destroy_with_user!" do + it "permanently deletes the person and their user" do + person = create(:person) + user = person.user + + described_class.new(person).destroy_with_user! + + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + end + + it "deletes a person without a user" do + person = create(:person, user: nil) + + described_class.new(person).destroy_with_user! + + expect(Person.exists?(person.id)).to be false + end + + it "purges the user's ahoy visits and events" do + person = create(:person) + user = person.user + visit = create(:ahoy_visit, user: user) + event = create(:ahoy_event, visit: visit, user: user) + orphan_event = create(:ahoy_event, visit: visit, user: nil) + + described_class.new(person).destroy_with_user! + + expect(Ahoy::Visit.exists?(visit.id)).to be false + expect(Ahoy::Event.exists?(event.id)).to be false + expect(Ahoy::Event.exists?(orphan_event.id)).to be false + end + end + + describe "#full_destroy!" do + it "deletes the person, their user, and associated data" do + person = create(:person) + user = person.user + affiliation = create(:affiliation, person: person) + registration = create(:event_registration, registrant: person) + + described_class.new(person).full_destroy! + + expect(Person.exists?(person.id)).to be false + expect(User.exists?(user.id)).to be false + expect(Affiliation.exists?(affiliation.id)).to be false + expect(EventRegistration.exists?(registration.id)).to be false + end + + it "detaches spotlighted stories instead of being blocked by them" do + person = create(:person) + story = create(:story, spotlighted_facilitator: person) + + described_class.new(person).full_destroy! + + expect(Person.exists?(person.id)).to be false + expect(story.reload.spotlighted_facilitator_id).to be_nil + end + end +end