From 15009286ab73431edd49cbab0505c1fb07015eb9 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 19 Aug 2026 09:10:09 -0400 Subject: [PATCH] Show affiliation history as a merged timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading an affiliation's dates tells you what is true now but not how it got there. The Ahoy lifecycle events already record every edit, and the trainings and membership periods that explain those edits live in their own tables — this puts all three in one time-ordered view so an admin can see why a row looks the way it does without leaving the page. Two views: a History section on the affiliation editor (Change / What / When / By columns), and a person-level affiliation history page reached from the gear on the person edit form's affiliations section. Trainings and memberships are read from their own tables rather than from Ahoy: Ahoy records *changes*, and only those made while a Current.user or Current.source was set, so imported and seeded rows have no events at all. Ahoy is used only for the affiliation's own columns, where nothing else records them. Split out of #2195. Was stacked on the STI branch (#2259) while that was open; now that it is closed unmerged, this sits directly on main and the STI-specific resource-type matching is gone with it. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 + app/controllers/affiliations_controller.rb | 2 + app/controllers/people_controller.rb | 12 +- app/policies/person_policy.rb | 4 + .../analytics/affiliation_timeline.rb | 101 ++++++++++++++++ .../analytics/person_affiliation_timeline.rb | 79 +++++++++++++ app/services/analytics/resource_history.rb | 103 +++++++++++++++++ app/views/affiliations/_timeline.html.erb | 94 +++++++++++++++ .../_timeline_affiliation.html.erb | 21 ++++ .../affiliations/_timeline_change.html.erb | 19 +++ .../_timeline_membership.html.erb | 10 ++ .../_timeline_person_training.html.erb | 27 +++++ .../_timeline_provenance.html.erb | 12 ++ .../affiliations/_timeline_training.html.erb | 26 +++++ app/views/affiliations/edit.html.erb | 2 + app/views/people/_form.html.erb | 14 ++- app/views/people/affiliation_history.html.erb | 45 ++++++++ config/routes.rb | 1 + .../affiliations_edit_history_spec.rb | 65 +++++++++++ .../people_affiliation_history_spec.rb | 47 ++++++++ .../analytics/affiliation_timeline_spec.rb | 108 ++++++++++++++++++ .../person_affiliation_timeline_spec.rb | 77 +++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 23 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 app/services/analytics/affiliation_timeline.rb create mode 100644 app/services/analytics/person_affiliation_timeline.rb create mode 100644 app/services/analytics/resource_history.rb create mode 100644 app/views/affiliations/_timeline.html.erb create mode 100644 app/views/affiliations/_timeline_affiliation.html.erb create mode 100644 app/views/affiliations/_timeline_change.html.erb create mode 100644 app/views/affiliations/_timeline_membership.html.erb create mode 100644 app/views/affiliations/_timeline_person_training.html.erb create mode 100644 app/views/affiliations/_timeline_provenance.html.erb create mode 100644 app/views/affiliations/_timeline_training.html.erb create mode 100644 app/views/people/affiliation_history.html.erb create mode 100644 spec/requests/affiliations_edit_history_spec.rb create mode 100644 spec/requests/people_affiliation_history_spec.rb create mode 100644 spec/services/analytics/affiliation_timeline_spec.rb create mode 100644 spec/services/analytics/person_affiliation_timeline_spec.rb 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
  • is `contents`, so its cells become grid items directly. The + row rule lives on the cells rather than the
  • for the same reason. %> +<% return unless allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> +<% cell = "border-t border-gray-100 py-2.5" %> +<% badge = { "create" => [ "Created", "bg-blue-50 text-blue-700" ], + "update" => [ "Update", "bg-gray-100 text-gray-700" ], + training: [ "Training", "bg-purple-50 text-purple-700" ], + membership: [ "Membership", "bg-amber-50 text-amber-800" ], + provenance: [ "Created", "bg-emerald-50 text-emerald-800" ] } %> + +
    +
    + + + + +
    +

    + History (<%= timeline.entries.size %>) +

    +

    Edits to this affiliation, this person's facilitator trainings, and their membership periods.

    +
    + <%= link_to admin_activities_events_path(resource_type: "Affiliation", resource_id: affiliation.id), + class: "ml-auto shrink-0 text-xs text-indigo-600 hover:underline", + title: "Open the full activity log for this affiliation" do %> + Full log + <% end %> +
    + + <% if timeline.any? %> +
      +
    1. + ChangeWhatWhenBy +
    2. + <% timeline.entries.each do |entry| %> + <% label, badge_class = badge[entry.change? ? entry.record.action : entry.kind] %> +
    3. + + <%= label %> + + +
      + <% if entry.change? %> + <%= render "affiliations/timeline_change", change: entry.record %> + <% elsif entry.training? %> + <%= render "affiliations/timeline_training", registration: entry.record, + linked_here: entry.linked_here, minted: entry.minted %> + <% elsif entry.provenance? %> + <%= render "affiliations/timeline_provenance", registration: entry.record %> + <% else %> + <%= render "affiliations/timeline_membership", invoice: entry.record %> + <% end %> +
      + + <%# Zone-converted, matching EventDecorator#date_range — the same event must + not read one date here and another on its own page. %> + + <% when_at = entry.occurred_at&.in_time_zone %> + <%= when_at&.strftime("%b %-d, %Y") %> + <% if entry.change? %> + <%= when_at&.strftime("%-l:%M %p") %> + <% end %> + + + + <% author = entry.change? ? entry.record.user : nil %> + <% if author&.person %> + <%= link_to author.full_name, person_path(author.person), class: "text-indigo-600 hover:underline" %> + <% elsif author %> + <%= author.full_name %> + <% else %> + + <% end %> + +
    4. + <% end %> +
    + <% else %> +

    + 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 %> +
    +
    diff --git a/app/views/affiliations/_timeline_affiliation.html.erb b/app/views/affiliations/_timeline_affiliation.html.erb new file mode 100644 index 0000000000..9601377d6a --- /dev/null +++ b/app/views/affiliations/_timeline_affiliation.html.erb @@ -0,0 +1,21 @@ +<% deco = affiliation.decorate %> +<% facilitator = affiliation.facilitator? %> +
    + "> + <%= affiliation.title.presence || "Facilitator" %> + + <%= deco.period_label %> + <% if affiliation.organization %> + <%= link_to affiliation.organization.name, organization_path(affiliation.organization), + class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> + <% end %> + <% unless affiliation.active? %> + Ended + <% end %> + <%= link_to edit_affiliation_path(affiliation, return_to: "person", origin_id: affiliation.person_id), + target: "_blank", rel: "noopener", + class: "ml-auto shrink-0 text-gray-300 hover:text-gray-500", + title: "Edit this affiliation (opens in a new tab)" do %> + + <% end %> +
    diff --git a/app/views/affiliations/_timeline_change.html.erb b/app/views/affiliations/_timeline_change.html.erb new file mode 100644 index 0000000000..40763da0ed --- /dev/null +++ b/app/views/affiliations/_timeline_change.html.erb @@ -0,0 +1,19 @@ +<%# The "What" cell for a recorded edit: one line per changed field. %> +<% if change.changes.any? %> + <% change.changes.each do |field| %> +
    + <%= field.label %> + <%= field.before %> + + <%= field.after %> +
    + <% end %> +<% else %> + Affiliation <%= change.action == "create" ? "created" : "saved" %> +<% end %> +<% if change.association_summary %> +

    <%= 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. %> +
    + + <%= invoice.start_date.strftime("%b %-d, %Y") %> – <%= invoice.end_date.strftime("%b %-d, %Y") %> + + <%= dollars_from_cents(invoice.cost_cents) %> + <% if invoice.membership.cancelled? %> + Cancelled + <% end %> +
    diff --git a/app/views/affiliations/_timeline_person_training.html.erb b/app/views/affiliations/_timeline_person_training.html.erb new file mode 100644 index 0000000000..fa6c48f798 --- /dev/null +++ b/app/views/affiliations/_timeline_person_training.html.erb @@ -0,0 +1,27 @@ +<%# Locals: registration, affiliated_org_ids (Set of org ids the person is + affiliated with). The person-page counterpart to _timeline_training: it flags + a training against every organization the person belongs to, not one. %> +<% deco = registration.decorate %> +
    + Training + <%= registration.event&.start_date&.to_date&.strftime("%b %Y") %> + <% 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" %> + <% end %> + + + <%= registration.attendance_status_label %> + +
    +

    + <% 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 %> +
    + + + <%= registration.attendance_status_label %> + + <% 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 %> + <% if minted %> + + Created this affiliation + + <% end %> +
    +

    + <% 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 %>
    -
    - Affiliations (only editable by admins) +
    +
    + Affiliations (only editable by admins) +
    + <% if person.persisted? %> + <%= link_to affiliation_history_person_path(person), target: "_blank", rel: "noopener", + class: "shrink-0 text-gray-400 hover:text-gray-600", + title: "Affiliation history — trainings and membership over time (opens in a new tab)" do %> + + Affiliation history + <% end %> + <% end %>
    diff --git a/app/views/people/affiliation_history.html.erb b/app/views/people/affiliation_history.html.erb new file mode 100644 index 0000000000..6804314759 --- /dev/null +++ b/app/views/people/affiliation_history.html.erb @@ -0,0 +1,45 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% affiliated_org_ids = @timeline.affiliated_organization_ids %> + +
    +
    + <%= link_to edit_person_path(@person, anchor: "affiliations"), class: "text-sm text-gray-500 hover:text-gray-700" do %> + + <%= @person.full_name %> + <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
    + +

    Affiliation history

    +

    + <%= @person.full_name %>'s affiliations, facilitator trainings, and membership periods over time. +

    + +
    + <% if @timeline.any? %> +
      + <% @timeline.entries.each do |entry| %> +
    1. + <% if entry.affiliation? %> + <%= render "affiliations/timeline_affiliation", affiliation: entry.record %> + <% elsif entry.training? %> + <%= render "affiliations/timeline_person_training", registration: entry.record, affiliated_org_ids: affiliated_org_ids %> + <% else %> + <%= render "affiliations/timeline_membership", invoice: entry.record %> + <% end %> +
    2. + <% end %> +
    + <% else %> +

    + 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 %> +
    +
    diff --git a/config/routes.rb b/config/routes.rb index 8cce00e914..3819670ecb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -246,6 +246,7 @@ get :bio get :all_comments post :send_form_link + get :affiliation_history end resources :comments, only: [ :create, :update ] resources :memberships, only: [ :index, :new, :create ] diff --git a/spec/requests/affiliations_edit_history_spec.rb b/spec/requests/affiliations_edit_history_spec.rb new file mode 100644 index 0000000000..9274de6641 --- /dev/null +++ b/spec/requests/affiliations_edit_history_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe "the affiliation edit History section", type: :request do + let(:admin) { create(:user, :admin) } + let(:organization) { create(:organization) } + let(:person) { create(:person) } + # Midday rather than midnight: an event stored at UTC midnight reads as the + # previous day for a Pacific viewer, and the controller sets the zone per user. + let(:event) do + create(:event, facilitator_training: true, title: "TOS205 Fresno", + start_date: Time.zone.parse("2026-03-04 12:00"), + end_date: Time.zone.parse("2026-03-05 17:00"), + registration_close_date: Time.zone.parse("2026-03-01 12:00")) + end + let(:registration) { create(:event_registration, event: event, registrant: person, status: "no_show") } + let(:affiliation) do + create(:affiliation, person: person, organization: organization, title: "Facilitator", + start_date: Date.new(2026, 3, 4), event_registration: registration) + end + + before do + sign_in admin + create(:event_registration_organization, event_registration: registration, organization: organization) + end + + it "shows the minting training with its event name and date, marked as the source" do + get edit_affiliation_path(affiliation) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("TOS205 Fresno") + expect(response.body).to include("Mar 4, 2026") + expect(response.body).to include("Created this affiliation") + end + + it "lays the entries out in Change / What / When / By columns" do + get edit_affiliation_path(affiliation) + + headers = Nokogiri::HTML(response.body).css("li.contents").first.css("span").map(&:text).map(&:strip) + expect(headers).to eq(%w[Change What When By]) + end + + it "renders a recorded edit as before → after, with dates and booleans humanized" do + Ahoy::Event.create!(name: "update.affiliation", time: 2.days.ago, visit: create(:ahoy_visit), + resource_type: "Affiliation", resource_id: affiliation.id, user: admin, + properties: { "changes" => { + "inactive" => { "before" => false, "after" => true }, + "end_date" => { "before" => nil, "after" => "2026-03-04" } + } }) + + get edit_affiliation_path(affiliation) + + expect(response.body).to include("End date") + expect(response.body).to include("Mar 4, 2026") + expect(response.body).to include("Yes") + expect(response.body).to include(admin.full_name) + end + + it "does not show the section to a non-admin" do + sign_in create(:user) + + get edit_affiliation_path(affiliation) + + expect(response.body).not_to include("Created this affiliation") + end +end diff --git a/spec/requests/people_affiliation_history_spec.rb b/spec/requests/people_affiliation_history_spec.rb new file mode 100644 index 0000000000..0186769188 --- /dev/null +++ b/spec/requests/people_affiliation_history_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe "People#affiliation_history", type: :request do + let(:admin) { create(:user, :admin) } + let(:person) { create(:person) } + let(:organization) { create(:organization, name: "Sunrise Center") } + + describe "GET /people/:id/affiliation_history" do + context "as an admin" do + before { sign_in admin } + + it "renders the merged affiliation history" do + create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: 1.year.ago.to_date) + event = create(:event, facilitator_training: true, title: "Intro Training", + start_date: 2.years.ago, end_date: 2.years.ago + 1.day, + registration_close_date: 2.years.ago - 1.day) + registration = create(:event_registration, event: event, registrant: person, status: "attended") + create(:event_registration_organization, event_registration: registration, organization: organization) + + get affiliation_history_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Affiliation history") + expect(response.body).to include("Sunrise Center") + expect(response.body).to include("Intro Training") + end + + it "shows an empty state when there is nothing on record" do + get affiliation_history_person_path(person) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("No affiliations, facilitator trainings, or membership periods") + end + end + + context "as a non-admin" do + it "is forbidden" do + sign_in create(:user) + + get affiliation_history_person_path(person) + + expect(response).not_to have_http_status(:ok) + end + end + end +end diff --git a/spec/services/analytics/affiliation_timeline_spec.rb b/spec/services/analytics/affiliation_timeline_spec.rb new file mode 100644 index 0000000000..dc0a876254 --- /dev/null +++ b/spec/services/analytics/affiliation_timeline_spec.rb @@ -0,0 +1,108 @@ +require "rails_helper" + +RSpec.describe Analytics::AffiliationTimeline do + let(:person) { create(:person) } + let(:organization) { create(:organization) } + let(:affiliation) do + create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: 1.year.ago.to_date) + end + + def training(start_date:, status: "attended", link_org: organization, facilitator_training: true) + event = create(:event, facilitator_training: facilitator_training, + start_date: start_date, end_date: start_date + 1.day, + registration_close_date: start_date - 1.day) + registration = create(:event_registration, event: event, registrant: person, status: status) + create(:event_registration_organization, event_registration: registration, organization: link_org) if link_org + registration + end + + def ahoy_event(name:, time:, properties: {}) + Ahoy::Event.create!(name: name, time: time, visit: create(:ahoy_visit), + resource_type: "Affiliation", resource_id: affiliation.id, + properties: properties) + end + + describe "what it merges" do + it "puts edits, trainings and the minting registration in one newest-first list" do + old_training = training(start_date: 3.years.ago) + recent_training = training(start_date: 6.months.ago) + ahoy_event(name: "update.affiliation", time: 1.day.ago, + properties: { "changes" => { "end_date" => { "before" => nil, "after" => "2026-01-01" } } }) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to eq([ :change, :training, :training ]) + expect(entries.map(&:occurred_at)).to eq(entries.map(&:occurred_at).sort.reverse) + expect(entries.last.record).to eq(old_training) + expect(entries[1].record).to eq(recent_training) + end + + it "dates a training by the event, not by when the row was written" do + registration = training(start_date: 2.years.ago) + + entry = described_class.new(affiliation).entries.first + + expect(entry.occurred_at.to_date).to eq(registration.event.start_date.to_date) + end + + it "is empty for an affiliation with no history at all" do + expect(described_class.new(affiliation)).not_to be_any + end + end + + describe "which trainings count as this organization's" do + it "flags a training linked to this affiliation's organization" do + training(start_date: 1.year.ago, link_org: organization) + + expect(described_class.new(affiliation).entries.first.linked_here).to be(true) + end + + it "still lists a training linked to a different organization, unflagged" do + training(start_date: 1.year.ago, link_org: create(:organization)) + + entry = described_class.new(affiliation).entries.first + + expect(entry).to be_training + expect(entry.linked_here).to be(false) + end + + it "ignores registrations to events that are not facilitator trainings" do + training(start_date: 1.year.ago, facilitator_training: false) + + expect(described_class.new(affiliation)).not_to be_trainings + end + end + + describe "the minting registration" do + it "marks the training that created this affiliation rather than repeating it" do + registration = training(start_date: 1.year.ago) + affiliation.update!(event_registration: registration) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to eq([ :training ]) + expect(entries.first.minted).to be(true) + end + + it "does not mark the person's other trainings" do + minting = training(start_date: 2.years.ago) + training(start_date: 1.year.ago) + affiliation.update!(event_registration: minting) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.select(&:minted).map(&:record)).to eq([ minting ]) + end + + it "adds a provenance entry when the minting event is not a facilitator training" do + registration = training(start_date: 1.year.ago, facilitator_training: false) + affiliation.update!(event_registration: registration) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to eq([ :provenance ]) + expect(entries.first.record).to eq(registration) + end + end +end diff --git a/spec/services/analytics/person_affiliation_timeline_spec.rb b/spec/services/analytics/person_affiliation_timeline_spec.rb new file mode 100644 index 0000000000..23f74fff32 --- /dev/null +++ b/spec/services/analytics/person_affiliation_timeline_spec.rb @@ -0,0 +1,77 @@ +require "rails_helper" + +RSpec.describe Analytics::PersonAffiliationTimeline do + let(:person) { create(:person) } + let(:organization) { create(:organization) } + + def affiliation(start_date:, **attrs) + create(:affiliation, person: person, organization: organization, start_date: start_date, **attrs) + end + + def training(start_date:, status: "attended", link_org: organization, facilitator_training: true) + event = create(:event, facilitator_training: facilitator_training, + start_date: start_date, end_date: start_date + 1.day, + registration_close_date: start_date - 1.day) + registration = create(:event_registration, event: event, registrant: person, status: status) + create(:event_registration_organization, event_registration: registration, organization: link_org) if link_org + registration + end + + def membership_invoice(start_date:) + membership = create(:membership, person: person) + create(:membership_invoice, membership: membership, start_date: start_date, end_date: start_date + 1.year - 1.day) + end + + describe "what it merges" do + it "puts affiliations, trainings and memberships in one newest-first list" do + affiliation(start_date: 3.years.ago.to_date) + training(start_date: 2.years.ago) + membership_invoice(start_date: 6.months.ago.to_date) + + entries = described_class.new(person).entries + + expect(entries.map(&:kind)).to eq([ :membership, :training, :affiliation ]) + expect(entries.map(&:occurred_at)).to eq(entries.map(&:occurred_at).sort.reverse) + end + + it "dates a training by the event, not by when the row was written" do + registration = training(start_date: 2.years.ago) + + entry = described_class.new(person).entries.first + + expect(entry).to be_training + expect(entry.occurred_at.to_date).to eq(registration.event.start_date.to_date) + end + + it "is empty for a person with no affiliations, trainings or memberships" do + expect(described_class.new(person)).not_to be_any + end + + it "ignores registrations to events that are not facilitator trainings" do + training(start_date: 1.year.ago, facilitator_training: false) + + expect(described_class.new(person)).not_to be_trainings + end + end + + describe "#affiliated_organization_ids" do + it "collects the organizations the person is affiliated with" do + other = create(:organization) + affiliation(start_date: 1.year.ago.to_date) + create(:affiliation, person: person, organization: other, start_date: 1.year.ago.to_date) + + expect(described_class.new(person).affiliated_organization_ids).to contain_exactly(organization.id, other.id) + end + end + + describe "memberships" do + it "lists the person's membership periods" do + invoice = membership_invoice(start_date: 3.months.ago.to_date) + + timeline = described_class.new(person) + + expect(timeline).to be_memberships + expect(timeline.entries.map(&:record)).to include(invoice) + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 9d390af9b8..79d775281b 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -111,6 +111,7 @@ "app/views/other_responses/index.html.erb" => "admin-only bg-blue-100", "app/views/banners/index.html.erb" => "admin-only bg-blue-100", "app/views/people/all_comments.html.erb" => "admin-only bg-blue-100", + "app/views/people/affiliation_history.html.erb" => "admin-only bg-blue-100", "app/views/comments/index.html.erb" => "admin-only bg-blue-100", "app/views/bookmarks/index.html.erb" => "admin-only bg-blue-100", "app/views/categories/index.html.erb" => "admin-only bg-blue-100",