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",