Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ action, or `authorize! :workshop, to: :summary?`).
- `Analytics::AhoyTracker` β€” Coordinates ahoy event tracking
- `Analytics::PersonActivityEvents` β€” Aggregates Ahoy events for a person, their user, and associated data (powers the person edit History card + `person_id` filter on the Ahoy activities index)
- `Analytics::EventReferenceLoader` β€” Batch-loads the records referenced in a page of Ahoy event properties (association changes, associated records), one query per type, so the activity table's Details column links each to its show page without an N+1
- `Analytics::ResourceHistory` β€” One record's own Ahoy lifecycle history, newest first, reading the `(resource_type, resource_id, time)` index. Normalizes each event into an entry (action, time, user, source) with its field changes turned into `label / before / after` triples, dates and booleans formatted for display. Generic β€” takes any record
- `Analytics::AffiliationTimeline` β€” Merges one affiliation's Ahoy edits with the person's facilitator-training registrations and membership invoice periods into one newest-first timeline (powers the affiliation edit History section). Only the edits come from Ahoy; trainings and memberships are read from their own tables because Ahoy only records changes made with a `Current.user`/`Current.source`. Flags the training that minted the affiliation, and the trainings linked to this affiliation's org; falls back to a `:provenance` entry when the minting registration is not a training (a job affiliation)
- `Analytics::PersonAffiliationTimeline` β€” Person-level counterpart to `AffiliationTimeline`: merges all of a person's affiliations, their facilitator-training registrations, and their membership invoice periods into one newest-first timeline (powers the affiliation-history page reached from the gear on the person edit form's affiliations section). Read entirely from own tables (no Ahoy edit history β€” that stays on each affiliation's edit page); flags trainings that link to an org the person is affiliated with

### Business Logic

Expand Down
2 changes: 2 additions & 0 deletions app/controllers/affiliations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class AffiliationsController < ApplicationController

def edit
authorize! @affiliation
@timeline = Analytics::AffiliationTimeline.new(@affiliation)
end

def update
Expand All @@ -15,6 +16,7 @@ def update
if @affiliation.save
redirect_to affiliation_return_path, notice: "Affiliation was successfully updated.", status: :see_other
else
@timeline = Analytics::AffiliationTimeline.new(@affiliation)
render :edit, status: :unprocessable_content
end
end
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class PeopleController < ApplicationController
include AhoyTracking, TagAssignable
before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments send_form_link ]
before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments send_form_link affiliation_history ]

# The profile's "Submitted content" sections β€” private to the person and admins,
# not part of the public profile even after profile viewing opens up.
Expand Down Expand Up @@ -108,6 +108,16 @@ def show
# hang off them (registrations, scholarships, CE registrations, user account) β€”
# in one newest-first feed you can add to and edit in place. Staff-only, since
# comments are internal notes (CommentPolicy#manage? = admin).
# A person's affiliation history β€” their affiliations, facilitator trainings,
# and membership periods in one newest-first timeline. Reached from the gear on
# the affiliations section of the edit form; admin-only, like that section.
def affiliation_history
authorize! @person
@person = @person.decorate
@timeline = Analytics::PersonAffiliationTimeline.new(@person)
track_view("person_affiliation_history", { person_id: @person.id })
end

def all_comments
authorize! @person, to: :manage?, with: CommentPolicy
@person = @person.decorate
Expand Down
4 changes: 4 additions & 0 deletions app/policies/person_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def workshop_logs?
admin? || owner?
end

def affiliation_history?
admin?
end

def own_membership?
owner? && Membership.enabled?
end
Expand Down
101 changes: 101 additions & 0 deletions app/services/analytics/affiliation_timeline.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
module Analytics
# One affiliation's story in time order: the edits made to the affiliation
# itself, the registration that minted it, the facilitator trainings the person
# registered for, and their membership periods β€” merged newest-first.
#
# Only the edits come from Ahoy. Ahoy records *changes*, and only those made
# while a `Current.user`/`Current.source` was set, so imported and seeded rows
# have no events at all. Everything else is read from its own table, which is
# the complete answer; Ahoy is used for the affiliation's own columns because
# nothing else records them.
class AffiliationTimeline
Entry = Data.define(:kind, :occurred_at, :record, :linked_here, :minted) do
def change? = kind == :change
def training? = kind == :training
def membership? = kind == :membership
def provenance? = kind == :provenance
end

