Skip to content
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ When changing a model or controller, check whether these related files need upda
| Decorator | Decorator spec |
| Mailer (add/remove) | Mailer spec, mailer preview (follow existing patterns) |
| Add/remove model, concern, service, or gem | AGENTS.md |
| Add an association to `Person`/`User`, or a new model referencing them (`person_id`, `user_id`, `created_by_id`, `updated_by_id`, polymorphic `*able`) | Wire it into `PeopleRemover` (`app/services/people_remover.rb`) so a purge cascades/clears it (or deliberately blocks). `spec/services/people_remover_association_coverage_spec.rb` is the source of truth — it fails on new **associations** until they're handled and allowlisted there; it can't catch a new model that only *references* Person/User, so check that case by hand |

## Code Style

Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ end
- `WorkshopVariationFromIdeaService` — Variation creation from ideas
- `TaggingSearchService` — Search and filter tagging data
- `PersonFromUserService` — Create Person from User account
- `PeopleRemover` — Deletes a set of people and ALL their associated data, their linked User + the user's own data where relevant (real authored content is FK-restricted and blocks), plus the full PaperTrail/Ahoy audit graph; protects real/admin accounts (override with `force`). Used by the `data:remove_people` rake task and the admin-only "Delete person & all data" purge button on person edit (`purge_confirmation`/`purge` actions)
- `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 Expand Up @@ -413,8 +414,9 @@ RuboCop linting on PRs and pushes to main.

## Rake Tasks

Located in `lib/tasks/` (4 files):
Located in `lib/tasks/` (9 files):
- `dev.rake` — Development database seeding from XML/CSV
- `rhino_migrator.rake` — Rich text editor migration
- `attachment_report.rake` — Attachment reporting
- `migrate_internal_id_to_filemaker_code.rake` — FileMaker code migration
- `remove_people.rake` (`data:remove_people`) — Removes fake form-submission people via `PeopleRemover` (dry run unless `CONFIRM=true`)
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ When changing a model or controller, check whether these related files need upda
| Decorator | Decorator spec |
| Mailer (add/remove) | Mailer spec, mailer preview (follow existing patterns) |
| Add/remove model, concern, service, or gem | AGENTS.md |
| Add an association to `Person`/`User`, or a new model referencing them (`person_id`, `user_id`, `created_by_id`, `updated_by_id`, polymorphic `*able`) | Wire it into `PeopleRemover` (`app/services/people_remover.rb`) so a purge cascades/clears it (or deliberately blocks). `spec/services/people_remover_association_coverage_spec.rb` is the source of truth — it fails on new **associations** until they're handled and allowlisted there; it can't catch a new model that only *references* Person/User, so check that case by hand |

## Code Style

Expand Down
38 changes: 37 additions & 1 deletion 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 workshop_logs checkout bio purge_confirmation purge ]

def index
authorize!
Expand Down Expand Up @@ -227,6 +227,42 @@ def destroy
end
end

# Admin-only interstitial: shows exactly what would be deleted for this person
# (and why they're protected, if so) before anything is removed.
def purge_confirmation
authorize! @person, to: :purge?
@force = params[:force] == "true"
# force: true so the inventory reflects the full graph that WOULD be deleted,
# even when the person looks real — the page always shows what's at stake.
preview = PeopleRemover.new(person_ids: [ @person.id ], force: true)
@protections = preview.protection_reasons(@person)
@counts = preview.counts
# Everything removed with the linked account — only relevant when overriding,
# since that's when the account itself (and its content) gets destroyed.
@account_content = @force ? preview.account_content : {}
@person = @person.decorate
end

# Admin-only deletion of a person and their whole data + audit graph. Skips a
# person who looks real unless force=true overrides the protections (see
# PeopleRemover).
def purge
authorize! @person, to: :purge?
remover = PeopleRemover.new(person_ids: [ @person.id ], force: params[:force] == "true")

if remover.call.include?(@person.id)
redirect_to people_path, status: :see_other,
notice: "#{@person.full_name} and all their data were deleted."
else
reasons = remover.skipped.find { |skip| skip.person.id == @person.id }&.reasons
redirect_to edit_person_path(@person), status: :see_other,
alert: "Couldn't delete — this person looks real (#{reasons&.join(', ')}). Use “Delete anyway” to override."
end
rescue ActiveRecord::InvalidForeignKey

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: Backstop for any FK we don't enumerate (an account that authored real content is FK-restricted). The DB rolls the whole transaction back, so this turns a would-be 500 into a clear "reassign or remove that content first" message.

redirect_to edit_person_path(@person), status: :see_other,
alert: "Couldn't delete #{@person.full_name} — the linked User Account is still referenced by records it doesn't own (e.g. a shared banner or event it created). Reassign those first."
end

def check_duplicates
authorize!

Expand Down
8 changes: 8 additions & 0 deletions app/policies/person_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ def destroy?
admin? && record.persisted? && !has_associated_data?
end

# Full purge of a person and their entire data graph. Bypasses the
# has_associated_data? guard on destroy? on purpose — purging exists precisely
# to remove people who have that data. The PeopleRemover service still
# refuses to touch anyone who looks real unless explicitly forced.
def purge?
admin?
end

def search?
admin?
end
Expand Down
Loading