diff --git a/AGENTS.md b/AGENTS.md index 18e277690a..4e9af7861d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,6 +121,8 @@ This codebase (Rails 8.1) - **Asset** (inheritance column: `type`): PrimaryAsset, GalleryAsset, RichTextAsset, DownloadableAsset, ThumbnailAsset, FormUploadAsset - The `type` column defaults to `"PrimaryAsset"`, which narrows `ACCEPTED_CONTENT_TYPES` to five image types — always name the type when building an Asset, or documents will fail validation. `FormUploadAsset` backs a respondent's file-upload form answer and takes Asset's full accepted-type list. - **Report**: MonthlyReport +- **Affiliation** (inheritance column: `type`, nullable, no default): FacilitatorAffiliation, JobAffiliation + - The type is *derived from the title* — `set_type_from_title` (a `before_validation`) sets `FacilitatorAffiliation` when the title is exactly `"Facilitator"` (trimmed, case-sensitive), else `JobAffiliation` (the default). Title is the single source of truth, so a retitle re-types the row; never write `title` via `update_all`/`update_columns` (skips the callback). The column has **no default** on purpose — a default subclass name would make `Affiliation.new` build that subclass and then break `reload` after the callback re-types. `#facilitator?` and the `.facilitators` scope read the `type` column. Both subtypes share `Affiliation`'s routes/param-key/`dom_id` (`self.model_name`) and authorize through the one `AffiliationPolicy` (`self.policy_class`). ### Polymorphic Associations @@ -196,6 +198,9 @@ action, or `authorize! :workshop, to: :summary?`). - `Analytics::EventBuilder` — Constructs analytics event payloads - `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::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). See ADR-0002 D2a +- `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 da1278bf2c..0cad016029 100644 --- a/app/controllers/affiliations_controller.rb +++ b/app/controllers/affiliations_controller.rb @@ -3,6 +3,7 @@ class AffiliationsController < ApplicationController def edit authorize! @affiliation + @timeline = Analytics::AffiliationTimeline.new(@affiliation) end def update @@ -14,6 +15,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 030c9b2fc7..67f88a1c9c 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 ] + before_action :set_person, only: %i[ show edit update destroy workshop_logs checkout bio all_comments affiliation_history ] def index authorize! @@ -97,6 +97,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/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 86f3f24dc7..46028193e0 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -2,4 +2,11 @@ class AffiliationDecorator < ApplicationDecorator def detail(length: nil) "#{person.full_name}: #{title.presence || position} - #{organization.name}" end + + # e.g. "Oct 13, 2026 – present" + def date_range + start = start_date ? start_date.strftime("%b %-d, %Y") : "no start date" + finish = end_date ? end_date.strftime("%b %-d, %Y") : "present" + "#{start} – #{finish}" + end end diff --git a/app/frontend/javascript/controllers/affiliation_dates_controller.js b/app/frontend/javascript/controllers/affiliation_dates_controller.js index 641e23adaf..46286a0d2e 100644 --- a/app/frontend/javascript/controllers/affiliation_dates_controller.js +++ b/app/frontend/javascript/controllers/affiliation_dates_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import { isFacilitatorTitle } from "../lib/affiliation" export default class extends Controller { static targets = ["affiliatedSince", "facilitatorSince", "affiliationsContainer", "programStatus"] @@ -68,11 +69,7 @@ export default class extends Controller { } } - // Mirrors Affiliation#facilitator?: exact, case-sensitive, trimmed — so the - // live figure matches the server render. - const facilitatorAffiliations = affiliations.filter(a => - a.title.trim() === "Facilitator" - ) + const facilitatorAffiliations = affiliations.filter(a => isFacilitatorTitle(a.title)) const facStartDates = facilitatorAffiliations.map(a => a.startDate).filter(Boolean) const facilitatorSince = facStartDates.length ? new Date(Math.min(...facStartDates.map(d => new Date(d)))) diff --git a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js index 702edbf6a8..4260502dd9 100644 --- a/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js +++ b/app/frontend/javascript/controllers/affiliation_facilitator_warning_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus"; +import { isFacilitatorTitle } from "../lib/affiliation"; // Connects to data-controller="affiliation-facilitator-warning" // @@ -65,8 +66,7 @@ export default class extends Controller { startDate, endDate, destroyed, - // Mirror Affiliation#facilitator?: exact, case-sensitive "Facilitator" (trimmed). - facilitator: title.trim() === "Facilitator", + facilitator: isFacilitatorTitle(title), }; } diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 5dad1705e6..3083aeff31 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus"; +import { isFacilitatorTitle } from "../lib/affiliation"; // Live styling for the affiliation editor row as you edit, before saving. Four // states by colour: role is the hue (facilitator = purple, else blue) and status @@ -92,9 +93,7 @@ export default class extends Controller { return this.expiredValue; } - // Mirror Affiliation#facilitator? — an exact, case-sensitive match on - // "Facilitator" (trimmed), so the live styling matches what the server renders. isFacilitator() { - return this.hasTitleTarget && this.titleTarget.value.trim() === "Facilitator"; + return this.hasTitleTarget && isFacilitatorTitle(this.titleTarget.value); } } diff --git a/app/frontend/javascript/lib/affiliation.js b/app/frontend/javascript/lib/affiliation.js new file mode 100644 index 0000000000..5bf12fbe26 --- /dev/null +++ b/app/frontend/javascript/lib/affiliation.js @@ -0,0 +1,9 @@ +// The single JS source of truth for "is this the standing Facilitator +// affiliation?", mirroring the server's title rule (Affiliation derives its STI +// type from this): the title must be *exactly* "Facilitator" (trimmed, +// case-sensitive). Variants like "Lead Facilitator" or "facilitator" are +// deliberately excluded. The affiliation editors drive their live preview off the +// typed title, so they compare the input value through this helper. +export const facilitatorTitle = "Facilitator" + +export const isFacilitatorTitle = (title) => (title ?? "").trim() === facilitatorTitle diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 6f780a6218..adad8ab057 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -48,11 +48,10 @@ class Affiliation < ApplicationRecord .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date) } - # Only the exact, case-sensitive title "Facilitator" counts — variants like - # "Lead Facilitator" or "facilitator" are deliberately excluded. BINARY forces - # a case-sensitive comparison under MySQL's default case-insensitive collation; - # TRIM mirrors the in-memory #facilitator? strip so stray whitespace still matches. - scope :facilitators, -> { where("BINARY TRIM(title) = ?", "Facilitator") } + # STI: facilitator affiliations are the FacilitatorAffiliation subtype, which + # #set_type_from_title assigns whenever the title is exactly "Facilitator" + # (trimmed, case-sensitive). An executable agreement spec locks type to that rule. + scope :facilitators, -> { where(type: FacilitatorAffiliation.name) } # Affiliations whose #status_on(date) equals the given status, expressed in SQL # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with @@ -77,6 +76,7 @@ class Affiliation < ApplicationRecord end } + before_validation :set_type_from_title before_validation :skip_if_duplicate # Runs before validation so a reassigned org drops its stale organization_address_id # before organization_address_belongs_to_organization would reject it. @@ -87,13 +87,27 @@ class Affiliation < ApplicationRecord after_destroy :sync_organization_status_with_affiliations after_destroy :sync_organization_affiliation_dates + # Both STI subtypes authorize through the one AffiliationPolicy — ActionPolicy's + # class-policy_class resolver picks this up before it would fail to infer a + # FacilitatorAffiliationPolicy / JobAffiliationPolicy. + def self.policy_class + AffiliationPolicy + end + + # STI subtypes share Affiliation's routes, form param key, and dom_ids — the app + # treats them uniformly as "affiliation" (AffiliationsController#params.require(:affiliation), + # dom_id anchors like affiliation_123, affiliation_path). Without this, url_for / + # dom_id would derive facilitator_affiliation_* and break those. + def self.model_name + @_affiliation_model_name ||= ActiveModel::Name.new(Affiliation) + end + # Methods - # A facilitator affiliation is one whose title is *exactly* "Facilitator" - # (trimmed, case-sensitive). Variants like "Lead Facilitator" or "facilitator" - # are deliberately excluded. Mirrors the .facilitators scope so in-memory and - # SQL checks agree. + # True for the FacilitatorAffiliation subtype. Reads the STI type column rather + # than #is_a? so it's correct even on a base-built instance whose type was just + # assigned by #set_type_from_title but not yet reloaded into its subclass. def facilitator? - title.to_s.strip == "Facilitator" + type == FacilitatorAffiliation.name end # Current: not flagged inactive and not past its end date. Mirrors the `active` @@ -168,6 +182,15 @@ def set_inactive_from_dates self.inactive = end_date.present? && end_date < Date.current end + # The title is the single source of truth for the STI subtype: exactly + # "Facilitator" (trimmed, case-sensitive) is a FacilitatorAffiliation, anything + # else (including blank) is a JobAffiliation, the default. Runs on every save so + # a retitle re-types the row. Invariant: never write `title` via update_columns / + # update_all — that skips this callback and lets type drift from the title. + def set_type_from_title + self.type = title.to_s.strip == FACILITATOR_TITLE ? FacilitatorAffiliation.name : JobAffiliation.name + end + def sync_organization_affiliation_dates org = organization affiliations = org.affiliations.where.not(id: destroyed_by_association ? id : nil) diff --git a/app/models/facilitator_affiliation.rb b/app/models/facilitator_affiliation.rb new file mode 100644 index 0000000000..7514c98861 --- /dev/null +++ b/app/models/facilitator_affiliation.rb @@ -0,0 +1,6 @@ +# STI subclass for the standing "Facilitator" affiliation — the one that confers +# AWBW Art Program status on an organization. Affiliation assigns this type from +# the title (exactly "Facilitator") in a before_validation, so the type always +# tracks the title; see Affiliation#set_type_from_title. +class FacilitatorAffiliation < Affiliation +end diff --git a/app/models/job_affiliation.rb b/app/models/job_affiliation.rb new file mode 100644 index 0000000000..5c9cd1607f --- /dev/null +++ b/app/models/job_affiliation.rb @@ -0,0 +1,5 @@ +# STI subclass for a person's role/job at an organization (any title other than +# exactly "Facilitator"). This is the default affiliation type; Affiliation +# assigns it from the title in a before_validation. See Affiliation#set_type_from_title. +class JobAffiliation < Affiliation +end diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index f103336854..64ec826b1b 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -13,6 +13,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..1e04799c6b --- /dev/null +++ b/app/services/analytics/resource_history.rb @@ -0,0 +1,117 @@ +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: resource_types, resource_id: @record.id) + .includes(user: :person) + .order(time: :desc) + .limit(@limit) + end + + # STI subtypes share a table, so events for one row can be filed under the base + # class or any subtype — `Affiliation` before the subtypes existed, and a + # different subtype for anything recorded before a retitle re-typed the row. + # Matching only `@record.class.name` would silently drop that earlier history. + # + # `descendants` covers the retitle case only for subclasses that are loaded, + # which is always true under eager loading and may not be in a lazy-loading + # dev/test process. The base and current names are matched unconditionally. + def resource_types + klass = @record.class + base = klass.respond_to?(:base_class) ? klass.base_class : klass + + ([ klass.name, base.name ] + base.subclasses.map(&:name)).uniq + 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..f376a337fe --- /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.date_range %> + <% 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 54471cfef7..9e30009365 100644 --- a/app/views/affiliations/edit.html.erb +++ b/app/views/affiliations/edit.html.erb @@ -167,5 +167,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 edf690898f..a56df650a5 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -228,8 +228,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 7ac4a1cf71..3a730b4f6a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -226,6 +226,7 @@ get :checkout get :bio get :all_comments + get :affiliation_history end resources :comments, only: [ :create, :update ] resources :memberships, only: [ :index, :new, :create ] diff --git a/db/migrate/20260819134257_add_type_to_affiliations.rb b/db/migrate/20260819134257_add_type_to_affiliations.rb new file mode 100644 index 0000000000..93ff591abe --- /dev/null +++ b/db/migrate/20260819134257_add_type_to_affiliations.rb @@ -0,0 +1,20 @@ +class AddTypeToAffiliations < ActiveRecord::Migration[8.1] + # STI discriminator: FacilitatorAffiliation vs JobAffiliation. Affiliation derives + # the type from the title on save (JobAffiliation is the default for any non- + # "Facilitator" title). Intentionally nullable with NO column default: a default + # subclass name would make Affiliation.new instantiate that subclass, so a row + # the callback then re-types would raise RecordNotFound on reload. Schema only — + # existing rows are typed by the affiliations:backfill_facilitator rake task + # after deploy (see lib/tasks); app-created rows always get a type via the callback. + def up + return if column_exists?(:affiliations, :type) + + add_column :affiliations, :type, :string + add_index :affiliations, :type + end + + def down + remove_index :affiliations, :type, if_exists: true + remove_column :affiliations, :type, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8674e05fdb..5a6a8fa71e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_17_115845) do +ActiveRecord::Schema[8.1].define(version: 2026_08_19_134257) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -118,6 +118,7 @@ t.boolean "primary_contact", default: false, null: false t.date "start_date" t.string "title" + t.string "type" t.datetime "updated_at", precision: nil, null: false t.integer "user_id" t.index ["event_registration_id"], name: "index_affiliations_on_event_registration_id" @@ -125,6 +126,7 @@ t.index ["organization_agency_id"], name: "index_affiliations_on_organization_agency_id" t.index ["organization_id"], name: "index_affiliations_on_organization_id" t.index ["person_id"], name: "index_affiliations_on_person_id" + t.index ["type"], name: "index_affiliations_on_type" t.index ["user_id"], name: "index_affiliations_on_user_id" end diff --git a/lib/tasks/backfill_affiliation_facilitator.rake b/lib/tasks/backfill_affiliation_facilitator.rake new file mode 100644 index 0000000000..708adc435c --- /dev/null +++ b/lib/tasks/backfill_affiliation_facilitator.rake @@ -0,0 +1,14 @@ +namespace :affiliations do + desc "Backfill affiliation STI type from the title (one-off, post-deploy)" + task backfill_facilitator: :environment do + # Type every existing row from its title, matching Affiliation#set_type_from_title: + # exactly "Facilitator" (trimmed, case-sensitive) is a FacilitatorAffiliation, + # everything else a JobAffiliation. update_all is deliberate — the value is + # computed inline, so no per-row callback is needed. + facilitators = Affiliation.where("BINARY TRIM(title) = ?", "Facilitator") + .update_all(type: "FacilitatorAffiliation") + jobs = Affiliation.where("BINARY TRIM(title) <> ? OR title IS NULL", "Facilitator") + .update_all(type: "JobAffiliation") + puts "Typed #{facilitators} FacilitatorAffiliation(s) and #{jobs} JobAffiliation(s)." + end +end diff --git a/spec/decorators/affiliation_decorator_spec.rb b/spec/decorators/affiliation_decorator_spec.rb new file mode 100644 index 0000000000..bb67da60bd --- /dev/null +++ b/spec/decorators/affiliation_decorator_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe AffiliationDecorator do + describe "#date_range" do + it "reads 'present' when there is no end date" do + affiliation = build(:affiliation, start_date: Date.new(2026, 10, 13), end_date: nil) + + expect(affiliation.decorate.date_range).to eq("Oct 13, 2026 – present") + end + + it "shows both dates when the affiliation has ended" do + affiliation = build(:affiliation, start_date: Date.new(2026, 10, 13), end_date: Date.new(2026, 10, 13)) + + expect(affiliation.decorate.date_range).to eq("Oct 13, 2026 – Oct 13, 2026") + end + + it "reads 'no start date' when the start date is unset" do + affiliation = build(:affiliation, start_date: nil, end_date: nil) + + expect(affiliation.decorate.date_range).to eq("no start date – present") + end + end +end diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb index fa1ff6a1a2..1f90f48721 100644 --- a/spec/models/affiliation_spec.rb +++ b/spec/models/affiliation_spec.rb @@ -118,26 +118,65 @@ end end - describe '#facilitator?' do + describe '#facilitator? (STI type derived from title on validation)' do + # #facilitator? reads the STI type column, which set_type_from_title assigns in + # before_validation — so validate before reading it. + def facilitator_flag(title) + build(:affiliation, title: title).tap(&:validate).facilitator? + end + it 'is true for the exact title "Facilitator"' do - expect(build(:affiliation, title: "Facilitator").facilitator?).to be true + expect(facilitator_flag("Facilitator")).to be true end it 'ignores surrounding whitespace' do - expect(build(:affiliation, title: " Facilitator ").facilitator?).to be true + expect(facilitator_flag(" Facilitator ")).to be true end it 'is false for title variants like "Lead Facilitator"' do - expect(build(:affiliation, title: "Lead Facilitator").facilitator?).to be false + expect(facilitator_flag("Lead Facilitator")).to be false end it 'is case-sensitive' do - expect(build(:affiliation, title: "facilitator").facilitator?).to be false - expect(build(:affiliation, title: "FACILITATOR").facilitator?).to be false + expect(facilitator_flag("facilitator")).to be false + expect(facilitator_flag("FACILITATOR")).to be false end it 'is false when the title is blank' do - expect(build(:affiliation, title: nil).facilitator?).to be false + expect(facilitator_flag(nil)).to be false + end + + it 're-types the row when retitled to or from "Facilitator"' do + affiliation = create(:affiliation, title: "Facilitator") + expect(described_class.find(affiliation.id)).to be_a(FacilitatorAffiliation) + + affiliation.update!(title: "Lead Facilitator") + expect(described_class.find(affiliation.id)).to be_a(JobAffiliation) + expect(affiliation.reload.facilitator?).to be false + + affiliation.update!(title: "Facilitator") + expect(described_class.find(affiliation.id)).to be_a(FacilitatorAffiliation) + expect(affiliation.reload.facilitator?).to be true + end + end + + describe 'STI subtypes' do + it 'loads a "Facilitator"-titled row as FacilitatorAffiliation and others as JobAffiliation' do + facilitator = create(:affiliation, title: "Facilitator") + job = create(:affiliation, title: "Volunteer") + + expect(described_class.find(facilitator.id)).to be_a(FacilitatorAffiliation) + expect(described_class.find(job.id)).to be_a(JobAffiliation) + end + + it 'defaults an untitled row to JobAffiliation' do + affiliation = create(:affiliation, title: nil) + expect(described_class.find(affiliation.id)).to be_a(JobAffiliation) + end + + it 'authorizes both subtypes through AffiliationPolicy' do + expect(FacilitatorAffiliation.policy_class).to eq(AffiliationPolicy) + expect(JobAffiliation.policy_class).to eq(AffiliationPolicy) end end @@ -148,7 +187,19 @@ let!(:lowercase) { create(:affiliation, title: "facilitator") } it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do - expect(described_class.facilitators).to contain_exactly(exact, whitespace) + expect(described_class.facilitators.ids).to contain_exactly(exact.id, whitespace.id) + end + + it 'returns exactly the rows whose title matches the rule (type ↔ scope agree)' do + expected = described_class.all.select { |a| a.title.to_s.strip == "Facilitator" }.map(&:id).sort + expect(described_class.facilitators.ids.sort).to eq(expected) + end + + it 'keeps the persisted STI type in step with the title rule' do + described_class.find_each do |affiliation| + expected_type = affiliation.title.to_s.strip == "Facilitator" ? "FacilitatorAffiliation" : "JobAffiliation" + expect(affiliation.type).to eq(expected_type) + end end end 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..d6c39cdf1e --- /dev/null +++ b/spec/services/analytics/affiliation_timeline_spec.rb @@ -0,0 +1,130 @@ +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 + + # STI: the row is a FacilitatorAffiliation, but its earlier events may be filed + # under the base class or the other subtype. + describe "history filed under another STI type" do + it "still finds events recorded as the base Affiliation class" do + ahoy_event(name: "update.affiliation", time: 1.day.ago, + properties: { "changes" => { "title" => { "before" => "Volunteer", "after" => "Facilitator" } } }) + + entries = described_class.new(affiliation.reload).entries + + expect(entries.map(&:kind)).to include(:change) + expect(entries.first.record.changes.map(&:label)).to eq([ "Title" ]) + end + + it "finds events recorded under the other subtype, from before a retitle" do + Ahoy::Event.create!(name: "update.affiliation", time: 2.days.ago, visit: create(:ahoy_visit), + resource_type: JobAffiliation.name, resource_id: affiliation.id, + properties: { "changes" => { "title" => { "before" => "Counselor", "after" => "Facilitator" } } }) + + expect(described_class.new(affiliation.reload).entries.map(&:kind)).to include(:change) + 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 a38789da02..eb500059a4 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-white", + "app/views/people/affiliation_history.html.erb" => "admin-only bg-blue-100", "app/views/comments/index.html.erb" => "admin-only bg-white", "app/views/bookmarks/index.html.erb" => "admin-only bg-blue-100", "app/views/categories/index.html.erb" => "admin-only bg-blue-100",