def initialize(affiliation, limit: ResourceHistory::DEFAULT_LIMIT)
@affiliation = affiliation
@limit = limit
end

def entries
@entries ||= (change_entries + provenance_entries + training_entries + membership_entries)
.sort_by { |entry| entry.occurred_at || Time.at(0) }
.reverse
end

def any? = entries.any?
def trainings? = training_entries.any?
def memberships? = membership_entries.any?

private

def person
@affiliation.person
end

def minting_registration
@affiliation.event_registration
end

# The timestamps arrive as a mix of Time and Date, which can't be sorted
# against each other.
def entry(kind:, occurred_at:, record:, linked_here: false, minted: false)
Entry.new(kind:, occurred_at: occurred_at&.to_time, record:, linked_here:, minted:)
end

def change_entries
@change_entries ||= ResourceHistory.new(@affiliation, limit: @limit).entries.map do |change|
entry(kind: :change, occurred_at: change.time, record: change, linked_here: true)
end
end

# The minting registration usually IS one of the trainings below, and gets
# marked there rather than duplicated. This covers the other case: a job
# affiliation minted by a registration to an event that isn't a training.
def provenance_entries
return [] unless minting_registration
return [] if training_entries.any? { |candidate| candidate.record.id == minting_registration.id }

[ entry(kind: :provenance, occurred_at: registration_date(minting_registration),
record: minting_registration, minted: true) ]
end

# Dated by the event itself rather than by when the row was written, so it
# sits alongside the affiliation dates it explains.
def training_entries
return @training_entries if defined?(@training_entries)
return @training_entries = [] unless person

registrations = person.event_registrations
.joins(:event).where(events: { facilitator_training: true })
.includes(:event, :organizations)

@training_entries = registrations.map do |registration|
entry(kind: :training, occurred_at: registration_date(registration), record: registration,
linked_here: registration.organizations.any? { |org| org.id == @affiliation.organization_id },
minted: registration.id == @affiliation.event_registration_id)
end
end

def membership_entries
return @membership_entries if defined?(@membership_entries)
return @membership_entries = [] unless person && Membership.enabled?

invoices = MembershipInvoice.joins(:membership)
.where(memberships: { person_id: person.id })
.includes(:membership)

@membership_entries = invoices.map do |invoice|
entry(kind: :membership, occurred_at: invoice.start_date, record: invoice)
end
end

def registration_date(registration)
registration.event&.start_date || registration.created_at
end
end
end
79 changes: 79 additions & 0 deletions app/services/analytics/person_affiliation_timeline.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
module Analytics
# A person's affiliation history as one time-ordered list: every affiliation
# they hold, the facilitator trainings they registered for (which can confer
# facilitator status), and their membership periods β€” merged newest-first.
#
# Everything is read from its own table, so this is the complete picture. Unlike
# AffiliationTimeline it carries no Ahoy edit history β€” that stays on each
# affiliation's own edit page, where a single record's audit trail belongs.
class PersonAffiliationTimeline
Entry = Data.define(:kind, :occurred_at, :record) do
def affiliation? = kind == :affiliation
def training? = kind == :training
def membership? = kind == :membership
end

def initialize(person)
@person = person
end

def entries
@entries ||= (affiliation_entries + training_entries + membership_entries)
.sort_by { |entry| entry.occurred_at || Time.at(0) }
.reverse
end

def any? = entries.any?
def affiliations? = affiliation_entries.any?
def trainings? = training_entries.any?
def memberships? = membership_entries.any?

# The organizations this person is affiliated with, so a training's linked
# organizations can be flagged as conferring status somewhere they belong.
def affiliated_organization_ids
@affiliated_organization_ids ||= affiliations.filter_map(&:organization_id).to_set
end

