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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -785,6 +787,7 @@ DEPENDENCIES
country_select
debug (~> 1.11)
devise (~> 5.0.4)
discard (~> 2.0)
dotenv-rails
draper
factory_bot_rails
Expand Down Expand Up @@ -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
Expand Down
34 changes: 30 additions & 4 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
@@ -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!
Expand All @@ -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
Expand Down Expand Up @@ -217,11 +218,36 @@ def update
end

def destroy
authorize! @person
@person.destroy
if ActiveModel::Type::Boolean.new.cast(params[:admin])

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: ?admin=true selects the full-purge path (person + user + all associated data) and authorizes against full_destroy?; the UI gates it behind a browser confirmation.

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

Expand Down
1 change: 1 addition & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class Person < ApplicationRecord
include Discard::Model
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable

pay_customer default_payment_processor: :stripe
Expand Down
10 changes: 8 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -118,7 +120,11 @@ def remote_search_label
end

def active_for_authentication?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Discarded (archived) users are blocked from authenticating here in addition to the existing inactive check, so archiving an account immediately revokes login.

super && !inactive?
super && !inactive? && !discarded?
end

def inactive_message
discarded? ? :archived : super
end

def bookmark_for(record)
Expand Down
29 changes: 23 additions & 6 deletions app/policies/person_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: A bare user account is intentionally not "significant" here — that's exactly the "person only has a user" case we want plain Delete to allow. Affiliations/registrations/scholarships/etc. still block it.

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
22 changes: 22 additions & 0 deletions app/services/person_archival_service.rb
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions app/services/person_deletion_service.rb
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Ahoy events are deleted by both user_id and parent visit_id so events on the user's visits that were never attributed to a user (e.g. pre-login) are not left orphaned.

Ahoy::Visit.where(user_id: user.id).delete_all
end
end
7 changes: 6 additions & 1 deletion app/views/people/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
<div id="people_count" class="pr-6">
<h2 class="text-2xl font-semibold mb-2">People</h2>
</div>
<div class="text-right ">
<div class="text-right flex items-center gap-2">
<% 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,
Expand Down
32 changes: 32 additions & 0 deletions app/views/people/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,38 @@
<%= render "bookmarks/editable_bookmark_button", resource: @person.object %>
</span>
</div>
<% if allowed_to?(:archive?, @person) %>
<div class="admin-only flex flex-wrap items-center justify-end gap-2 mb-4">
<% if @person.discarded? %>
<span class="inline-flex items-center px-2 py-0.5 border border-amber-300 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">
<i class="fa-solid fa-box-archive mr-1"></i> Archived
</span>
<%= link_to unarchive_person_path(@person), class: "btn btn-secondary px-3 py-1 text-sm",
data: { turbo_method: :patch } do %>
<i class="fa-solid fa-rotate-left mr-1"></i> Restore
<% end %>
<% else %>
<%= link_to archive_person_path(@person), class: "btn btn-secondary px-3 py-1 text-sm",
data: { turbo_method: :patch } do %>
<i class="fa-solid fa-box-archive mr-1"></i> 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 %>
<i class="fa-solid fa-trash mr-1"></i> 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 %>
<i class="fa-solid fa-triangle-exclamation mr-1"></i> Permanently delete
<% end %>
<% end %>
</div>
<% end %>
<!-- Header: avatar + name + badges -->
<div class="flex flex-col md:flex-row md:items-center gap-6 mb-8">
<div class="flex-shrink-0 flex justify-center md:justify-start">
Expand Down
1 change: 1 addition & 0 deletions config/locales/devise.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@
get :workshop_logs
get :checkout
get :bio
patch :archive
patch :unarchive
end
resources :comments, only: [ :index, :create, :update ]
end
Expand Down
17 changes: 17 additions & 0 deletions db/migrate/20260615120000_add_discarded_at_to_people_and_users.rb
Original file line number Diff line number Diff line change
@@ -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
Loading