Skip to content
Draft
6 changes: 6 additions & 0 deletions app/models/address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
2 changes: 2 additions & 0 deletions app/models/affiliation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 7 additions & 1 deletion app/models/allocation.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/asset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/models/comment.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions app/models/contact_method.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
6 changes: 5 additions & 1 deletion app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/models/discount.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 6 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/models/event_attendance_time_entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/models/event_form.rb
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class EventRegistration < ApplicationRecord
has_paper_trail meta: { subject_person_id: :registrant_id }
include RemoteSearchable
include Registerable
include Certifiable
Expand Down
5 changes: 5 additions & 0 deletions app/models/event_registration_checklist_completion.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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

validates :step,
presence: true,
inclusion: { in: ->(_) { EventRegistration::CHECKLIST_STEPS.keys } },
uniqueness: { scope: :event_registration_id }

def subject_person_id
event_registration&.registrant_id
end
end
5 changes: 5 additions & 0 deletions app/models/event_registration_organization.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -39,4 +40,8 @@ def record_form_submission(submission)

update!(form_submission: submission)
end

def subject_person_id
event_registration&.registrant_id
end
end
2 changes: 2 additions & 0 deletions app/models/event_staff.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class EventStaff < ApplicationRecord
has_paper_trail meta: { subject_person_id: :person_id }

belongs_to :event
belongs_to :person

Expand Down
5 changes: 5 additions & 0 deletions app/models/form_answer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions app/models/form_submission.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/models/membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion app/models/membership_invoice.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/models/payment.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/models/professional_license.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 7 additions & 1 deletion app/models/refund.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/models/registration_ticket_callout.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/scholarship.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/scholarship_agreement_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ 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 }
validates :responder, inclusion: { in: RESPONDERS }, allow_nil: true
validates :responded_at, presence: true

scope :chronological, -> { order(:responded_at, :id) }

def subject_person_id
scholarship&.recipient_id
end
end
7 changes: 7 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/views/event_registrations/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@

<%= render "form", event_registration: @event_registration %>
<%= render "shared/audit_info", resource: @event_registration %>
<div class="mt-4 flex justify-end">
<%= render "papertrail_versions", record: @event_registration %>
</div>
</div>
13 changes: 13 additions & 0 deletions db/migrate/20260817132717_add_subject_person_id_to_versions.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading