Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d07200e
add timeline migration
jmilljr24 Aug 24, 2026
8c62a18
add service and rename column
jmilljr24 Aug 24, 2026
363a6b7
use namespace
jmilljr24 Aug 24, 2026
8710e94
add timeline to people
jmilljr24 Aug 24, 2026
ac7ee84
handle comments on person and event-reg
jmilljr24 Aug 24, 2026
0e93170
change labels for comment and event reg
jmilljr24 Aug 24, 2026
732364c
Merge branch 'main' into add-timeline
jmilljr24 Aug 24, 2026
1e1ac82
schema
jmilljr24 Aug 24, 2026
8d7b7e5
clean up
jmilljr24 Aug 24, 2026
1db05bd
add event reg timeline
jmilljr24 Aug 24, 2026
00d0015
remove label from snapshot
jmilljr24 Aug 24, 2026
0e6c123
eager load
jmilljr24 Aug 24, 2026
6aebdc8
add notifications to timeline
jmilljr24 Aug 24, 2026
978da01
adjust view condition
jmilljr24 Aug 25, 2026
1b343f7
add renderer
jmilljr24 Aug 25, 2026
673a00f
add timelineable to scholarship and ce
jmilljr24 Aug 25, 2026
c4a1f9a
add subject-path to scholarship
jmilljr24 Aug 25, 2026
34d12b7
handle unique routes
jmilljr24 Aug 25, 2026
ce970b6
handle renderer on all timelineables
jmilljr24 Aug 25, 2026
4bc7a08
guard on destroy - falls back to applicationer timeline renderer
jmilljr24 Aug 25, 2026
60c5f14
spec
jmilljr24 Aug 25, 2026
a077225
add event submission to event-reg timelines
jmilljr24 Aug 25, 2026
22fa595
add affiliation as timelineable
jmilljr24 Aug 25, 2026
b45f8ef
add org timeline ui with affiliations
jmilljr24 Aug 25, 2026
7137692
return nil is not person or org on affiliation link
jmilljr24 Aug 25, 2026
44d16cf
add nested fields to timeline
jmilljr24 Aug 28, 2026
1a82e15
merge main
jmilljr24 Aug 28, 2026
6d3ce40
org timeline
jmilljr24 Aug 28, 2026
6d82a33
fix category destroy
jmilljr24 Aug 28, 2026
3f58a12
handle deleted category
jmilljr24 Aug 28, 2026
9cc8afc
update lables
jmilljr24 Aug 29, 2026
4fa5feb
adjust more lables
jmilljr24 Aug 29, 2026
577df4d
fix
jmilljr24 Aug 29, 2026
7e3d75a
consolidate specs
jmilljr24 Aug 29, 2026
92420c4
clean up custom attributes
jmilljr24 Aug 29, 2026
16f8609
tw-sort
jmilljr24 Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ def edit
stripe_session = Stripe::Checkout::Session.retrieve(@event_registration.checkout_session_id)
@checkout_payment_status = stripe_session.payment_status
end

@timeline_events = @event_registration.timeline_events
.includes(:actor, :subject)
.order(created_at: :desc)
.paginate(page: params[:page], per_page: 25)
end

def create
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ def new

def edit
authorize! @organization

if turbo_frame_request? && params[:section] == "timeline"
@timeline_events = @organization.timeline_events.includes(:actor, :subject).order(created_at: :desc).paginate(page: params[:page], per_page: 25)
return render partial: "organizations/sections/timeline", locals: { organization: @organization, timeline_events: @timeline_events }
end

set_form_variables
end

Expand Down
6 changes: 6 additions & 0 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ def edit
affiliations: { organization: [ :logo_attachment, :addresses ] }
).find(params[:id]).decorate
authorize! @person

if turbo_frame_request? && params[:section] == "timeline"
@timeline_events = @person.timeline_events.includes(:actor, :subject).order(created_at: :desc).paginate(page: params[:page], per_page: 25)
return render partial: "people/sections/timeline", locals: { person: @person, timeline_events: @timeline_events }
end

@membership = membership_for(@person)
set_form_variables
end
Expand Down
21 changes: 21 additions & 0 deletions app/models/address.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
class Address < ApplicationRecord
include Timelineable

def self.timeline_renderer_class
NestedRecordTimelineRenderer
end

ADDRESS_TIMELINE_ATTRIBUTES = %w[
street_address city state zip_code phone county country
address_type locality district
].freeze

LOCALITIES = [ "LA City", "LA County", "Southern CA", "Northern CA",
"Central CA", "Orange County", "Outside CA", "Outside USA", "Unknown" ]
CONTACT_TYPES = [ nil, "work", "personal", "mailing", "unknown" ].freeze
Expand Down Expand Up @@ -32,4 +43,14 @@ class Address < ApplicationRecord
def name
"#{street_address}, #{city}, #{state} #{zip_code}"
end

def timeline_label
"Address"
end

def timeline_changes
saved_changes
.slice(*ADDRESS_TIMELINE_ATTRIBUTES)
.transform_values { |(old_value, new_value)| [ old_value.to_s, new_value.to_s ] }
end
end
9 changes: 9 additions & 0 deletions app/models/affiliation.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Affiliation < ApplicationRecord
include Communicable
include Timelineable
# Standing title given to the "facilitator affiliation" we create on registration
# and org linking. Matches the `facilitators` scope / `facilitator?` predicate
# (both treat exactly "Facilitator" as canonical).
Expand Down Expand Up @@ -28,6 +29,10 @@ class Affiliation < ApplicationRecord
def communications_email
person&.preferred_email
end

def self.timeline_renderer_class
AffiliationTimelineRenderer
end
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? }

# Validations
Expand Down Expand Up @@ -140,6 +145,10 @@ def name
"#{person.name}" if person
end

def timeline_label
"Affiliation: #{person&.name} at #{organization&.name}"
end

private

# The linked address must be one of this affiliation's organization's own
Expand Down
10 changes: 10 additions & 0 deletions app/models/categorizable_item.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
class CategorizableItem < ApplicationRecord
include Timelineable

def self.timeline_renderer_class
NestedRecordTimelineRenderer
end

belongs_to :categorizable, polymorphic: true
belongs_to :category

# Validations
validates_presence_of :categorizable_type, :categorizable_id, :category_id
validates :category_id, uniqueness: { scope: [ :categorizable_type, :categorizable_id ] }

def timeline_label
"category '#{category.name}'"
end
end
26 changes: 26 additions & 0 deletions app/models/comment.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Comment < ApplicationRecord
include Timelineable

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 @@ -94,4 +96,28 @@ def self.parse_date(value)
rescue ArgumentError
nil
end

def timeline_label
return "Comment" unless commentable.present?
"Comment on #{commentable.timeline_label}"
end

def self.timeline_renderer_class
CommentTimelineRenderer
end

private

def timeline_changes
super.except("commentable_id", "commentable_type")
end

def record_timeline_event(action)
return unless action == "created"

targets = TimelineServices::Router.targets_for(self)
return if targets.empty?

TimelineServices::RecordEvent.call(subject: self, action: action, snapshot: { "changes" => timeline_changes })
end
end
8 changes: 8 additions & 0 deletions app/models/concerns/has_timeline.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module HasTimeline
extend ActiveSupport::Concern

included do
has_many :timeline_entries, as: :owner, dependent: :destroy
has_many :timeline_events, through: :timeline_entries
end
end
42 changes: 42 additions & 0 deletions app/models/concerns/timelineable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module Timelineable
extend ActiveSupport::Concern

SENSITIVE_ATTRIBUTE_PATTERN = /password|token|secret|key|digest|salt|otp/i

included do
after_create -> { record_timeline_event("created") }
after_update -> { record_timeline_event("updated") if timeline_changes.any? }
# before_destroy, not after: dependent associations (e.g. a scholarship's
# allocation) are already gone by after_destroy, and routing needs them.
before_destroy -> { record_timeline_event("destroyed") }

def self.timeline_renderer_class
ApplicationTimelineRenderer
end
end

def record_timeline_event(action)
TimelineServices::RecordEvent.call(
subject: self,
action: action,
snapshot: { "changes" => timeline_changes },
also_log: timeline_also_log
)
end

def timeline_also_log
[]
end

def timeline_changes
saved_changes.except("id", "created_at", "updated_at", "created_by_id", "updated_by_id", "slug")
.reject { |attribute, _| attribute.match?(SENSITIVE_ATTRIBUTE_PATTERN) }
.transform_values { |(old_value, new_value)| [ old_value.to_s, new_value.to_s ] }
end

def timeline_label
model_name.human
end

private
end
10 changes: 10 additions & 0 deletions app/models/contact_method.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
class ContactMethod < ApplicationRecord
include Timelineable

def self.timeline_renderer_class
NestedRecordTimelineRenderer
end

CONTACT_TYPES = [ nil, "work", "personal" ].freeze

belongs_to :contactable, polymorphic: true
Expand All @@ -12,4 +18,8 @@ class ContactMethod < ApplicationRecord

validates :value, presence: true, length: { maximum: 255 }
validates :kind, presence: true

def timeline_label
kind.humanize
end
end
10 changes: 10 additions & 0 deletions app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class ContinuingEducationRegistration < ApplicationRecord
include Registerable
include Certifiable
include Communicable
include Timelineable

