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/allocation.rb b/app/models/allocation.rb index 7b04190ea..f5a0f766f 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,12 @@ def reverted? reverted_id.present? end + # 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 + def amount_dollars amount.to_d / 100 if amount end 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/comment.rb b/app/models/comment.rb index 74b26d753..cd3c97a47 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -1,4 +1,5 @@ class Comment < ApplicationRecord + 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 @@ -73,4 +74,17 @@ def self.parse_date(value) rescue ArgumentError nil end + + # Resolve the polymorphic commentable to its person (forward of + # PersonCommentAggregator); order matters — first match wins, and non-person + # 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) || + commentable.try(:registrant)&.id + end end 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/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 3d09de297..1803901aa 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,10 @@ def payment_status_label "Due" end + 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..f2ea38ff1 100644 --- a/app/models/discount.rb +++ b/app/models/discount.rb @@ -1,4 +1,6 @@ class Discount < ApplicationRecord + # 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.rb b/app/models/event.rb index 32312bb10..aff05755c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -15,6 +15,12 @@ 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). 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 has_many :bookmarks, as: :bookmarkable, dependent: :destroy diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb index 552587bf7..1dee6efac 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 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 @@ -43,6 +44,10 @@ def attendance_date signed_in_at&.in_time_zone(Time.zone)&.to_date end + 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_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_registration.rb b/app/models/event_registration.rb index eda77e3ff..cce8b0664 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -1,4 +1,5 @@ class EventRegistration < ApplicationRecord + 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 9d92b2f54..ec78dc86e 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 meta: { subject_person_id: :subject_person_id } belongs_to :event_registration belongs_to :completed_by, class_name: "User", optional: true @@ -6,4 +7,8 @@ class EventRegistrationChecklistCompletion < ApplicationRecord presence: true, inclusion: { in: ->(_) { EventRegistration::CHECKLIST_STEPS.keys } }, uniqueness: { scope: :event_registration_id } + + 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 c82fcb958..200e6c07f 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 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. @@ -39,4 +40,8 @@ def record_form_submission(submission) update!(form_submission: submission) end + + def subject_person_id + event_registration&.registrant_id + end end 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/form_answer.rb b/app/models/form_answer.rb index 2c81813c5..5d6436516 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,8 @@ def uploaded_file def sync_uploaded_filename! update!(submitted_answer: uploaded_file&.filename.to_s) end + + 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 b4df73fd0..03f343150 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -1,4 +1,5 @@ class FormSubmission < ApplicationRecord + 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/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..d98ccb34f 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,10 @@ def cost_dollars=(value) self.cost_cents = (value.to_d * 100).to_i if value.present? end + def subject_person_id + membership&.person_id + end + private def derive_end_date diff --git a/app/models/organization.rb b/app/models/organization.rb index 6c5153c1f..0d6f28baa 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -1,5 +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. 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/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/person.rb b/app/models/person.rb index 66a94b4fb..58e010a2d 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -3,6 +3,10 @@ class Person < ApplicationRecord pay_customer default_payment_processor: :stripe + # 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/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..909d4646e 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,12 @@ def reverse_payment_remaining refundable.update!(amount_cents_remaining: refundable.amount_cents_remaining + amount_cents) end + # 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) + end + def amount_dollars amount_cents.to_d / 100 if amount_cents end 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 diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index 2a570ff26..baed51e4a 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -1,4 +1,5 @@ class Scholarship < ApplicationRecord + 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/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/app/models/user.rb b/app/models/user.rb index 10afd4c70..8a399fb7c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,6 +4,13 @@ class User < ApplicationRecord devise :database_authenticatable, :recoverable, :confirmable, :rememberable, :trackable, :validatable, :lockable + # 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 } + attr_accessor :locked_will_change before_save :sync_locked_at_from_locked 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 %> +
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..2e62e123f --- /dev/null +++ b/db/migrate/20260817132717_add_subject_person_id_to_versions.rb @@ -0,0 +1,13 @@ +class AddSubjectPersonIdToVersions < ActiveRecord::Migration[8.1] + # 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 ]) + 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| 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