diff --git a/AGENTS.md b/AGENTS.md index 6ea1f94b02..f7a8769947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/controllers/affiliations_controller.rb b/app/controllers/affiliations_controller.rb index 092395db6a..5b4a167a39 100644 --- a/app/controllers/affiliations_controller.rb +++ b/app/controllers/affiliations_controller.rb @@ -4,6 +4,7 @@ class AffiliationsController < ApplicationController def edit authorize! @affiliation + @timeline = Analytics::AffiliationTimeline.new(@affiliation) end def update @@ -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 diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 5c731587f5..cb59143a05 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -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. @@ -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 diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 72c95c2d2a..4c40c8da0d 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -20,6 +20,10 @@ def workshop_logs? admin? || owner? end + def affiliation_history? + admin? + end + def own_membership? owner? && Membership.enabled? end diff --git a/app/services/analytics/affiliation_timeline.rb b/app/services/analytics/affiliation_timeline.rb new file mode 100644 index 0000000000..c71758b8a5 --- /dev/null +++ b/app/services/analytics/affiliation_timeline.rb @@ -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 diff --git a/app/services/analytics/person_affiliation_timeline.rb b/app/services/analytics/person_affiliation_timeline.rb new file mode 100644 index 0000000000..79d151378e --- /dev/null +++ b/app/services/analytics/person_affiliation_timeline.rb @@ -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 diff --git a/app/services/analytics/resource_history.rb b/app/services/analytics/resource_history.rb new file mode 100644 index 0000000000..c033531e26 --- /dev/null +++ b/app/services/analytics/resource_history.rb @@ -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 diff --git a/app/views/affiliations/_timeline.html.erb b/app/views/affiliations/_timeline.html.erb new file mode 100644 index 0000000000..ce4c402c23 --- /dev/null +++ b/app/views/affiliations/_timeline.html.erb @@ -0,0 +1,94 @@ +<%# Locals: timeline (Analytics::AffiliationTimeline), affiliation. Admin-only — + the raw Ahoy records behind the edit entries are reachable from "Full log". + + One grid for the whole list so the four columns line up across every entry + kind: each
Edits to this affiliation, this person's facilitator trainings, and their membership periods.
++ Nothing recorded yet — edits made from here on will show up, alongside any facilitator training this person registers for. +
+ <% end %> + + <% unless timeline.trainings? %> ++ This person has no facilitator-training registrations on record. +
+ <% end %> +<%= change.association_summary.upcase_first %>
+<% end %> +<% if change.source %> +via <%= change.source %>
+<% end %> diff --git a/app/views/affiliations/_timeline_membership.html.erb b/app/views/affiliations/_timeline_membership.html.erb new file mode 100644 index 0000000000..33e6795cc0 --- /dev/null +++ b/app/views/affiliations/_timeline_membership.html.erb @@ -0,0 +1,10 @@ +<%# The "What" cell for one paid membership period. %> ++ <% orgs = registration.organizations.to_a %> + <% linked = orgs.select { |org| affiliated_org_ids.include?(org.id) } %> + <% if linked.any? %> + Can confer facilitator status at <%= linked.map(&:name).to_sentence %> — an organization this person is affiliated with. + <% elsif orgs.any? %> + Linked to <%= orgs.map(&:name).to_sentence %>, not an organization this person is affiliated with. + <% else %> + No organization linked to this registration. + <% end %> +
diff --git a/app/views/affiliations/_timeline_provenance.html.erb b/app/views/affiliations/_timeline_provenance.html.erb new file mode 100644 index 0000000000..7125284628 --- /dev/null +++ b/app/views/affiliations/_timeline_provenance.html.erb @@ -0,0 +1,12 @@ +<%# The "What" cell when the minting registration is not a facilitator training. %> +<% if registration.event %> + <%= link_to registration.event.title, event_path(registration.event), + target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> + <%= registration.event.start_date&.in_time_zone&.strftime("%b %Y") %> +<% end %> ++ Minted by + <%= link_to (registration.registrant&.full_name.presence || "a registration"), + edit_event_registration_path(registration), target: "_blank", rel: "noopener", class: "underline" %>, + which is not a facilitator training — so it confers no facilitator status. +
diff --git a/app/views/affiliations/_timeline_training.html.erb b/app/views/affiliations/_timeline_training.html.erb new file mode 100644 index 0000000000..eb8361f8f1 --- /dev/null +++ b/app/views/affiliations/_timeline_training.html.erb @@ -0,0 +1,26 @@ +<%# The "What" cell for one of this person's facilitator trainings. %> +<% deco = registration.decorate %> ++ <% if linked_here %> + Linked to this organization — a training that can confer facilitator status here. + <% else %> + <% others = registration.organizations.map(&:name) %> + <%= others.any? ? "Linked to #{others.to_sentence}, not this organization." : "No organization linked to this registration." %> + <% end %> +
diff --git a/app/views/affiliations/edit.html.erb b/app/views/affiliations/edit.html.erb index 12388e90e9..c622cfdf02 100644 --- a/app/views/affiliations/edit.html.erb +++ b/app/views/affiliations/edit.html.erb @@ -239,5 +239,7 @@ + <%= render "affiliations/timeline", timeline: @timeline, affiliation: @affiliation %> + <%= render "shared/audit_info", resource: @affiliation %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 5dbd68e4eb..bf08acf80b 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -255,8 +255,18 @@ <% person = f.object.respond_to?(:object) ? f.object.object : f.object %> <% decorated = person.decorate %>+ <%= @person.full_name %>'s affiliations, facilitator trainings, and membership periods over time. +
+ ++ No affiliations, facilitator trainings, or membership periods on record for this person yet. +
+ <% end %> + + <% unless @timeline.trainings? %> ++ This person has no facilitator-training registrations on record. +
+ <% end %> +