# AWBW's continuing-education accreditation, referenced by the CE callout copy
# and the completion certificate's CE clause. Kept here as the single source of
Expand Down Expand Up @@ -188,6 +189,15 @@ def payment_status_label
"Due"
end

def timeline_label
return "CE Registration" unless event_registration&.registrant.present?
"CE Registration: #{event_registration.registrant.timeline_label}"
end

def self.timeline_renderer_class
ContinuingEducationRegistrationTimelineRenderer
end

private

# Snapshot the hours offered and total cost from the event when they aren't set
Expand Down
15 changes: 15 additions & 0 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ class EventRegistration < ApplicationRecord
include Registerable
include Certifiable
include Communicable
include HasTimeline
include Timelineable

# Sentinel for the roster's Payment method filter that matches buddy-system
# registrations (someone_else_will_pay), which isn't an expected_payment_method
Expand Down Expand Up @@ -1114,6 +1116,19 @@ def remote_search_label
}
end

def timeline_label
return "Event Registration" unless event.present?
"Event Registration: #{event.name}"
end

def self.timeline_renderer_class
EventRegistrationTimelineRenderer
end

def timeline_also_log
[ registrant ].compact
end

private

def generate_slug
Expand Down
6 changes: 6 additions & 0 deletions app/models/form_submission.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class FormSubmission < ApplicationRecord
include Timelineable

belongs_to :person
belongs_to :form
belongs_to :event, optional: true
Expand Down Expand Up @@ -332,6 +334,10 @@ def title
"#{form&.display_name} submission ##{id}"
end

def timeline_label
"Form Submission: #{form.name}"
end

private

def answer_text(raw_value)
Expand Down
30 changes: 30 additions & 0 deletions app/models/notification.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Notification < ApplicationRecord
include Timelineable

belongs_to :sender, class_name: "User", optional: true
belongs_to :noticeable, polymorphic: true, optional: true
belongs_to :parent_notification, class_name: "Notification", optional: true
Expand Down Expand Up @@ -179,6 +181,34 @@ def timeline_activity_name
TIMELINE_NAMES_BY_DIRECTION.fetch(direction)
end

def timeline_label
subject_text = custom_subject.presence || email_subject.presence
label = "#{channel.titleize} #{direction.humanize}"
label += ": #{subject_text}" if subject_text.present?
label
end

def self.timeline_renderer_class
NotificationTimelineRenderer
end

def record_timeline_event(action)
return unless action == "created"
return unless noticeable.present?

TimelineServices::RecordEvent.call(
subject: self,
action: action,
snapshot: { "changes" => timeline_changes },
also_log: timeline_also_log
)
end

def timeline_also_log
return [] unless noticeable.is_a?(EventRegistration)
[ noticeable.registrant ].compact
end

# Scopes
scope :email, ->(email) { where("notifications.recipient_email LIKE ?", "%#{email}%") }
scope :participant_name, ->(name) { joins(:people)
Expand Down
6 changes: 5 additions & 1 deletion app/models/organization.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class Organization < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable
include Communicable
include Communicable, HasTimeline, Timelineable
belongs_to :organization_status
belongs_to :organization_obligation, optional: true
belongs_to :location, optional: true # TODO - remove Location if unused
Expand All @@ -19,6 +19,10 @@ class Organization < ApplicationRecord
def communications_email
email
end

def timeline_label
"Organization: #{name}"
end
has_many :reports
has_many :workshop_logs
has_many :grants, as: :funder, dependent: :destroy
Expand Down
8 changes: 6 additions & 2 deletions app/models/person.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class Person < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable, StaffTaggable
include Communicable
include Communicable, HasTimeline, RemoteSearchable, TagFilterable, Timelineable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable, StaffTaggable


pay_customer default_payment_processor: :stripe

Expand Down Expand Up @@ -513,6 +513,10 @@ def age_range_items_ordered
age_range_categorizable_items.sort_by { |item| [ item.category&.position || 0, item.category&.name.to_s ] }
end

def timeline_label
name
end

private

# Count the in-memory set (not a DB query): nested attributes build the items in
Expand Down
10 changes: 10 additions & 0 deletions app/models/professional_license.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
class ProfessionalLicense < ApplicationRecord
include Timelineable

def self.timeline_renderer_class
NestedRecordTimelineRenderer
end

has_paper_trail

belongs_to :person
Expand Down Expand Up @@ -71,6 +77,10 @@ def used_for_ce?
continuing_education_registrations.exists?
end

def timeline_label
model_name.human
end

private

def prevent_destroy_with_ce
Expand Down
Loading
Loading