From e8e8f09ac6b65dae7fd0b4de0274527eda8a6ebf Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 17 Aug 2026 08:57:02 -0400 Subject: [PATCH 01/11] Track event registrations with PaperTrail Give EventRegistration a versioned audit trail (status, payment flags, attendance, certificate, scholarship changes) and surface it as the existing Change Log dropdown on the registration edit page. Ahoy stays on these records; PaperTrail adds a per-record, reify-able history alongside it. Start with the parent record only and expand to child records as we see what the log reads like. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/event_registration.rb | 1 + app/views/event_registrations/edit.html.erb | 3 +++ 2 files changed, 4 insertions(+) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index eda77e3ff..b7c2774e2 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -1,4 +1,5 @@ class EventRegistration < ApplicationRecord + has_paper_trail include RemoteSearchable include Registerable include Certifiable diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 1d6e0bddf..81a52ca9b 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -64,4 +64,7 @@ <%= render "form", event_registration: @event_registration %> <%= render "shared/audit_info", resource: @event_registration %> +
+ <%= render "papertrail_versions", record: @event_registration %> +
From ad79a32c9f3d154e8a989a314ad1f4128b33b0d5 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 17 Aug 2026 09:07:55 -0400 Subject: [PATCH 02/11] Version registration child records with PaperTrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the registration audit trail to its child records so a full timeline can reconstruct what changed and by whom, including after a record is deleted (destroy versions persist in the versions table). - EventAttendanceTimeEntry: sign-in/out corrections - EventRegistrationChecklistCompletion: onboarding step completions - EventRegistrationOrganization: org link add/remove - Comment: comment history (app-wide, polymorphic — not registration-only) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/comment.rb | 1 + app/models/event_attendance_time_entry.rb | 1 + app/models/event_registration_checklist_completion.rb | 1 + app/models/event_registration_organization.rb | 1 + 4 files changed, 4 insertions(+) diff --git a/app/models/comment.rb b/app/models/comment.rb index 74b26d753..f04def359 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,4 +1,5 @@ class Comment < ApplicationRecord + has_paper_trail belongs_to :commentable, polymorphic: true belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index 552587bf7..6c521b759 100644 --- a/app/models/event_attendance_time_entry.rb +++ b/app/models/event_attendance_time_entry.rb @@ -5,6 +5,7 @@ # signed in (an "open" entry). Times are stored UTC and displayed in the app zone # (Pacific), matching the paper CE sign-in sheet this replaces. class EventAttendanceTimeEntry < ApplicationRecord + has_paper_trail belongs_to :event_registration # Registrant self-service sign-ins happen on the public (login-free) callout, so # created_by is nil for those; it's stamped only when staff add/edit an entry on diff --git a/app/models/event_registration_checklist_completion.rb b/app/models/event_registration_checklist_completion.rb index 9d92b2f54..c9c2465cc 100644 --- a/app/models/event_registration_checklist_completion.rb +++ b/app/models/event_registration_checklist_completion.rb @@ -1,4 +1,5 @@ class EventRegistrationChecklistCompletion < ApplicationRecord + has_paper_trail belongs_to :event_registration belongs_to :completed_by, class_name: "User", optional: true diff --git a/app/models/event_registration_organization.rb b/app/models/event_registration_organization.rb index c82fcb958..21c488562 100644 --- a/app/models/event_registration_organization.rb +++ b/app/models/event_registration_organization.rb @@ -1,4 +1,5 @@ class EventRegistrationOrganization < ApplicationRecord + has_paper_trail belongs_to :event_registration belongs_to :organization # The submission whose answers describe this org, pinned when the link is made. From 9ec9a8b1d3b77218bf3f5e4040b162d93c704374 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 17 Aug 2026 09:12:35 -0400 Subject: [PATCH 03/11] Version scholarships with PaperTrail A scholarship's award amount, agreement signature, and task completion are part of a registration's story via the allocation that links them. Track the scholarship so the timeline captures award/acceptance changes; its comments and joining allocation are already versioned. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/scholarship.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index 2a570ff26..ad23b6057 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -1,4 +1,5 @@ class Scholarship < ApplicationRecord + has_paper_trail belongs_to :recipient, class_name: "Person" belongs_to :grant, optional: true has_one :allocation, as: :source, dependent: :destroy From 03ee66d36b94bdd103a8954d852cbd7d87cda96e Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 17 Aug 2026 09:17:55 -0400 Subject: [PATCH 04/11] Version form submissions with PaperTrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the submission envelope (event linkage, metadata, create/delete) so the timeline and audit trail can see when a registrant submitted or a submission was removed. The answer rows live in form_answers (untracked here — see PR note). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/form_submission.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb index b4df73fd0..638b86abc 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -1,4 +1,5 @@ class FormSubmission < ApplicationRecord + has_paper_trail belongs_to :person belongs_to :form belongs_to :event, optional: true From 5a2329f32a734be45a3a7e2d6b39fa79ae07b7b6 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 17 Aug 2026 09:31:33 -0400 Subject: [PATCH 05/11] Track form answers + user accounts; add subject_person_id to versions Foundation for making PaperTrail the single activity-timeline source (retiring Ahoy) with both person and per-record timelines. - FormAnswer: version the actual submitted answers (envelope was already tracked on FormSubmission). - User: version account changes, skipping only secrets (password + reset/ confirm/unlock/welcome tokens). Sign-in timestamps, counts, and IPs are kept as the login-activity signal (parity with Ahoy). - Add versions.subject_person_id (indexed) so a person's timeline is one indexed sweep instead of reifying and walking associations. Every timeline-tracked model populates it uniformly via has_paper_trail meta; Comment resolves its polymorphic commentable to the subject person. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/comment.rb | 16 +++++++++++++++- app/models/event_attendance_time_entry.rb | 8 +++++++- app/models/event_registration.rb | 2 +- .../event_registration_checklist_completion.rb | 8 +++++++- app/models/event_registration_organization.rb | 8 +++++++- app/models/form_answer.rb | 7 +++++++ app/models/form_submission.rb | 2 +- app/models/scholarship.rb | 2 +- app/models/user.rb | 8 ++++++++ ...17132717_add_subject_person_id_to_versions.rb | 15 +++++++++++++++ db/schema.rb | 4 +++- 11 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260817132717_add_subject_person_id_to_versions.rb diff --git a/app/models/comment.rb b/app/models/comment.rb index f04def359..892b54183 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,5 +1,5 @@ class Comment < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :commentable, polymorphic: true belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true @@ -74,4 +74,18 @@ def self.parse_date(value) rescue ArgumentError nil end + + # The person this comment is about, so it lands on their activity timeline. + # Resolves the commentable to its person the same way PersonCommentAggregator + # walks the reverse direction: a profile is the person; registrations and + # scholarships carry the registrant/recipient; a CE registration delegates to + # its registration's registrant; a user account carries its person. Org/story/ + # workshop comments aren't person-scoped and stay nil. + def subject_person_id + return commentable.id if commentable.is_a?(Person) + commentable.try(:registrant_id) || + commentable.try(:recipient_id) || + commentable.try(:person_id) || + commentable.try(:registrant)&.id + end end diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index 6c521b759..5a473bfed 100644 --- a/app/models/event_attendance_time_entry.rb +++ b/app/models/event_attendance_time_entry.rb @@ -5,7 +5,7 @@ # signed in (an "open" entry). Times are stored UTC and displayed in the app zone # (Pacific), matching the paper CE sign-in sheet this replaces. class EventAttendanceTimeEntry < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :event_registration # Registrant self-service sign-ins happen on the public (login-free) callout, so # created_by is nil for those; it's stamped only when staff add/edit an entry on @@ -44,6 +44,12 @@ def attendance_date signed_in_at&.in_time_zone(Time.zone)&.to_date end + # The person this record's timeline entries belong to — the registrant behind + # the parent registration. + def subject_person_id + event_registration&.registrant_id + end + private # On :base and phrased as a whole sentence like the other two guards: these reach diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index b7c2774e2..cce8b0664 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -1,5 +1,5 @@ class EventRegistration < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :registrant_id } include RemoteSearchable include Registerable include Certifiable diff --git a/app/models/event_registration_checklist_completion.rb b/app/models/event_registration_checklist_completion.rb index c9c2465cc..83d77c7f9 100644 --- a/app/models/event_registration_checklist_completion.rb +++ b/app/models/event_registration_checklist_completion.rb @@ -1,5 +1,5 @@ class EventRegistrationChecklistCompletion < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :event_registration belongs_to :completed_by, class_name: "User", optional: true @@ -7,4 +7,10 @@ class EventRegistrationChecklistCompletion < ApplicationRecord presence: true, inclusion: { in: ->(_) { EventRegistration::CHECKLIST_STEPS.keys } }, uniqueness: { scope: :event_registration_id } + + # The person this record's timeline entries belong to — the registrant behind + # the parent registration. + def subject_person_id + event_registration&.registrant_id + end end diff --git a/app/models/event_registration_organization.rb b/app/models/event_registration_organization.rb index 21c488562..d2febcf96 100644 --- a/app/models/event_registration_organization.rb +++ b/app/models/event_registration_organization.rb @@ -1,5 +1,5 @@ class EventRegistrationOrganization < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :event_registration belongs_to :organization # The submission whose answers describe this org, pinned when the link is made. @@ -40,4 +40,10 @@ def record_form_submission(submission) update!(form_submission: submission) end + + # The person this record's timeline entries belong to — the registrant behind + # the parent registration. + def subject_person_id + event_registration&.registrant_id + end end diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb index 2c81813c5..422b5f20d 100644 --- a/app/models/form_answer.rb +++ b/app/models/form_answer.rb @@ -8,6 +8,7 @@ # `simple_format` — that would reintroduce an XSS hole. Volume abuse (giant # answers) is bounded separately by FormField's effective max-characters cap. class FormAnswer < ApplicationRecord + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :form_field, optional: true belongs_to :form_submission @@ -39,4 +40,10 @@ def uploaded_file def sync_uploaded_filename! update!(submitted_answer: uploaded_file&.filename.to_s) end + + # The person this answer's timeline entries belong to — the submitter behind the + # parent submission. + def subject_person_id + form_submission&.person_id + end end diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb index 638b86abc..03f343150 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -1,5 +1,5 @@ class FormSubmission < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :person_id } belongs_to :person belongs_to :form belongs_to :event, optional: true diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index ad23b6057..baed51e4a 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -1,5 +1,5 @@ class Scholarship < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :recipient_id } belongs_to :recipient, class_name: "Person" belongs_to :grant, optional: true has_one :allocation, as: :source, dependent: :destroy diff --git a/app/models/user.rb b/app/models/user.rb index 10afd4c70..0040ff4bd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,6 +4,14 @@ class User < ApplicationRecord devise :database_authenticatable, :recoverable, :confirmable, :rememberable, :trackable, :validatable, :lockable + # Version account changes for the activity timeline. Skip only the secrets — + # password + the reset/confirm/unlock/welcome tokens — so they never land in the + # audit log. Sign-in timestamps, counts, and IPs are deliberately kept: they're + # the login-activity signal the timeline needs (parity with what Ahoy retained). + has_paper_trail skip: %w[encrypted_password reset_password_token confirmation_token + unlock_token welcome_instructions_token], + meta: { subject_person_id: :person_id } + attr_accessor :locked_will_change before_save :sync_locked_at_from_locked diff --git a/db/migrate/20260817132717_add_subject_person_id_to_versions.rb b/db/migrate/20260817132717_add_subject_person_id_to_versions.rb new file mode 100644 index 000000000..f98f6aa6e --- /dev/null +++ b/db/migrate/20260817132717_add_subject_person_id_to_versions.rb @@ -0,0 +1,15 @@ +class AddSubjectPersonIdToVersions < ActiveRecord::Migration[8.1] + # Denormalized "who is this version about" so a person's activity timeline is a + # single indexed sweep of the versions table (PaperTrail::Version), rather than + # reifying and walking associations per row. Populated uniformly by every + # timeline-tracked model via has_paper_trail meta. + def up + add_column :versions, :subject_person_id, :bigint unless column_exists?(:versions, :subject_person_id) + add_index :versions, [ :subject_person_id, :created_at ] unless index_exists?(:versions, [ :subject_person_id, :created_at ]) + end + + def down + remove_index :versions, column: [ :subject_person_id, :created_at ] if index_exists?(:versions, [ :subject_person_id, :created_at ]) + remove_column :versions, :subject_person_id, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8674e05fd..6cf1327e1 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_08_17_115845) do +ActiveRecord::Schema[8.1].define(version: 2026_08_17_132717) 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 @@ -1524,8 +1524,10 @@ t.string "item_type", null: false t.text "object", size: :long t.text "object_changes", size: :long + t.bigint "subject_person_id" t.string "whodunnit" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" + t.index ["subject_person_id", "created_at"], name: "index_versions_on_subject_person_id_and_created_at" end create_table "video_recordings", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| From 38579dfda8051e88e7f9907eb9e098d682b82962 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 18 Aug 2026 06:25:44 -0400 Subject: [PATCH 06/11] Person-scope the financial & membership version trails Add subject_person_id meta to the already-versioned money/membership models so their versions land on a person's activity timeline: - Payment, Membership, ProfessionalLicense: direct person_id - MembershipInvoice (member), ContinuingEducationRegistration (registrant): delegated resolvers - Allocation, Refund: polymorphic resolvers walking allocatable/refundable (and Refund's own person recipient) to the subject person - Discount stays unscoped by design (fans out to many people; scoped via its Allocation versions) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/allocation.rb | 10 +++++++++- app/models/continuing_education_registration.rb | 8 +++++++- app/models/discount.rb | 3 +++ app/models/membership.rb | 2 +- app/models/membership_invoice.rb | 7 ++++++- app/models/payment.rb | 2 +- app/models/professional_license.rb | 2 +- app/models/refund.rb | 9 ++++++++- 8 files changed, 36 insertions(+), 7 deletions(-) diff --git a/app/models/allocation.rb b/app/models/allocation.rb index 7b04190ea..b0bd45b15 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -1,5 +1,5 @@ class Allocation < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :source, polymorphic: true belongs_to :allocatable, polymorphic: true belongs_to :reverted, class_name: "Allocation", optional: true @@ -43,6 +43,14 @@ def reverted? reverted_id.present? end + # The person this allocation's timeline entries belong to — the person behind + # whatever it funds. Allocatables are EventRegistration (registrant), + # ContinuingEducationRegistration (delegated registrant), and MembershipInvoice + # (delegated member); org-owed allocatables resolve to nil. + def subject_person_id + allocatable.try(:registrant_id) || allocatable.try(:registrant)&.id || allocatable.try(:person)&.id + end + def amount_dollars amount.to_d / 100 if amount end diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 3d09de297..47ec2ab19 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -10,7 +10,7 @@ class ContinuingEducationRegistration < ApplicationRecord # AWBW's CAMFT approved-provider number (per awbw.org's CE hours page). ACCREDITATION_PROVIDER_NUMBER = "1000151".freeze - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :event_registration delegate :registrant, to: :event_registration @@ -154,6 +154,12 @@ def payment_status_label "Due" end + # The person this record's timeline entries belong to — the registrant behind + # the parent registration. + def subject_person_id + event_registration&.registrant_id + end + private # Snapshot the hours offered and total cost from the event when they aren't set diff --git a/app/models/discount.rb b/app/models/discount.rb index 70208feec..f2909c575 100644 --- a/app/models/discount.rb +++ b/app/models/discount.rb @@ -1,4 +1,7 @@ class Discount < ApplicationRecord + # No subject_person_id meta: a discount fans out to many allocations (and thus + # many people), so it has no single timeline owner — its person scope rides on + # each Allocation version instead. has_paper_trail has_many :allocations, as: :source, dependent: :destroy diff --git a/app/models/membership.rb b/app/models/membership.rb index 23b296372..e9690421b 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -11,7 +11,7 @@ def self.enabled? GRACE_PERIOD_DAYS = ENV.fetch("ANNUAL_MEMBERSHIP_GRACE_PERIOD_DAYS", 30).to_i RENEWAL_WINDOW_DAYS = ENV.fetch("ANNUAL_MEMBERSHIP_RENEWAL_WINDOW_DAYS", 30).to_i - has_paper_trail + has_paper_trail meta: { subject_person_id: :person_id } belongs_to :person has_many :membership_invoices, -> { order(start_date: :desc) }, dependent: :destroy diff --git a/app/models/membership_invoice.rb b/app/models/membership_invoice.rb index e8f285a08..dd1508fad 100644 --- a/app/models/membership_invoice.rb +++ b/app/models/membership_invoice.rb @@ -1,7 +1,7 @@ class MembershipInvoice < ApplicationRecord include Registerable - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } belongs_to :membership has_many :allocations, as: :allocatable, dependent: :destroy @@ -75,6 +75,11 @@ def cost_dollars=(value) self.cost_cents = (value.to_d * 100).to_i if value.present? end + # The person this invoice's timeline entries belong to — the member. + def subject_person_id + membership&.person_id + end + private def derive_end_date diff --git a/app/models/payment.rb b/app/models/payment.rb index f834e420d..4259068d3 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -1,5 +1,5 @@ class Payment < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :person_id } PAYER_TYPES = %w[Person Organization].freeze has_many :allocations, as: :source diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index 9a4091d6c..3cf334663 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -1,5 +1,5 @@ class ProfessionalLicense < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :person_id } belongs_to :person belongs_to :created_by, class_name: "User", optional: true diff --git a/app/models/refund.rb b/app/models/refund.rb index bb4fa4806..83ed6fc1d 100644 --- a/app/models/refund.rb +++ b/app/models/refund.rb @@ -1,5 +1,5 @@ class Refund < ApplicationRecord - has_paper_trail + has_paper_trail meta: { subject_person_id: :subject_person_id } METHODS = %w[check cash stripe].freeze belongs_to :refundable, polymorphic: true @@ -21,6 +21,13 @@ def reverse_payment_remaining refundable.update!(amount_cents_remaining: refundable.amount_cents_remaining + amount_cents) end + # The person this refund's timeline entries belong to — the recipient when it's + # a person, otherwise the person behind the refunded payment. + def subject_person_id + return recipient_id if recipient_type == "Person" + refundable.try(:person_id) + end + def amount_dollars amount_cents.to_d / 100 if amount_cents end From b1ff14130a9da69ebb5288dd76480422ec08bbca Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 18 Aug 2026 06:28:35 -0400 Subject: [PATCH 07/11] Keep sign-in IPs out of the version trail An IP is PII and the versions table is append-only, so versioning it fights data-retention/erasure. Skip the two sign-in IP columns; the live IP stays on the user record. Sign-in timestamps and counts remain versioned as the login-activity signal. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/user.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 0040ff4bd..d3e247a3c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,12 +4,14 @@ class User < ApplicationRecord devise :database_authenticatable, :recoverable, :confirmable, :rememberable, :trackable, :validatable, :lockable - # Version account changes for the activity timeline. Skip only the secrets — - # password + the reset/confirm/unlock/welcome tokens — so they never land in the - # audit log. Sign-in timestamps, counts, and IPs are deliberately kept: they're - # the login-activity signal the timeline needs (parity with what Ahoy retained). + # Version account changes for the activity timeline. Skip the secrets (password + + # the reset/confirm/unlock/welcome tokens) so they never land in the audit log, + # and skip the sign-in IPs: an IP is PII, and the versions table is append-only, + # so keeping them there fights data-retention/erasure. The live IP still sits on + # the record (mutable, purgeable). Sign-in timestamps and counts stay versioned — + # that's the login-activity signal the timeline needs, minus the IP. has_paper_trail skip: %w[encrypted_password reset_password_token confirmation_token - unlock_token welcome_instructions_token], + unlock_token welcome_instructions_token current_sign_in_ip last_sign_in_ip], meta: { subject_person_id: :person_id } attr_accessor :locked_will_change From 106c0e8bc233c1dc04f2ee78da38caeee641fc7b Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 18 Aug 2026 06:35:48 -0400 Subject: [PATCH 08/11] Trim PaperTrail comments to non-obvious whys only Drop the six self-evident subject_person_id delegator comments; condense the polymorphic resolver, User skip-list, Discount, and migration comments to the gotcha they actually carry, per the comment policy. --- app/models/allocation.rb | 6 ++---- app/models/comment.rb | 9 +++------ app/models/continuing_education_registration.rb | 2 -- app/models/discount.rb | 5 ++--- app/models/event_attendance_time_entry.rb | 2 -- app/models/event_registration_checklist_completion.rb | 2 -- app/models/event_registration_organization.rb | 2 -- app/models/form_answer.rb | 2 -- app/models/membership_invoice.rb | 1 - app/models/refund.rb | 3 +-- app/models/user.rb | 9 +++------ .../20260817132717_add_subject_person_id_to_versions.rb | 6 ++---- 12 files changed, 13 insertions(+), 36 deletions(-) diff --git a/app/models/allocation.rb b/app/models/allocation.rb index b0bd45b15..f5a0f766f 100644 --- a/app/models/allocation.rb +++ b/app/models/allocation.rb @@ -43,10 +43,8 @@ def reverted? reverted_id.present? end - # The person this allocation's timeline entries belong to — the person behind - # whatever it funds. Allocatables are EventRegistration (registrant), - # ContinuingEducationRegistration (delegated registrant), and MembershipInvoice - # (delegated member); org-owed allocatables resolve to nil. + # Resolve the polymorphic allocatable to its person: registration/CE registrant + # or membership-invoice member; org-owed allocatables stay nil. def subject_person_id allocatable.try(:registrant_id) || allocatable.try(:registrant)&.id || allocatable.try(:person)&.id end diff --git a/app/models/comment.rb b/app/models/comment.rb index 892b54183..1c915262f 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -75,12 +75,9 @@ def self.parse_date(value) nil end - # The person this comment is about, so it lands on their activity timeline. - # Resolves the commentable to its person the same way PersonCommentAggregator - # walks the reverse direction: a profile is the person; registrations and - # scholarships carry the registrant/recipient; a CE registration delegates to - # its registration's registrant; a user account carries its person. Org/story/ - # workshop comments aren't person-scoped and stay nil. + # Resolve the polymorphic commentable to its person (forward of + # PersonCommentAggregator); order matters — first match wins, and non-person + # commentables (org/story/workshop) stay nil. def subject_person_id return commentable.id if commentable.is_a?(Person) commentable.try(:registrant_id) || diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 47ec2ab19..1803901aa 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -154,8 +154,6 @@ def payment_status_label "Due" end - # The person this record's timeline entries belong to — the registrant behind - # the parent registration. def subject_person_id event_registration&.registrant_id end diff --git a/app/models/discount.rb b/app/models/discount.rb index f2909c575..f2ea38ff1 100644 --- a/app/models/discount.rb +++ b/app/models/discount.rb @@ -1,7 +1,6 @@ class Discount < ApplicationRecord - # No subject_person_id meta: a discount fans out to many allocations (and thus - # many people), so it has no single timeline owner — its person scope rides on - # each Allocation version instead. + # No subject_person_id: a discount fans out to many allocations/people, so it has + # no single timeline owner — its person scope rides on each Allocation version. has_paper_trail has_many :allocations, as: :source, dependent: :destroy diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index 5a473bfed..1dee6efac 100644 --- a/app/models/event_attendance_time_entry.rb +++ b/app/models/event_attendance_time_entry.rb @@ -44,8 +44,6 @@ def attendance_date signed_in_at&.in_time_zone(Time.zone)&.to_date end - # The person this record's timeline entries belong to — the registrant behind - # the parent registration. def subject_person_id event_registration&.registrant_id end diff --git a/app/models/event_registration_checklist_completion.rb b/app/models/event_registration_checklist_completion.rb index 83d77c7f9..ec78dc86e 100644 --- a/app/models/event_registration_checklist_completion.rb +++ b/app/models/event_registration_checklist_completion.rb @@ -8,8 +8,6 @@ class EventRegistrationChecklistCompletion < ApplicationRecord inclusion: { in: ->(_) { EventRegistration::CHECKLIST_STEPS.keys } }, uniqueness: { scope: :event_registration_id } - # The person this record's timeline entries belong to — the registrant behind - # the parent registration. def subject_person_id event_registration&.registrant_id end diff --git a/app/models/event_registration_organization.rb b/app/models/event_registration_organization.rb index d2febcf96..200e6c07f 100644 --- a/app/models/event_registration_organization.rb +++ b/app/models/event_registration_organization.rb @@ -41,8 +41,6 @@ def record_form_submission(submission) update!(form_submission: submission) end - # The person this record's timeline entries belong to — the registrant behind - # the parent registration. def subject_person_id event_registration&.registrant_id end diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb index 422b5f20d..5d6436516 100644 --- a/app/models/form_answer.rb +++ b/app/models/form_answer.rb @@ -41,8 +41,6 @@ def sync_uploaded_filename! update!(submitted_answer: uploaded_file&.filename.to_s) end - # The person this answer's timeline entries belong to — the submitter behind the - # parent submission. def subject_person_id form_submission&.person_id end diff --git a/app/models/membership_invoice.rb b/app/models/membership_invoice.rb index dd1508fad..d98ccb34f 100644 --- a/app/models/membership_invoice.rb +++ b/app/models/membership_invoice.rb @@ -75,7 +75,6 @@ def cost_dollars=(value) self.cost_cents = (value.to_d * 100).to_i if value.present? end - # The person this invoice's timeline entries belong to — the member. def subject_person_id membership&.person_id end diff --git a/app/models/refund.rb b/app/models/refund.rb index 83ed6fc1d..909d4646e 100644 --- a/app/models/refund.rb +++ b/app/models/refund.rb @@ -21,8 +21,7 @@ def reverse_payment_remaining refundable.update!(amount_cents_remaining: refundable.amount_cents_remaining + amount_cents) end - # The person this refund's timeline entries belong to — the recipient when it's - # a person, otherwise the person behind the refunded payment. + # Person recipient when there is one, else the refunded payment's person. def subject_person_id return recipient_id if recipient_type == "Person" refundable.try(:person_id) diff --git a/app/models/user.rb b/app/models/user.rb index d3e247a3c..8a399fb7c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,12 +4,9 @@ class User < ApplicationRecord devise :database_authenticatable, :recoverable, :confirmable, :rememberable, :trackable, :validatable, :lockable - # Version account changes for the activity timeline. Skip the secrets (password + - # the reset/confirm/unlock/welcome tokens) so they never land in the audit log, - # and skip the sign-in IPs: an IP is PII, and the versions table is append-only, - # so keeping them there fights data-retention/erasure. The live IP still sits on - # the record (mutable, purgeable). Sign-in timestamps and counts stay versioned — - # that's the login-activity signal the timeline needs, minus the IP. + # Skip the secrets (PaperTrail serializes every column and won't scrub them) and + # the sign-in IPs (PII in an append-only log fights erasure; the live IP stays on + # the record). Sign-in timestamps/counts stay versioned as the login signal. has_paper_trail skip: %w[encrypted_password reset_password_token confirmation_token unlock_token welcome_instructions_token current_sign_in_ip last_sign_in_ip], meta: { subject_person_id: :person_id } diff --git a/db/migrate/20260817132717_add_subject_person_id_to_versions.rb b/db/migrate/20260817132717_add_subject_person_id_to_versions.rb index f98f6aa6e..2e62e123f 100644 --- a/db/migrate/20260817132717_add_subject_person_id_to_versions.rb +++ b/db/migrate/20260817132717_add_subject_person_id_to_versions.rb @@ -1,8 +1,6 @@ class AddSubjectPersonIdToVersions < ActiveRecord::Migration[8.1] - # Denormalized "who is this version about" so a person's activity timeline is a - # single indexed sweep of the versions table (PaperTrail::Version), rather than - # reifying and walking associations per row. Populated uniformly by every - # timeline-tracked model via has_paper_trail meta. + # Denormalized subject person so a person's timeline is one indexed sweep of the + # versions table, not a reify-and-walk per row. Populated via has_paper_trail meta. def up add_column :versions, :subject_person_id, :bigint unless column_exists?(:versions, :subject_person_id) add_index :versions, [ :subject_person_id, :created_at ] unless index_exists?(:versions, [ :subject_person_id, :created_at ]) From 5ad11e6047fc487c1f0b5adee9877113e0e86580 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 18 Aug 2026 06:50:48 -0400 Subject: [PATCH 09/11] Version the person cluster for timeline parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend PaperTrail + subject_person_id to the records a person's timeline needs beyond registrations: Person (self), Affiliation (person), Address/ContactMethod (polymorphic — person-owned only), Asset (owner's person). Organization is tracked but unscoped — it has many affiliates, so it surfaces to a person via their affiliation, not a single subject_person_id. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/address.rb | 6 ++++++ app/models/affiliation.rb | 2 ++ app/models/asset.rb | 6 ++++++ app/models/contact_method.rb | 6 ++++++ app/models/organization.rb | 3 +++ app/models/person.rb | 2 ++ 6 files changed, 25 insertions(+) diff --git a/app/models/address.rb b/app/models/address.rb index f0100d65c..a4f101873 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -11,6 +11,8 @@ class Address < ApplicationRecord PR GU VI AS MP ].freeze + has_paper_trail meta: { subject_person_id: :subject_person_id } + belongs_to :addressable, polymorphic: true, touch: true # Affiliations that point to this address as their organization address. Nullify # the link rather than block deletion when an org address is removed. @@ -26,4 +28,8 @@ class Address < ApplicationRecord def name "#{street_address}, #{city}, #{state} #{zip_code}" end + + def subject_person_id + addressable_id if addressable_type == "Person" + end end diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 6f780a621..c1886ecc3 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -12,6 +12,8 @@ class Affiliation < ApplicationRecord # Options offered by the attendees index's Affiliation status filter. FILTER_STATUSES = [ "Active", "Upcoming", ACTIVE_OR_UPCOMING, "Inactive" ].freeze + has_paper_trail meta: { subject_person_id: :person_id } + belongs_to :organization, inverse_of: :affiliations belongs_to :person, touch: true # Which of the organization's addresses this person is affiliated with (optional). diff --git a/app/models/asset.rb b/app/models/asset.rb index 7ead8f1c7..851fb2ed8 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -70,6 +70,8 @@ def self.max_file_size_label ActiveSupport::NumberHelper.number_to_human_size(MAX_FILE_SIZE) end + has_paper_trail meta: { subject_person_id: :subject_person_id } + belongs_to :owner, polymorphic: true, optional: true, touch: true belongs_to :report, optional: true @@ -86,6 +88,10 @@ def self.max_file_size_label validate :file_type validate :file_size + def subject_person_id + owner&.try(:subject_person_id) || owner&.try(:person_id) + end + private def file_type diff --git a/app/models/contact_method.rb b/app/models/contact_method.rb index 50fd8ed76..e40167b3b 100644 --- a/app/models/contact_method.rb +++ b/app/models/contact_method.rb @@ -1,6 +1,8 @@ class ContactMethod < ApplicationRecord CONTACT_TYPES = [ nil, "work", "personal" ].freeze + has_paper_trail meta: { subject_person_id: :subject_person_id } + belongs_to :contactable, polymorphic: true belongs_to :address, optional: true @@ -12,4 +14,8 @@ class ContactMethod < ApplicationRecord validates :value, presence: true validates :kind, presence: true + + def subject_person_id + contactable_id if contactable_type == "Person" + end end diff --git a/app/models/organization.rb b/app/models/organization.rb index 6c5153c1f..d36b8ee76 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,5 +1,8 @@ class Organization < ApplicationRecord include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable + # No subject_person_id: an org relates to many people, so it has no single timeline + # owner — surface org changes to a person via their affiliation. + has_paper_trail belongs_to :organization_status belongs_to :organization_obligation, optional: true belongs_to :location, optional: true # TODO - remove Location if unused diff --git a/app/models/person.rb b/app/models/person.rb index 66a94b4fb..8163abe4f 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -3,6 +3,8 @@ class Person < ApplicationRecord pay_customer default_payment_processor: :stripe + has_paper_trail meta: { subject_person_id: :id } + belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true From 2c6312cb69ce6443fc44722185f56bb159ef6e91 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 18 Aug 2026 07:03:42 -0400 Subject: [PATCH 10/11] Version event-side config with PaperTrail Completes the event-registration coordination graph. EventStaff is person-scoped (a staffing role belongs on that person's timeline); Event, EventForm, and RegistrationTicketCallout have no single person, so they get a subjectless global trail like Discount. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/event.rb | 5 +++++ app/models/event_form.rb | 4 ++++ app/models/event_staff.rb | 2 ++ app/models/registration_ticket_callout.rb | 4 ++++ 4 files changed, 15 insertions(+) diff --git a/app/models/event.rb b/app/models/event.rb index 32312bb10..21a6a844d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -15,6 +15,11 @@ class Event < ApplicationRecord has_rich_text :rhino_header has_rich_text :rhino_description + # No subject_person_id: an event is shared config that fans out to every + # registrant, so its versions are a global audit trail rather than one person's + # (same subjectless pattern as Discount). + has_paper_trail + belongs_to :created_by, class_name: "User", optional: true belongs_to :location, optional: true has_many :bookmarks, as: :bookmarkable, dependent: :destroy diff --git a/app/models/event_form.rb b/app/models/event_form.rb index 8ce0b9d9f..adc89ca15 100644 --- a/app/models/event_form.rb +++ b/app/models/event_form.rb @@ -1,4 +1,8 @@ class EventForm < ApplicationRecord + # No subject_person_id: an event's form linkage is shared config, not scoped to + # one person, so its versions are a global audit trail (like Event itself). + has_paper_trail + belongs_to :event belongs_to :form diff --git a/app/models/event_staff.rb b/app/models/event_staff.rb index 176f2adbb..45dde54c4 100644 --- a/app/models/event_staff.rb +++ b/app/models/event_staff.rb @@ -1,4 +1,6 @@ class EventStaff < ApplicationRecord + has_paper_trail meta: { subject_person_id: :person_id } + belongs_to :event belongs_to :person diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index c936cfdd3..7fd55b4d6 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -45,6 +45,10 @@ class RegistrationTicketCallout < ApplicationRecord # blank one still falls back via #display_icon_class. attribute :icon_class, :string, default: -> { DEFAULT_ICONS["action"] } + # No subject_person_id: a callout is event-wide config shown to every registrant, + # so its versions are a global audit trail rather than one person's (like Event). + has_paper_trail + belongs_to :event # A callout can link many resources, shown in order on its detail page (PDF From 6eda9417592b0373e246056d7c18a19112041d15 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 06:29:15 -0400 Subject: [PATCH 11/11] Extend and pin the person-scoped version trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story and story-idea comments were the one aggregated source the trail dropped on the floor: PersonCommentAggregator credits them to the explicit author (or the submitting user's person), so the resolver now reads the same author_person precedence and the feed and the timeline can't disagree. Scholarship agreement transitions were likewise missing — the accept/decline history is part of what an award did to a person. Touch is off on Person, Organization, and Event because PaperTrail versions touches by default and a touch version bypasses the did-anything-change check: every address, affiliation, sector tag, or asset save was writing a parent version with no changes and no subject person. The spec covers each resolver and guards the shape — a newly versioned model that forgets subject_person_id has to be added to the subjectless list or the suite fails. Co-Authored-By: Claude Opus 5 (1M context) --- app/models/comment.rb | 4 +- app/models/event.rb | 5 +- app/models/organization.rb | 6 +- app/models/person.rb | 4 +- app/models/scholarship_agreement_response.rb | 6 + .../paper_trail_subject_person_id_spec.rb | 301 ++++++++++++++++++ 6 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 spec/models/paper_trail_subject_person_id_spec.rb diff --git a/app/models/comment.rb b/app/models/comment.rb index 1c915262f..cd3c97a47 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -77,9 +77,11 @@ def self.parse_date(value) # Resolve the polymorphic commentable to its person (forward of # PersonCommentAggregator); order matters — first match wins, and non-person - # commentables (org/story/workshop) stay nil. + # commentables (org/workshop) stay nil. Stories credit the same person the + # aggregator does: the explicit author, else the submitting user's person. def subject_person_id return commentable.id if commentable.is_a?(Person) + return commentable.author_person&.id if commentable.is_a?(Story) || commentable.is_a?(StoryIdea) commentable.try(:registrant_id) || commentable.try(:recipient_id) || commentable.try(:person_id) || diff --git a/app/models/event.rb b/app/models/event.rb index 21a6a844d..aff05755c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -17,8 +17,9 @@ class Event < ApplicationRecord # No subject_person_id: an event is shared config that fans out to every # registrant, so its versions are a global audit trail rather than one person's - # (same subjectless pattern as Discount). - has_paper_trail + # (same subjectless pattern as Discount). Touch is off because an asset upload + # touches the event, and a touch version records no changes at all. + has_paper_trail on: %i[create update destroy] belongs_to :created_by, class_name: "User", optional: true belongs_to :location, optional: true diff --git a/app/models/organization.rb b/app/models/organization.rb index d36b8ee76..0d6f28baa 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,8 +1,10 @@ class Organization < ApplicationRecord include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable # No subject_person_id: an org relates to many people, so it has no single timeline - # owner — surface org changes to a person via their affiliation. - has_paper_trail + # owner — surface org changes to a person via their affiliation. Touch is off + # because addresses and sector tags touch the org, and a touch version records + # no changes at all. + has_paper_trail on: %i[create update destroy] belongs_to :organization_status belongs_to :organization_obligation, optional: true belongs_to :location, optional: true # TODO - remove Location if unused diff --git a/app/models/person.rb b/app/models/person.rb index 8163abe4f..58e010a2d 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -3,7 +3,9 @@ class Person < ApplicationRecord pay_customer default_payment_processor: :stripe - has_paper_trail meta: { subject_person_id: :id } + # Touch is off: addresses and affiliations touch the person, and a touch version + # records no changes and no subject person — pure noise on their timeline. + has_paper_trail meta: { subject_person_id: :id }, on: %i[create update destroy] belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true diff --git a/app/models/scholarship_agreement_response.rb b/app/models/scholarship_agreement_response.rb index bd160dd53..ce66e0b28 100644 --- a/app/models/scholarship_agreement_response.rb +++ b/app/models/scholarship_agreement_response.rb @@ -4,6 +4,8 @@ class ScholarshipAgreementResponse < ApplicationRecord STATUSES = %w[pending accepted declined].freeze RESPONDERS = %w[recipient admin system].freeze + has_paper_trail meta: { subject_person_id: :subject_person_id } + belongs_to :scholarship validates :status, inclusion: { in: STATUSES } @@ -11,4 +13,8 @@ class ScholarshipAgreementResponse < ApplicationRecord validates :responded_at, presence: true scope :chronological, -> { order(:responded_at, :id) } + + def subject_person_id + scholarship&.recipient_id + end end diff --git a/spec/models/paper_trail_subject_person_id_spec.rb b/spec/models/paper_trail_subject_person_id_spec.rb new file mode 100644 index 000000000..65077327f --- /dev/null +++ b/spec/models/paper_trail_subject_person_id_spec.rb @@ -0,0 +1,301 @@ +require "rails_helper" + +# `versions.subject_person_id` is what a person's timeline reads, so it is only +# as good as each model's resolution of "whose person is this?". These models +# share nothing but that column, so the whole trail is asserted in one place. +RSpec.describe "PaperTrail subject_person_id" do + def subject_person_id_for(record) + record.versions.last.subject_person_id + end + + # Versioned models that deliberately carry no person: shared config, or records + # that fan out to many people and reach a timeline through their children. + let(:subjectless_models) { %w[Discount Event EventForm Organization RegistrationTicketCallout] } + + it "gives every versioned model a subject_person_id unless it is deliberately subjectless" do + Rails.autoloaders.main.eager_load_dir(Rails.root.join("app/models")) + versioned = ApplicationRecord.descendants + .select { |model| model.respond_to?(:paper_trail_options) } + .map(&:base_class).uniq + + without_subject = versioned.reject { |model| model.paper_trail_options.dig(:meta, :subject_person_id) } + + expect(without_subject.map(&:name)).to match_array(subjectless_models) + end + + describe "records keyed directly to a person" do + let(:person) { create(:person) } + + it "files the person's own versions under themselves" do + expect(subject_person_id_for(person)).to eq(person.id) + end + + it "files an affiliation under its person" do + expect(subject_person_id_for(create(:affiliation, person: person))).to eq(person.id) + end + + it "files an event staffing under its person" do + expect(subject_person_id_for(create(:event_staff, person: person))).to eq(person.id) + end + + it "files a professional license under its person" do + expect(subject_person_id_for(create(:professional_license, person: person))).to eq(person.id) + end + + it "files a login account under the person it belongs to" do + user = create(:user, person: person) + + expect(subject_person_id_for(user)).to eq(person.id) + end + + it "leaves a login account with no person unattributed" do + expect(subject_person_id_for(create(:user, person: nil))).to be_nil + end + + it "files a person's address under them and an organization's under nobody" do + expect(subject_person_id_for(create(:address, addressable: person))).to eq(person.id) + expect(subject_person_id_for(create(:address, addressable: create(:organization)))).to be_nil + end + + it "files a contact method under its person" do + contact_method = ContactMethod.create!(contactable: person, kind: "phone", value: "555-0100") + + expect(subject_person_id_for(contact_method)).to eq(person.id) + end + end + + describe "the registration cluster" do + let(:person) { create(:person) } + let(:registration) { create(:event_registration, registrant: person) } + + it "files the registration under its registrant" do + expect(subject_person_id_for(registration)).to eq(person.id) + end + + it "files a checklist completion under the registrant" do + completion = create(:event_registration_checklist_completion, event_registration: registration) + + expect(subject_person_id_for(completion)).to eq(person.id) + end + + it "files a linked organization under the registrant" do + link = create(:event_registration_organization, event_registration: registration) + + expect(subject_person_id_for(link)).to eq(person.id) + end + + it "files an attendance time entry under the registrant" do + entry = create(:event_attendance_time_entry, event_registration: registration) + + expect(subject_person_id_for(entry)).to eq(person.id) + end + + it "files a CE registration under the registrant" do + ce_registration = create(:continuing_education_registration, event_registration: registration) + + expect(subject_person_id_for(ce_registration)).to eq(person.id) + end + end + + describe "forms" do + let(:person) { create(:person) } + let(:submission) { create(:form_submission, person: person) } + + it "files a submission under the person who submitted it" do + expect(subject_person_id_for(submission)).to eq(person.id) + end + + it "files an answer under the submission's person" do + expect(subject_person_id_for(create(:form_answer, form_submission: submission))).to eq(person.id) + end + end + + describe "scholarships" do + let(:person) { create(:person) } + let(:scholarship) { create(:scholarship, recipient: person) } + + it "files the award under its recipient" do + expect(subject_person_id_for(scholarship)).to eq(person.id) + end + + it "files each agreement response under the award's recipient" do + response = create(:scholarship_agreement_response, scholarship: scholarship) + + expect(subject_person_id_for(response)).to eq(person.id) + end + end + + describe "the financial trail" do + let(:person) { create(:person) } + let(:payment) { create(:payment, person: person) } + + it "files a payment under its payer" do + expect(subject_person_id_for(payment)).to eq(person.id) + end + + it "files a membership and its invoices under the member" do + membership = create(:membership, person: person) + invoice = create(:membership_invoice, membership: membership) + + expect(subject_person_id_for(membership)).to eq(person.id) + expect(subject_person_id_for(invoice)).to eq(person.id) + end + + it "files a refund under a person recipient" do + refund = create(:refund, refundable: payment, recipient: person) + + expect(subject_person_id_for(refund)).to eq(person.id) + end + + it "falls back to the refunded payment's person for an organization recipient" do + refund = create(:refund, refundable: payment, recipient: create(:organization)) + + expect(subject_person_id_for(refund)).to eq(person.id) + end + + it "resolves an allocation through whatever it pays for" do + registration = create(:event_registration, registrant: person) + ce_registration = create(:continuing_education_registration, event_registration: registration) + invoice = create(:membership_invoice, membership: create(:membership, person: person)) + + [ registration, ce_registration, invoice ].each do |allocatable| + allocation = create(:allocation, source: payment, allocatable: allocatable) + + expect(subject_person_id_for(allocation)).to eq(person.id), "expected #{allocatable.class} allocation to resolve" + end + end + end + + describe "comments" do + let(:person) { create(:person) } + + it "files comments on the person cluster under that person" do + registration = create(:event_registration, registrant: person) + commentables = [ + person, + registration, + create(:continuing_education_registration, event_registration: registration), + create(:scholarship, recipient: person), + create(:topic_subscription, person: person), + person.user + ] + + commentables.each do |commentable| + comment = create(:comment, commentable: commentable) + + expect(subject_person_id_for(comment)).to eq(person.id), "expected a #{commentable.class} comment to resolve" + end + end + + it "credits a story comment to the story's author, matching PersonCommentAggregator" do + comment = create(:comment, commentable: create(:story, author: person)) + + expect(subject_person_id_for(comment)).to eq(person.id) + end + + it "credits an unattributed story's comment to the submitting user's person" do + comment = create(:comment, commentable: create(:story, author: nil, created_by: person.user)) + + expect(subject_person_id_for(comment)).to eq(person.id) + end + + it "credits a story idea comment to the submitting user's person" do + comment = create(:comment, commentable: create(:story_idea, created_by: person.user)) + + expect(subject_person_id_for(comment)).to eq(person.id) + end + + it "leaves comments on records that belong to no one person unattributed" do + [ create(:organization), create(:workshop) ].each do |commentable| + comment = create(:comment, commentable: commentable) + + expect(subject_person_id_for(comment)).to be_nil, "expected a #{commentable.class} comment to stay unattributed" + end + end + end + + describe "assets" do + it "files an upload under the person whose form answer owns it" do + person = create(:person) + answer = create(:form_answer, form_submission: create(:form_submission, person: person)) + + expect(subject_person_id_for(create(:asset, owner: answer))).to eq(person.id) + end + + it "leaves an upload owned by shared content unattributed" do + expect(subject_person_id_for(create(:asset, owner: create(:event)))).to be_nil + end + end + + # PaperTrail versions `:touch` by default, and a touch version bypasses the + # "did anything change?" check — so without opting out, every child save writes + # a parent version with no changes and (through the deferred-touch path) no + # subject person. + describe "records that children touch" do + it "writes no version when a child save only touches its parent" do + person = create(:person) + organization = create(:organization) + event = create(:event) + [ person, organization, event ].each { |record| record.versions.destroy_all } + + create(:address, addressable: person) + create(:affiliation, person: person) + create(:address, addressable: organization) + create(:sectorable_item, sectorable: organization) + create(:asset, owner: event) + + expect(person.versions.reload).to be_empty + expect(organization.versions.reload).to be_empty + expect(event.versions.reload).to be_empty + end + + it "still versions their own updates" do + person = create(:person) + person.versions.destroy_all + + person.update!(first_name: "Renamed") + + expect(subject_person_id_for(person)).to eq(person.id) + end + end + + describe "deliberately subjectless models" do + it "records no person for shared configuration" do + event = create(:event) + records = [ + event, + create(:discount), + create(:event_form, event: event), + create(:organization), + create(:registration_ticket_callout, event: event) + ] + + records.each do |record| + expect(subject_person_id_for(record)).to be_nil, "expected #{record.class} versions to carry no person" + end + end + end + + describe "the user trail" do + it "keeps secrets and sign-in IPs out of the version, and records the sign-in itself" do + user = create(:user, person: create(:person)) + user.versions.destroy_all + + user.update!(sign_in_count: 1, current_sign_in_at: Time.current, current_sign_in_ip: "203.0.113.5") + version = user.versions.last + + expect(version.object_changes).to include("sign_in_count") + expect(version.object_changes).not_to include("sign_in_ip", "203.0.113.5") + expect(version.object).not_to include("encrypted_password", "reset_password_token") + end + + it "records no version when only the sign-in IPs change" do + user = create(:user) + user.versions.destroy_all + + user.update!(current_sign_in_ip: "203.0.113.5", last_sign_in_ip: "203.0.113.5") + + expect(user.versions.reload).to be_empty + end + end +end