private

def affiliations
@affiliations ||= @person.affiliations.includes(organization: { logo_attachment: :blob }).to_a
end

# Timestamps arrive as a mix of Date and Time, which can't be sorted against
# each other.
def entry(kind:, occurred_at:, record:)
Entry.new(kind:, occurred_at: occurred_at&.to_time, record:)
end

def affiliation_entries
@affiliation_entries ||= affiliations.map do |affiliation|
entry(kind: :affiliation, occurred_at: affiliation.start_date || affiliation.created_at,
record: affiliation)
end
end

# Dated by the event itself rather than by when the row was written, so it
# sits alongside the affiliation dates it explains.
def training_entries
@training_entries ||= @person.event_registrations
.joins(:event).where(events: { facilitator_training: true })
.includes(:event, :organizations)
.map { |registration| entry(kind: :training, occurred_at: registration_date(registration), record: registration) }
end

def membership_entries
return @membership_entries if defined?(@membership_entries)
return @membership_entries = [] unless Membership.enabled?

@membership_entries = MembershipInvoice.joins(:membership)
.where(memberships: { person_id: @person.id })
.includes(:membership)
.map { |invoice| entry(kind: :membership, occurred_at: invoice.start_date, record: invoice) }
end

def registration_date(registration)
registration.event&.start_date || registration.created_at
end
end
end
103 changes: 103 additions & 0 deletions app/services/analytics/resource_history.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
module Analytics
# One record's own Ahoy history, newest first: what changed, when, and who did
# it. Reads the (resource_type, resource_id, time) index, so it is cheap enough
# to render inline on an edit page.
#
# Takes every event filed against the record rather than just create/update, so
# a custom tracked event (`autochange.*` and the like) shows up here too β€” the
# action is the part of the event name before the dot.
class ResourceHistory
DEFAULT_LIMIT = 25

Change = Data.define(:label, :before, :after)

Entry = Data.define(:action, :time, :user, :source, :changes, :association_summary) do
def detailed? = changes.any? || association_summary.present?
end

def initialize(record, limit: DEFAULT_LIMIT)
@record = record
@limit = limit
end

def entries
@entries ||= events.map { |event| entry_for(event) }
end

def any? = entries.any?

private

def events
return Ahoy::Event.none unless @record&.persisted?

Ahoy::Event
.where(resource_type: @record.class.name, resource_id: @record.id)
.includes(user: :person)
.order(time: :desc)
.limit(@limit)
end


def entry_for(event)
properties = event.properties || {}

Entry.new(
action: event.name.to_s.split(".").first,
time: event.time,
user: event.user,
source: properties["source"].presence,
changes: changes_for(properties["changes"]),
association_summary: association_summary_for(properties["association_changes"])
)
end

def changes_for(raw)
return [] unless raw.is_a?(Hash)

raw.filter_map do |attribute, values|
next unless values.is_a?(Hash)

Change.new(label: label_for(attribute),
before: format_value(values["before"]),
after: format_value(values["after"]))
end
end

# e.g. "2 comments added, 1 updated" β€” enough to know something happened
# alongside the record's own columns without rebuilding the nested diff.
def association_summary_for(raw)
return nil unless raw.is_a?(Hash)

parts = raw.flat_map do |association, entries|
next [] unless entries.is_a?(Array)

entries.group_by { |entry| entry["action"] }.map do |action, group|
"#{group.size} #{association.to_s.humanize(capitalize: false).singularize.pluralize(group.size)} #{action}"
end
end

parts.presence&.to_sentence
end

def label_for(attribute)
@record.class.human_attribute_name(attribute)
end

def format_value(value)
return "β€”" if value.nil? || value == ""
return "Yes" if value == true
return "No" if value == false

as_date(value)&.strftime("%b %-d, %Y") || value.to_s
end

def as_date(value)
return nil unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}/)

Date.parse(value)
rescue Date::Error
nil
end
end
end
Loading