<% if @people.any? %>
- | Name |
- Affiliated since |
- Primary sector |
- Primary age range |
- Affiliation(s) |
- Socials |
+ Name |
+ Affiliated since |
+ Primary sector |
+ Primary age range |
+ Affiliation(s) |
+ Socials |
<% if allowed_to?(:manage?, Person) %>
- Actions |
+ Actions |
<% end %>
<% @people.each do |person| %>
<% cache [ person, current_user.super_user?, current_user.person_id == person.id ] do %>
- ">
+
">
|
<% show_email = person.profile_show_email? || allowed_to?(:manage?, Person) %>
<% if allowed_to?(:show?, person) %>
@@ -68,7 +68,7 @@
|
- <% affiliations = person.affiliations.select { |a| a.organization.present? && !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) } %>
+ <% affiliations = person.affiliations.select { |a| a.organization.present? && a.active? } %>
<% if affiliations.any? %>
<%# Org names are long and multi-word, so a stacked list of
truncated links reads better than chips, which squeeze the
@@ -107,12 +107,12 @@
<% end %>
|
-
+ |
<%= render "social_media_buttons", person: person %>
|
<% if allowed_to?(:manage?, Person) %>
-
+ |
<%= link_to "User", user_path(person.user),
data: { turbo_frame: "_top" },
class: "admin-only bg-blue-100 btn btn-secondary-outline px-2.5 py-1 text-xs" if person.user %>
@@ -128,7 +128,7 @@
|
<% else %>
-
+
No <%= Person.model_name.human.pluralize.downcase %> found.
<% end %>
diff --git a/app/views/shared/_affiliation_organization_buttons.html.erb b/app/views/shared/_affiliation_organization_buttons.html.erb
index c6f79a829c..3c59fb0dce 100644
--- a/app/views/shared/_affiliation_organization_buttons.html.erb
+++ b/app/views/shared/_affiliation_organization_buttons.html.erb
@@ -5,7 +5,7 @@
<% include_inactive = local_assigns.fetch(:include_inactive, false) %>
<% all_affiliations = affiliations.select { |a| a.organization.present? } %>
<% active_affiliations = all_affiliations
- .select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }
+ .select(&:active?)
.sort_by { |a| a.organization.name.to_s.downcase } %>
<% inactive_affiliations = include_inactive ?
(all_affiliations - active_affiliations).sort_by { |a| a.organization.name.to_s.downcase } : [] %>
diff --git a/app/views/shared/_affiliation_person_buttons.html.erb b/app/views/shared/_affiliation_person_buttons.html.erb
index 2942b1a72a..8b985aa073 100644
--- a/app/views/shared/_affiliation_person_buttons.html.erb
+++ b/app/views/shared/_affiliation_person_buttons.html.erb
@@ -5,7 +5,7 @@
<% include_inactive = local_assigns.fetch(:include_inactive, false) %>
<% all_affiliations = affiliations.select { |a| a.person.present? } %>
<% active_affiliations = all_affiliations
- .select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }
+ .select(&:active?)
.sort_by { |a| [a.person.first_name.to_s.downcase, a.person.last_name.to_s.downcase] } %>
<% inactive_affiliations = include_inactive ?
(all_affiliations - active_affiliations).sort_by { |a| [a.person.first_name.to_s.downcase, a.person.last_name.to_s.downcase] } : [] %>
diff --git a/config/features.yml b/config/features.yml
index 157f3473c9..056b1dfebf 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -277,6 +277,19 @@
pro_tips:
- "Super-admins can edit any feature in place (rich descriptions with screenshots) and click \"Sync latest updates\" to pull in newly-shipped ones."
+- name: "Reconcile affiliations after a training"
+ area: events
+ display_status: admin_facing
+ released_on: 2026-08-18
+ action_path: "/events/1/reconcile_affiliations"
+ pr_number: 2195
+ summary: >-
+ A bulk action on an event that brings facilitator affiliations in line with who
+ attended — creating missing ones, ending them for people who didn't attend, and
+ reactivating anyone later marked attended. Preview every change before applying it.
+ pro_tips:
+ - "Job affiliations are never touched, and any row can be left as-is."
+
- name: "Edit an affiliation's details and comments"
area: people
display_status: admin_facing
@@ -1792,3 +1805,18 @@
- Transferring someone a second time collapses the chain — the new
registration points straight at the original source and the middle stop is
removed.
+
+- name: "Data health checks for admins"
+ area: reporting
+ display_status: admin_facing
+ released_on: 2026-08-21
+ action_path: "/admin/data_health"
+ summary: >-
+ A page listing consistency checks that span every record rather than one page
+ at a time — each shows a count, the rows it found, and a button to fix them
+ where a correct fix exists.
+ pro_tips:
+ - "Checks with nothing to report stay quiet, so an empty page is the healthy state."
+ - >-
+ Not every check can be fixed automatically. Ones marked "Review by hand"
+ report only, because the right answer is a judgement call rather than a rule.
diff --git a/config/routes.rb b/config/routes.rb
index b4ad751b3b..76f400c7cc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -53,6 +53,8 @@
get "activities/charts", to: "ahoy_activities#charts", as: "activities_charts"
get "activities/counts", to: "analytics#index", as: "activities_counts"
post "activities/counts/print", to: "analytics#print", as: "analytics_print"
+ get "data_health", to: "data_health#index", as: "data_health"
+ post "data_health/:check/repair", to: "data_health#repair", as: "data_health_repair"
end
resources :comments, only: [ :index ]
@@ -203,6 +205,9 @@
get :recipients
post :feature_recipient_shoutout
get :bulk_payments, to: "events/bulk_payments#index"
+ get :reconcile_affiliations, to: "events/reconcile_affiliations#index"
+ post :reconcile_affiliations, to: "events/reconcile_affiliations#confirm"
+ post :perform_reconcile_affiliations, to: "events/reconcile_affiliations#create"
get :preview_reminder
patch :preview
post :copy_registration_form
diff --git a/db/migrate/20260814044207_add_affiliations_reconciled_at_to_events.rb b/db/migrate/20260814044207_add_affiliations_reconciled_at_to_events.rb
new file mode 100644
index 0000000000..4f25e87ba2
--- /dev/null
+++ b/db/migrate/20260814044207_add_affiliations_reconciled_at_to_events.rb
@@ -0,0 +1,11 @@
+class AddAffiliationsReconciledAtToEvents < ActiveRecord::Migration[8.1]
+ def up
+ unless column_exists?(:events, :affiliations_reconciled_at)
+ add_column :events, :affiliations_reconciled_at, :datetime, null: true
+ end
+ end
+
+ def down
+ remove_column :events, :affiliations_reconciled_at, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1581f5f647..d724f095b3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -547,6 +547,7 @@
create_table "events", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.string "abbreviation"
+ t.datetime "affiliations_reconciled_at"
t.boolean "autoshow_cost", default: true, null: false
t.boolean "autoshow_date", default: true, null: false
t.boolean "autoshow_location", default: true, null: false
diff --git a/db/seeds/dev/affiliation_history.rb b/db/seeds/dev/affiliation_history.rb
new file mode 100644
index 0000000000..c4604f64c2
--- /dev/null
+++ b/db/seeds/dev/affiliation_history.rb
@@ -0,0 +1,184 @@
+# Several years of interleaved history for one person — trainings, memberships and
+# affiliation edits — so the person History card and the admin activity timeline have
+# something realistic to render. Targets the owner of the first affiliation.
+#
+# Ahoy lifecycle events are written directly rather than letting AhoyTrackable fire
+# them, because the whole point is timestamps spread over past years.
+
+affiliation = Affiliation.order(:id).first
+
+if affiliation.nil?
+ puts "Skipping affiliation history seed: no affiliations. Run db:seed:dev first."
+elsif Ahoy::Event.where(resource_type: "Affiliation", resource_id: affiliation.id).where("time < ?", 1.year.ago).exists?
+ puts "Skipping affiliation history seed (already seeded)"
+else
+ person = affiliation.person
+ actor = person&.user || User.where(super_user: true).first
+
+ if person.nil? || actor.nil?
+ puts "Skipping affiliation history seed: affiliation ##{affiliation.id} has no person or no admin user."
+ else
+ puts "Building #{person.full_name}'s history around affiliation ##{affiliation.id}…"
+
+ home_org = affiliation.organization
+ second_org = Organization.where.not(id: home_org&.id).order(:id).first || home_org
+
+ visits = Hash.new do |cache, year|
+ cache[year] = Ahoy::Visit.create!(
+ visit_token: SecureRandom.uuid, visitor_token: SecureRandom.uuid, user: actor,
+ started_at: Time.zone.local(year, 6, 1, 9, 0), browser: "Chrome", device_type: "Desktop",
+ city: "Los Angeles", country: "US", landing_page: "/people/#{person.id}/edit"
+ )
+ end
+
+ track = ->(action, record, at, extra = {}) do
+ Ahoy::Event.create!(
+ visit: visits[at.year],
+ user: actor,
+ name: "#{action}.#{record.class.table_name.singularize}",
+ resource_type: record.class.name,
+ resource_id: record.id,
+ properties: {
+ resource_type: record.class.name,
+ resource_id: record.id,
+ resource_title: (record.try(:title).presence || record.try(:name).presence || record.id).to_s
+ }.merge(extra),
+ time: at
+ )
+ end
+
+ changed = ->(pairs) { { changes: pairs.transform_values { |(before, after)| { before: before, after: after } } } }
+
+ # Comments reach the person's History through PersonCommentAggregator, so they
+ # only show when left on the person or a record that hangs off them.
+ note = ->(subject, body, at, topic: nil) do
+ comment = subject.comments.create!(body: body, topic: topic, created_by: actor, updated_by: actor)
+ comment.update_columns(created_at: at, updated_at: at)
+ track.("create", comment, at, { resource_title: body.truncate(60) })
+ comment
+ end
+
+ training = ->(title, starts_on, status, organization) do
+ event = Event.create!(
+ title: title,
+ description: "Two-day facilitator training.",
+ start_date: starts_on.to_time(:utc) + 9.hours,
+ end_date: starts_on.to_time(:utc) + 1.day + 16.hours,
+ registration_close_date: starts_on.to_time(:utc) - 1.week,
+ facilitator_training: true,
+ published: true,
+ created_by: actor,
+ cost_cents: 25_000
+ )
+ registration = EventRegistration.create!(event: event, registrant: person, status: status)
+ EventRegistrationOrganization.create!(event_registration: registration, organization: organization)
+ registration.update_columns(created_at: starts_on - 6.weeks, updated_at: starts_on + 3.days)
+
+ track.("create", registration, starts_on - 6.weeks, { resource_title: title })
+ track.("update", registration, starts_on + 3.days,
+ { resource_title: title }.merge(changed.({ "status" => [ "registered", status ] })))
+ registration
+ end
+
+ email = ->(subject, body, at, kind: "manual_log") do
+ Notification.create!(
+ kind: kind, notification_type: 0,
+ channel: "email", direction: "outgoing", recipient_role: "person",
+ recipient_email: person.communications_email, email_subject: subject, email_body_text: body,
+ sender: actor, delivered_at: at
+ ).update_columns(created_at: at, updated_at: at)
+ end
+
+ year = ->(n) { Date.current - n.years }
+
+ # ── 7 years ago: first training, becomes a facilitator ───────────────────
+ first_registration = training.("Facilitator Training: Foundations", year.(7), "attended", second_org)
+ note.(first_registration, "Travelled in from out of state; covered by a partial scholarship.",
+ year.(7) + 1.day, topic: "Registration")
+ email.("Welcome to the AWBW facilitator community",
+ "Congratulations on completing your facilitator training.", year.(7) + 3.days)
+
+ first_facilitator = Affiliation.create!(
+ person: person, organization: second_org, title: "Facilitator", start_date: year.(7) + 2.days
+ )
+ track.("create", first_facilitator, year.(7) + 2.days)
+ note.(first_facilitator, "Minted from the Foundations training roster.", year.(7) + 2.days)
+
+ # ── 6 years ago: first membership year, paid ─────────────────────────────
+ subscription = person.memberships.create!
+ subscription.update_columns(created_at: year.(6), updated_at: year.(6))
+ track.("create", subscription, year.(6), { resource_title: "Membership" })
+
+ [ 6, 4, 3, 0 ].each_with_index do |years_ago, index|
+ invoice = subscription.membership_invoices.create!(
+ start_date: year.(years_ago), cost_cents: Membership::ANNUAL_COST_CENTS
+ )
+ invoice.update_columns(created_at: year.(years_ago), updated_at: year.(years_ago))
+
+ # MembershipInvoice isn't one of the person's tracked resources, so the renewal
+ # shows as an update to the membership itself.
+ unless index.zero?
+ track.("update", subscription, year.(years_ago),
+ { resource_title: "Membership" }.merge(changed.({ "membership_invoices" => [ index, index + 1 ] })))
+ end
+
+ next if index == 3 # current year left unpaid so the badge shows something owing
+
+ paid_at = year.(years_ago) + (index == 2 ? 70 : 9).days
+ payment = CashPayment.create!(
+ person: person, amount_cents: Membership::ANNUAL_COST_CENTS,
+ amount_cents_remaining: Membership::ANNUAL_COST_CENTS, currency: "usd"
+ )
+ payment.update_columns(created_at: paid_at, updated_at: paid_at)
+ Allocation.create!(source: payment, allocatable: invoice, amount: Membership::ANNUAL_COST_CENTS)
+ track.("create", payment, paid_at, { resource_title: "Membership dues #{year.(years_ago).year}" })
+ end
+
+ # ── 5 years ago: takes on a job title alongside the facilitator row ──────
+ job = Affiliation.create!(person: person, organization: second_org, title: "Program Coordinator")
+ track.("create", job, year.(5))
+ note.(job, "Took on the Program Coordinator role alongside facilitating.", year.(5))
+ note.(person, "Promoted internally — worth checking which affiliation should be primary.",
+ year.(5) + 2.days, topic: "Profile")
+
+ # ── 4 years ago: signs up for a refresher and doesn't show ───────────────
+ no_show_registration = training.("Facilitator Training: Refresher", year.(4), "no_show", second_org)
+ note.(no_show_registration, "Called the morning of to say they couldn't make it.",
+ year.(4) + 1.day, topic: "Attendance")
+ first_facilitator.update_columns(end_date: first_facilitator.start_date, inactive: true)
+ track.("update", first_facilitator, year.(4) + 5.days,
+ changed.({ "end_date" => [ nil, first_facilitator.start_date.to_s ], "inactive" => [ false, true ] }))
+ note.(first_facilitator, "Ended after the refresher no-show; reinstate if they complete a later training.",
+ year.(4) + 5.days)
+
+ # ── 2 years ago: completes a training again, affiliation comes back ──────
+ return_registration = training.("Facilitator Training: Trauma-Informed Practice", year.(2), "attended", second_org)
+ note.(return_registration, "Back after two years away; asked about co-facilitating.",
+ year.(2) + 1.day, topic: "Attendance")
+ first_facilitator.update_columns(end_date: nil, inactive: false)
+ track.("update", first_facilitator, year.(2) + 4.days,
+ changed.({ "end_date" => [ first_facilitator.start_date.to_s, nil ], "inactive" => [ true, false ] }))
+ note.(first_facilitator, "Reactivated after the Trauma-Informed Practice training.", year.(2) + 4.days)
+ email.("Your facilitator affiliation is active again",
+ "We've reactivated your facilitator affiliation following the training.", year.(2) + 4.days)
+
+ # ── 1 year ago onward: edits to the affiliation this seed hangs off ──────
+ track.("create", affiliation, affiliation.start_date.to_time + 10.hours)
+ note.(affiliation, "Joined the #{home_org&.name} roster.", affiliation.start_date.to_time + 10.hours)
+
+ track.("update", affiliation, 8.months.ago,
+ changed.({ "title" => [ "Facilitator", affiliation.title ] }))
+ track.("update", affiliation, 5.months.ago,
+ changed.({ "primary_contact" => [ false, true ] }))
+ note.(affiliation, "Now the primary contact for the organization.", 5.months.ago)
+ track.("update", affiliation, 2.months.ago,
+ changed.({ "start_date" => [ (affiliation.start_date + 1.month).to_s, affiliation.start_date.to_s ] }))
+ note.(affiliation, "Corrected the start date against the training roster.", 2.months.ago)
+ note.(person, "Confirmed the corrected dates by phone.", 6.weeks.ago, topic: "Profile")
+
+ puts " #{person.full_name}: #{person.event_registrations.count} registrations, " \
+ "#{person.affiliations.count} affiliations, #{subscription.membership_invoices.count} membership years, " \
+ "#{PersonCommentAggregator.new(person).comments.count} comments, " \
+ "#{Analytics::PersonActivityEvents.new(person).count} activity events"
+ end
+end
diff --git a/docs/adr/0001-organization-affiliation-and-program-status.md b/docs/adr/0001-organization-affiliation-and-program-status.md
index 8fc97cb27a..a822f06350 100644
--- a/docs/adr/0001-organization-affiliation-and-program-status.md
+++ b/docs/adr/0001-organization-affiliation-and-program-status.md
@@ -23,7 +23,10 @@ decisions that resolve the ambiguities so they're written down once.
- **Affiliation** — an Org ↔ Person link (`affiliations` table) with `title`,
`start_date`, `end_date`, and a cached `inactive` flag. **Not tied to any
- event** (there is no `event_id` on an affiliation).
+ event** (there is no `event_id` on an affiliation). **Refined by
+ [ADR-0002](0002-affiliations-as-the-record-of-two-relationships.md) D2a:** still no
+ `event_id`, but there is now an `event_registration_id` recording which
+ registration minted the row.
- **Facilitator affiliation** — an affiliation whose `title` is **exactly
`"Facilitator"`** (trimmed, case-sensitive). No fuzzy/`LIKE` matching; "Lead
Facilitator" and "facilitator" do **not** count. See `Affiliation#facilitator?`
@@ -32,6 +35,9 @@ decisions that resolve the ambiguities so they're written down once.
`>= today`). `inactive` is a cached column derived from the dates on save
(`set_inactive_from_dates`: `inactive = end_date.present? && end_date < today`),
so in practice "active" reduces to **no end date, or end date ≥ today**.
+ **Superseded by [ADR-0002](0002-affiliations-as-the-record-of-two-relationships.md)
+ D2:** `inactive` is now an override that can end a row the dates still call
+ active, so "active" no longer reduces to the dates.
- **Facilitator-training event** — `events.facilitator_training == true`. The
only events for which per-event program status is meaningful.
@@ -208,7 +214,9 @@ coincide when no organization attended twice.
- **Strict `<`** for "earlier": `start_date == anchor` is **not** earlier (so the
affiliation a training mints is **New**, not Ongoing).
-- **Active-at-date** uses `end_date IS NULL OR end_date >= anchor`.
+- **Active-at-date** uses `end_date IS NULL OR end_date >= anchor`. Spelled
+ `Affiliation.active_by_date_on(date)` since ADR-0002 D3 — the `historical` in the
+ name marks it as the dates-only reader.
## Notes / open items
diff --git a/docs/adr/0002-affiliations-as-the-record-of-two-relationships.md b/docs/adr/0002-affiliations-as-the-record-of-two-relationships.md
new file mode 100644
index 0000000000..e0ef58b486
--- /dev/null
+++ b/docs/adr/0002-affiliations-as-the-record-of-two-relationships.md
@@ -0,0 +1,255 @@
+# ADR-0002 — Affiliations record two relationships, and only one of them is the art program
+
+- **Status:** Accepted
+- **Date:** 2026-08-19
+- **Extends:** [ADR-0001](0001-organization-affiliation-and-program-status.md) (supersedes its
+ "Active affiliation" vocabulary entry — see D2 below)
+
+## Context
+
+ADR-0001 pinned how program status is computed. It left two things implicit that
+have since caused real bugs:
+
+1. **`affiliations` carries two different relationships in one table**, and only
+ one of them says anything about the art program. Code that reads "the person's
+ affiliations with this org" without saying which kind it means has been wrong
+ more than once.
+2. **Two different questions get asked of the same rows** — "what was true on
+ date X" and "what is true now" — and they need different inputs. ADR-0001
+ described `inactive` as a cache of the dates, which made the two look
+ interchangeable. They aren't, and reconciliation broke the distinction: ending
+ a no-show's affiliation retroactively changed an organization's program status
+ at trainings years earlier.
+
+This ADR names the two relationships, splits the two questions, and writes down
+what has to be true for the annual grant figures to be trustworthy.
+
+## Decisions
+
+### D1 — One table, two relationships
+
+An `Affiliation` is a Person ↔ Organization link. Its `title` decides which of two
+relationships it records, and they are not interchangeable:
+
+- **Job affiliation** — the role the person holds at the org ("Counselor",
+ "Program Director", "Lead Facilitator"). It answers *who this person is to this
+ organization*. It carries **no** start date by default: we rarely know when they
+ took the job, and dating it to a registration would misrepresent that.
+- **Facilitator affiliation** — `title` exactly `"Facilitator"` (trimmed,
+ case-sensitive; see `Affiliation#facilitator?` and the `.facilitators` scope). It
+ answers *this organization was running an art program, staffed by this person,
+ over this period.* It is dated to the training that conferred it (ADR-0001 D8).
+
+**Only the facilitator affiliation feeds program status.** A job affiliation never
+makes an org active, never makes it Ongoing, and is never touched by
+reconciliation. One person can hold both at the same org at the same time, and
+normally does — a "Lead Facilitator" job affiliation plus a standing "Facilitator"
+one (`AffiliationServices::CreateFromRegistration`).
+
+**Being a facilitator is conferred by a training, not by attending an event.** Only
+a `facilitator_training` registration mints a facilitator affiliation; other
+org-linked registrations mint the job affiliation alone.
+
+### D2 — `inactive` is an override, not a cache
+
+ADR-0001 called `inactive` "a cached column derived from the dates on save," so
+that "active" reduced to the dates. **That is no longer true.** `inactive` is now
+an independent flag that can end an affiliation the dates still read as current:
+
+- It is still **derived** from the dates when no one says otherwise
+ (`set_inactive_from_dates`).
+- An **explicit** assignment wins — `Affiliation#inactive_supplied` marks that a
+ caller supplied the value deliberately, so a later edit to an unrelated date
+ can't quietly undo it.
+
+Why it has to exist: a one-day training that starts and ends today produces an
+affiliation whose end date is today, and `end_date >= today` reads as active. Without
+the flag a no-show would keep facilitator status for the rest of the day. The
+standalone affiliation editor exposes the same flag so an admin can end a row
+effective now without inventing a false end date.
+
+### D2a — Provenance is `event_registration_id`, and there is no `event_id`
+
+An affiliation links to the **registration** that minted it
+(`affiliations.event_registration_id`, nullable, `on_delete: :nullify`). There is
+deliberately **no `event_id`** — the event is reachable only through the
+registration.
+
+What the FK does and does not mean:
+
+- **It is the auto-vs-manual gate.** `NULL` means hand-entered or historical;
+ present means the registration flow created this row. `ReconcilePerson`'s
+ `include_unowned:` switches on exactly this, and D6's "the row this training
+ minted" is `affiliation.event_registration&.event_id == event.id`.
+- **It is NOT the completion signal.** Creation dedupes, so one affiliation can be
+ backed by several training registrations while the FK records only the *creating*
+ one. Reading completion off `affiliation.event_registration.attended?` would end a
+ returning facilitator whose first training was a no-show but who attended a later
+ one. Completion is a query across **all** of the person's facilitator-training
+ registrations for that org (`ReconcilePerson#completed_training?`).
+- **It is scoped to the current org.** Repointing an affiliation at a different
+ organization nulls it (`reset_org_scoped_links_on_org_change`), because the minting
+ registration no longer applies. Invariant: **FK present ⟺ this row was auto-minted
+ for its current organization.**
+- **It says nothing about the kind of relationship.** Both kinds of row (D1) carry
+ it — a job affiliation minted by a non-training registration has a registration
+ whose event is not a facilitator training. `event_registration.event.facilitator_training?`
+ must be checked, never assumed.
+
+Two consequences worth knowing:
+
+- **Provenance is lossy by design.** `EventRegistration has_many :affiliations,
+ dependent: :nullify` and the FK is `on_delete: :nullify`, so deleting a
+ registration leaves its affiliations standing with a `NULL` link. An auto-minted
+ row silently becomes indistinguishable from a hand-entered one, and the default
+ `include_unowned: false` gate will then spare it. That is the safe direction to
+ fail, but it means the gate is a floor, not a guarantee.
+- **The reverse lookup is cheap.** `index_affiliations_on_event_registration_id`
+ means "which affiliations did this registration mint" is an indexed read, which is
+ what lets the affiliation edit page show its minting event inline
+ (`Analytics::AffiliationTimeline`).
+
+### D3 — Two questions, two inputs
+
+| Question | Anchored on | Reads |
+|---|---|---|
+| **Historical** — "what was true on date X" | an explicit date | **dates only** |
+| **Current** — "what is true now" | now | **dates *and* the `inactive` flag** |
+
+- Historical: `FacilitatorProgramStatus` (New / Ongoing / Reinstated) and the
+ `Affiliation.active_by_date_on(date)` scope. They deliberately ignore `inactive`,
+ because the flag describes *now* and a historical answer must not move when
+ someone's status changes later.
+- Current: `Affiliation#active?`, the `.active` / `.active_or_pending` scopes, and
+ `OrganizationDecorator#organization_status_bucket`.
+
+**The corollary that cost us a bug:** because historical readers ignore the flag,
+they can only be kept honest by writing **truthful dates**. See D6.
+
+### D4 — The organization's current status: Active / Formerly active / Never active
+
+Derived purely from facilitator affiliations
+(`OrganizationDecorator#organization_status_bucket`, ADR-0001 D3):
+
+- any **active** facilitator affiliation → **Active**
+- facilitator affiliation(s) but **all ended** → **Formerly active**
+- **no** facilitator affiliation → **Never active**
+
+**Formerly active is a subset of "not active."** The index filter treats it that
+way (`Organization.program_status(:formerly_or_never)`), and any UI offering an
+active/inactive choice must fold Formerly active and Never active under inactive
+while still showing them apart — "used to run a program" and "never ran one" are
+different facts about an org and only one of them is a lapse worth chasing.
+
+The in-memory bucket and the SQL scope must agree; they are two spellings of one
+rule and are tested against each other.
+
+**The stored `organization_status` column is not an independent input.** It is
+maintained *from* the affiliations (`sync_organization_status_with_affiliations`)
+and is not consulted when computing the bucket (ADR-0001 D3/D3a). An org is active
+because someone is facilitating there, not because a column says so.
+
+### D5 — The anchor date, and what it's for
+
+Program status is one value per **(organization, anchor date)**. In event context
+the anchor is the event's `start_date`; with no event in view it falls back to
+January 1 of the current year (ADR-0001 D7).
+
+These figures back grant applications, so the property that matters is
+**stability**: asking the same question about the same past date must give the same
+answer forever, no matter what has happened to the people involved since. Two
+consequences:
+
+- Any anchor is legitimate, not just event dates. Comparing **Jan 1 vs Dec 31** of
+ a year is a supported use — it's how "what moved this year" gets answered.
+- Any write that changes an affiliation's dates is a write to the historical
+ record. It must be justified against D6.
+
+### D6 — Ending an affiliation must not erase the period it records
+
+When reconciliation ends a facilitator affiliation for someone who didn't complete
+a training, where the end date lands depends on what the row represents:
+
+- **The row this training minted** (owned by an `event_registration` for this
+ event) — same-day it: `end_date = start_date`. It recorded an *assumption* that
+ the person would become a facilitator on the training date. They didn't, so it
+ collapses to nothing. It never counted as prior history anyway (ADR-0001 D5/D8
+ use a strict `<`), so no anchored verdict moves.
+- **Any older row** — hand-entered, or minted by an earlier training — ends on
+ **this training's start date**. It records facilitation that really happened.
+ Same-daying it would delete years of history and retroactively flip the org from
+ Ongoing to Reinstated at every training in between.
+
+If an older row somehow starts *after* this training, it same-days instead; an end
+date before its own start is never written.
+
+`inactive: true` is set in both cases (D2), which is what makes the row read as
+ended today even when the end date is today.
+
+The org's *current* bucket is expected to change — that's the point. Its *anchored*
+verdicts are not.
+
+### D6a — A return after a lapse is a new row, never a reopened one
+
+When someone whose facilitator affiliation has ended completes a training for that
+organization again, reconciliation **creates a second affiliation** dated to the new
+training. It does not clear the old row's end date.
+
+Reopening it would swallow the gap: `Jan 2023 – Jan 2024, Aug 2026` collapses to
+`Jan 2023`, and the organization retroactively reads Ongoing across years it was not
+running a program. The lapse is the fact the two rows exist to record — ADR-0001 D2
+renders exactly that shape, and `CreateFromRegistration` has always minted a second
+row rather than extending an ended one (an ended facilitator affiliation does not
+block a new one).
+
+So there is no `:reactivate` action. An ended row is left alone with the reason
+"Ended — a return is recorded as a new affiliation", and the return shows up as an
+ordinary `:create`. The rule for proposing that create: the person has **no active**
+facilitator affiliation for the org, and either never had one or has completed a
+training here.
+
+This is the mirror of D6. D6 stops an ending from reaching too far back; D6a stops a
+reactivation from reaching too far forward. Both exist because the historical readers
+(D3) trust the dates.
+
+### D7 — What has to be tested
+
+The arithmetic is what the grant figures rest on, so it is covered directly rather
+than inferred from the single-affiliation cases
+(`spec/services/facilitator_program_status_math_spec.rb`):
+
+1. **Several people at one anchor** — one person still facilitating keeps the org
+ Ongoing however many others have left; Reinstated requires *every* earlier
+ person to have ended; people arriving *at* the training don't rescue a lapsed
+ program; non-facilitator titles never count.
+2. **One organization at several anchors** — Jan 1 vs Dec 31 of the same year in
+ both directions (a program starting mid-year, a program lapsing mid-year), and
+ a full new → ongoing → reinstated → ongoing walk across a lapse and a return.
+3. **Stability** — a past anchor keeps its verdict after the program later ends.
+4. **Both questions on the same org** — Ongoing at a past training while Formerly
+ active today, and vice versa.
+5. **The bucket agrees with the SQL scope** the index filter uses.
+6. **Reconciliation doesn't move an anchored verdict** — D6, both branches.
+7. **A return after a lapse adds a row and leaves the lapse intact** — D6a, asserted
+ on both the row count and the mid-gap verdict.
+
+Adding a rule here means adding a case there.
+
+## Notes / open items
+
+- **`inactive_reason` is not yet modelled.** Nothing records *why* an affiliation
+ ended — an admin's manual end date, a reconciliation after a no-show, or a
+ derivation from the dates. Worth adding as a plain string column constrained by a
+ constant if the distinction ever needs to be surfaced or filtered; deliberately
+ deferred until there's a reader for it.
+- **The public reader says "by date".** `Affiliation.active_by_date_on(date)` names
+ the input that separates it from the current-state `active?` / `.active`, and asks
+ whether **one affiliation's own period** covered that date. The organization-level
+ questions are built on top (D4 for now, `FacilitatorProgramStatus` for a date).
+ Anything new answering "as of a date" should follow the same convention.
+ `FacilitatorProgramStatus` keeps its own `active_on_anchor` — it is private to a
+ file upstream edits often, and renaming it there bought a recurring rebase
+ conflict for no call-site clarity.
+- **ADR-0001's vocabulary entry for "Active affiliation" is superseded by D2**, and
+ its note that an affiliation is "not tied to any event" is superseded by D2a — it
+ is tied to a *registration*, which is not the same thing.
diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake
index 476d7fa357..78c7e1fe2f 100644
--- a/lib/tasks/dev.rake
+++ b/lib/tasks/dev.rake
@@ -22,6 +22,7 @@ namespace :db do
payments
scholarships
membership
+ affiliation_history
bulk_payments
legacy_form_identifiers
public_forms
@@ -120,6 +121,11 @@ namespace :db do
load Rails.root.join("db/seeds/dev/membership.rb")
end
+ desc "Seed several years of trainings, memberships, comments and affiliation edits for one person (dev only)"
+ task affiliation_history: :environment do
+ load Rails.root.join("db/seeds/dev/affiliation_history.rb")
+ end
+
desc "Seed bulk payment demo submissions, payments, and allocations (dev only)"
task bulk_payments: :environment do
load Rails.root.join("db/seeds/dev/bulk_payments.rb")
diff --git a/spec/decorators/affiliation_decorator_spec.rb b/spec/decorators/affiliation_decorator_spec.rb
new file mode 100644
index 0000000000..e22e0c26d4
--- /dev/null
+++ b/spec/decorators/affiliation_decorator_spec.rb
@@ -0,0 +1,45 @@
+require "rails_helper"
+
+RSpec.describe AffiliationDecorator do
+ describe "#return_anchor" do
+ it "points at the row itself when the affiliation is active" do
+ affiliation = create(:affiliation, start_date: 1.year.ago.to_date, end_date: nil)
+
+ expect(affiliation.decorate.return_anchor).to eq("affiliation_#{affiliation.id}")
+ end
+
+ it "points at the affiliations section when the row has ended, since it sits on the Inactive tab" do
+ affiliation = create(:affiliation, start_date: 2.years.ago.to_date, end_date: 1.year.ago.to_date)
+
+ expect(affiliation.decorate.return_anchor).to eq("affiliations")
+ end
+
+ it "points at the section when the flag ended it, not the dates" do
+ affiliation = create(:affiliation, start_date: 1.year.ago.to_date, end_date: nil)
+ affiliation.inactive_supplied = true
+ affiliation.update!(inactive: true)
+
+ expect(affiliation.decorate.return_anchor).to eq("affiliations")
+ end
+ end
+
+ 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/frontend/stimulus_controller_registration_spec.rb b/spec/frontend/stimulus_controller_registration_spec.rb
new file mode 100644
index 0000000000..562b49d935
--- /dev/null
+++ b/spec/frontend/stimulus_controller_registration_spec.rb
@@ -0,0 +1,28 @@
+require "rails_helper"
+
+# Controllers are registered by hand in controllers/index.js. A file that isn't
+# listed there loads fine, renders its markup, and silently does nothing — the
+# page looks right and no test fails. This closes that gap.
+RSpec.describe "Stimulus controller registration" do
+ controllers_dir = Rails.root.join("app/frontend/javascript/controllers")
+ index = controllers_dir.join("index.js").read
+
+ files = Dir.children(controllers_dir)
+ .select { |name| name.end_with?("_controller.js") }
+ .sort
+
+ it "finds controllers to check" do
+ expect(files).not_to be_empty
+ end
+
+ files.each do |file|
+ identifier = file.delete_suffix("_controller.js").tr("_", "-")
+
+ it "registers #{identifier} from #{file}" do
+ expect(index).to include("from \"./#{file.delete_suffix('.js')}\""),
+ "#{file} is never imported in controllers/index.js"
+ expect(index).to include("application.register(\"#{identifier}\","),
+ "#{file} is imported but never registered as \"#{identifier}\", so it will never run"
+ end
+ end
+end
diff --git a/spec/models/affiliation_spec.rb b/spec/models/affiliation_spec.rb
index fa1ff6a1a2..bdf3a53887 100644
--- a/spec/models/affiliation_spec.rb
+++ b/spec/models/affiliation_spec.rb
@@ -150,6 +150,12 @@
it 'includes only the exact, case-sensitive title "Facilitator" (whitespace-trimmed)' do
expect(described_class.facilitators).to contain_exactly(exact, whitespace)
end
+
+ it 'qualifies title when joined with events (which also has title)' do
+ expect {
+ described_class.facilitators.joins(event_registration: :event).to_a
+ }.not_to raise_error
+ end
end
describe '#sync_organization_status_with_affiliations' do
@@ -192,7 +198,7 @@
end
end
- describe '.active_on' do
+ describe '.active_by_date_on' do
let(:date) { Date.new(2024, 6, 1) }
let!(:spanning) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: Date.new(2025, 1, 1)) }
let!(:open_ended) { create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil) }
@@ -201,24 +207,24 @@
let!(:no_dates) { create(:affiliation, start_date: nil, end_date: nil) }
it 'includes affiliations whose span covers the date' do
- expect(described_class.active_on(date)).to include(spanning, open_ended)
+ expect(described_class.active_by_date_on(date)).to include(spanning, open_ended)
end
it 'excludes affiliations that ended before the date' do
- expect(described_class.active_on(date)).not_to include(ended_before)
+ expect(described_class.active_by_date_on(date)).not_to include(ended_before)
end
it 'excludes affiliations that start after the date' do
- expect(described_class.active_on(date)).not_to include(starts_after)
+ expect(described_class.active_by_date_on(date)).not_to include(starts_after)
end
it 'includes affiliations with no dates on record' do
- expect(described_class.active_on(date)).to include(no_dates)
+ expect(described_class.active_by_date_on(date)).to include(no_dates)
end
it 'ignores the cached inactive flag, judging purely by dates' do
flagged = create(:affiliation, start_date: Date.new(2023, 1, 1), end_date: nil, inactive: true)
- expect(described_class.active_on(date)).to include(flagged)
+ expect(described_class.active_by_date_on(date)).to include(flagged)
end
end
@@ -291,6 +297,36 @@
op.update!(title: "New Title")
expect(op.reload.inactive).to be true
end
+
+ it 'keeps an explicitly set flag the dates would not derive' do
+ op.update!(start_date: Date.current, end_date: Date.current, inactive: true)
+
+ expect(op.reload.inactive).to be true
+ expect(op).not_to be_active
+ end
+
+ it 'treats a form\'s "0" as not supplied, so the dates still derive' do
+ op.inactive_supplied = "0"
+ op.update!(end_date: 1.day.ago.to_date)
+
+ expect(op.reload.inactive).to be true
+ end
+
+ it 'honours an end date of today when the form supplies the flag' do
+ op.inactive_supplied = "1"
+ op.update!(end_date: Date.current, inactive: "1")
+
+ expect(op.reload).not_to be_active
+ end
+
+ it 'keeps a hand-set flag when a later edit resubmits it alongside a new date' do
+ op.update!(start_date: Date.current, inactive: true)
+
+ op.inactive_supplied = true
+ op.update!(start_date: 1.month.ago.to_date, inactive: true)
+
+ expect(op.reload.inactive).to be true
+ end
end
describe "reassigning the organization" do
diff --git a/spec/requests/admin/data_health_spec.rb b/spec/requests/admin/data_health_spec.rb
new file mode 100644
index 0000000000..eef3d615ff
--- /dev/null
+++ b/spec/requests/admin/data_health_spec.rb
@@ -0,0 +1,95 @@
+require "rails_helper"
+
+RSpec.describe "Admin::DataHealth", type: :request do
+ let(:admin) { create(:user, :admin) }
+ let(:organization) { create(:organization) }
+ let(:person) { create(:person) }
+
+ # A facilitator affiliation minted by a registration to a non-training event.
+ def offending_affiliation
+ event = create(:event, :ended, facilitator_training: false, title: "Community Potluck")
+ registration = create(:event_registration, event: event, registrant: person, status: "attended")
+ create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 1.year.ago.to_date, event_registration: registration)
+ end
+
+ describe "GET index" do
+ before { sign_in admin }
+
+ it "reports a clean bill of health when nothing is wrong" do
+ get admin_data_health_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Everything checks out")
+ end
+
+ it "lists an offending row with enough context to recognise it" do
+ offending_affiliation
+
+ get admin_data_health_path
+
+ expect(response.body).to include("Facilitator affiliations from non-training events")
+ expect(response.body).to include(person.name)
+ expect(response.body).to include("Community Potluck")
+ expect(response.body).not_to include("Everything checks out")
+ end
+
+ it "offers a repair only for checks that have one" do
+ offending_affiliation
+
+ get admin_data_health_path
+
+ expect(response.body).to include("Delete them")
+ expect(response.body).to include("Review by hand")
+ end
+ end
+
+ describe "POST repair" do
+ before { sign_in admin }
+
+ it "applies the fix and says what it did" do
+ affiliation = offending_affiliation
+
+ post admin_data_health_repair_path(check: "facilitator_affiliations_from_non_trainings")
+
+ expect(response).to redirect_to(admin_data_health_path)
+ expect(flash[:notice]).to eq("Deleted 1 facilitator affiliation.")
+ expect(Affiliation.exists?(affiliation.id)).to be(false)
+ end
+
+ it "refuses an unknown check rather than erroring" do
+ post admin_data_health_repair_path(check: "no_such_check")
+
+ expect(response).to redirect_to(admin_data_health_path)
+ expect(flash[:alert]).to eq("Unknown check.")
+ end
+
+ # The param names a class to run, so a report-only check must not be coaxed
+ # into a repair by hitting the route directly.
+ it "refuses a report-only check" do
+ post admin_data_health_repair_path(check: "legacy_organization_status_drift")
+
+ expect(flash[:alert]).to eq("Unknown check.")
+ end
+ end
+
+ describe "authorization" do
+ it "denies a non-admin the page" do
+ sign_in create(:user)
+
+ get admin_data_health_path
+
+ expect(response).not_to have_http_status(:ok)
+ end
+
+ it "denies a non-admin a repair, leaving the data alone" do
+ affiliation = offending_affiliation
+ sign_in create(:user)
+
+ post admin_data_health_repair_path(check: "facilitator_affiliations_from_non_trainings")
+
+ expect(response).not_to have_http_status(:ok)
+ expect(Affiliation.exists?(affiliation.id)).to be(true)
+ end
+ end
+end
diff --git a/spec/requests/affiliation_comment_icon_spec.rb b/spec/requests/affiliation_comment_icon_spec.rb
new file mode 100644
index 0000000000..f42535c529
--- /dev/null
+++ b/spec/requests/affiliation_comment_icon_spec.rb
@@ -0,0 +1,66 @@
+require "rails_helper"
+
+RSpec.describe "the comment icon on an affiliation row", type: :request 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
+
+ before { sign_in create(:user, :admin) }
+
+ def comment_link(body)
+ Nokogiri::HTML(body).at_css("a[href*='comments-section']")
+ end
+
+ it "is not rendered when the affiliation has no comments" do
+ get edit_person_path(person)
+
+ expect(comment_link(response.body)).to be_nil
+ end
+
+ it "links to the affiliation editor's comments section, in a new tab" do
+ affiliation.comments.create!(body: "Ended after the training")
+
+ get edit_person_path(person)
+
+ link = comment_link(response.body)
+ expect(link["href"]).to eq(
+ edit_affiliation_path(affiliation, return_to: "person", origin_id: person.id, anchor: "comments-section")
+ )
+ expect(link["target"]).to eq("_blank")
+ expect(link["rel"]).to eq("noopener")
+ end
+
+ it "sends you back to whichever editor you came from" do
+ affiliation.comments.create!(body: "A note")
+
+ get edit_organization_path(organization)
+
+ expect(comment_link(response.body)["href"]).to eq(
+ edit_affiliation_path(affiliation, return_to: "organization", origin_id: organization.id, anchor: "comments-section")
+ )
+ end
+
+ # The gear in the same row is the other way into this editor; the two must agree
+ # or the eyebrow sends you somewhere different depending on which you clicked.
+ it "carries the same return_to and origin_id as the gear beside it" do
+ affiliation.comments.create!(body: "A note")
+
+ get edit_person_path(person)
+
+ doc = Nokogiri::HTML(response.body)
+ gear = doc.at_css("a[title^='Edit affiliation']")["href"]
+ comment = doc.at_css("a[href*='comments-section']")["href"]
+ expect(comment).to eq("#{gear}#comments-section")
+ end
+
+ it "lands on a section that actually exists on the affiliation editor" do
+ affiliation.comments.create!(body: "A note")
+
+ get edit_affiliation_path(affiliation)
+
+ expect(Nokogiri::HTML(response.body).at_css("#comments-section")).to be_present
+ end
+end
diff --git a/spec/requests/affiliation_filter_tabs_spec.rb b/spec/requests/affiliation_filter_tabs_spec.rb
new file mode 100644
index 0000000000..372e0cd682
--- /dev/null
+++ b/spec/requests/affiliation_filter_tabs_spec.rb
@@ -0,0 +1,96 @@
+require "rails_helper"
+
+# The Active/Inactive split is server-rendered — the browser only toggles which
+# group is shown, via :has() on two detached radios. See spec/system for the
+# toggling itself.
+RSpec.describe "the Active/Inactive split on the affiliation editor", type: :request do
+ let(:person) { create(:person) }
+ let!(:current) do
+ create(:affiliation, person: person, organization: create(:organization),
+ title: "Facilitator", start_date: 2.years.ago.to_date)
+ end
+ let!(:ended) do
+ create(:affiliation, person: person, organization: create(:organization), title: "Facilitator",
+ start_date: 3.years.ago.to_date, end_date: 1.year.ago.to_date)
+ end
+
+ before { sign_in create(:user, :admin) }
+
+ def parsed = Nokogiri::HTML(response.body)
+
+ def rows_in(id)
+ parsed.css("##{id} [data-paginated-fields-target='item']")
+ end
+
+ it "puts each row in the group the server bucketed it into" do
+ get edit_person_path(person)
+
+ expect(rows_in("person_affiliation_rows_active").to_s).to include(current.organization.name)
+ expect(rows_in("person_affiliation_rows_inactive").to_s).to include(ended.organization.name)
+ expect(rows_in("person_affiliation_rows_active").to_s).not_to include(ended.organization.name)
+ end
+
+ it "counts each bucket in its tab label" do
+ get edit_person_path(person)
+
+ expect(parsed.at_css("label[for='aff-tab-active']").text.split.last).to eq("1")
+ expect(parsed.at_css("label[for='aff-tab-inactive']").text.split.last).to eq("1")
+ end
+
+ it "keeps the tab radios out of the form so they never submit" do
+ get edit_person_path(person)
+
+ parsed.css("input[name='affiliation_tab']").each do |radio|
+ expect(radio["form"]).to eq("affiliation_tab_none")
+ expect(parsed.at_css("##{radio['form']}")).to be_nil
+ end
+ end
+
+ # Two Tailwind traps: `_` becomes a space inside an arbitrary value, so an
+ # underscored id compiles to a selector matching nothing; and an UNNAMED group
+ # here collides with each row's comment-icon group, popping every tooltip at once.
+ it "uses hyphenated radio ids and a named group so the :has() selectors compile and stay scoped" do
+ get edit_person_path(person)
+
+ expect(parsed.css("input[name='affiliation_tab']").map { |r| r["id"] })
+ .to all(match(/\A[a-z-]+\z/))
+ expect(parsed.at_css("[data-affiliation-dates-target='affiliationsContainer']")["class"])
+ .to include("group/afftabs")
+ expect(response.body).to include("group-has-[#aff-tab-inactive:checked]/afftabs:hidden")
+ end
+
+ it "still submits every row, both buckets, with distinct indices" do
+ get edit_person_path(person)
+
+ ids = parsed.css("input[name^='person[affiliations_attributes]'][name$='[id]']").map { |i| i["value"] }
+ expect(ids).to contain_exactly(current.id.to_s, ended.id.to_s)
+
+ indices = parsed.css("input[name^='person[affiliations_attributes]']")
+ .map { |i| i["name"][/\[affiliations_attributes\]\[([^\]]+)\]/, 1] }.uniq
+ expect(indices.size).to eq(2)
+ end
+
+ it "adds new rows into the active group only" do
+ get edit_person_path(person)
+
+ adder = parsed.at_css("[data-association-insertion-node]")
+ expect(adder["data-association-insertion-node"]).to eq("#person_affiliation_rows_active")
+ end
+
+ it "does the same on the organization editor" do
+ get edit_organization_path(current.organization)
+
+ expect(parsed.at_css("#organization_affiliation_rows_active")).to be_present
+ expect(parsed.at_css("#organization_affiliation_rows_inactive")).to be_present
+ end
+
+ it "buckets by the flag, not just the dates" do
+ current.inactive_supplied = true
+ current.update!(inactive: true)
+
+ get edit_person_path(person)
+
+ expect(parsed.at_css("label[for='aff-tab-active']").text.split.last).to eq("0")
+ expect(rows_in("person_affiliation_rows_inactive").size).to eq(2)
+ end
+end
diff --git a/spec/requests/affiliation_return_anchor_spec.rb b/spec/requests/affiliation_return_anchor_spec.rb
new file mode 100644
index 0000000000..dacd5d97f0
--- /dev/null
+++ b/spec/requests/affiliation_return_anchor_spec.rb
@@ -0,0 +1,39 @@
+require "rails_helper"
+# The eyebrow and the post-save redirect must agree, and both have to account for
+# the Inactive tab: a row that has ended isn't on screen when the person editor
+# opens, so linking to it would scroll to something hidden.
+RSpec.describe "where the affiliation editor sends you back to", type: :request do
+ let(:person) { create(:person) }
+ let(:org) { create(:organization) }
+ before { sign_in create(:user, :admin) }
+
+ it "anchors to the row when active, and redirects there after save" do
+ aff = create(:affiliation, person: person, organization: org, start_date: 1.year.ago.to_date)
+ get edit_affiliation_path(aff, return_to: "person", origin_id: person.id)
+ expect(response.body).to include("#affiliation_#{aff.id}")
+
+ patch affiliation_path(aff, return_to: "person", origin_id: person.id), params: { affiliation: { title: "Counselor" } }
+ expect(response).to redirect_to(edit_person_path(person, anchor: "affiliation_#{aff.id}"))
+ end
+
+ it "anchors to the section when inactive" do
+ aff = create(:affiliation, person: person, organization: org,
+ start_date: 2.years.ago.to_date, end_date: 1.year.ago.to_date)
+ get edit_affiliation_path(aff, return_to: "person", origin_id: person.id)
+ expect(response.body).to include("#affiliations")
+ expect(response.body).not_to include("#affiliation_#{aff.id}")
+
+ patch affiliation_path(aff, return_to: "person", origin_id: person.id), params: { affiliation: { title: "Counselor" } }
+ expect(response).to redirect_to(edit_person_path(person, anchor: "affiliations"))
+ end
+
+ # A month back, not a day: the controller sets the zone per user, so an end date
+ # of "yesterday" computed in UTC can still be today in the viewer's zone and read
+ # as active.
+ it "anchors to the section when the save is what makes it inactive" do
+ aff = create(:affiliation, person: person, organization: org, start_date: 1.year.ago.to_date)
+ patch affiliation_path(aff, return_to: "person", origin_id: person.id),
+ params: { affiliation: { end_date: 1.month.ago.to_date.to_s } }
+ expect(response).to redirect_to(edit_person_path(person, anchor: "affiliations"))
+ end
+end
diff --git a/spec/requests/affiliations_spec.rb b/spec/requests/affiliations_spec.rb
index 1cf65b6dcd..4590ca5bc9 100644
--- a/spec/requests/affiliations_spec.rb
+++ b/spec/requests/affiliations_spec.rb
@@ -80,6 +80,33 @@
expect(affiliation.reload.organization_address_id).to eq(address.id)
end
+
+ it "ends an affiliation whose dates still read as active" do
+ affiliation.update!(start_date: Date.current, end_date: nil, inactive: false)
+
+ patch affiliation_path(affiliation, return_to: "organization", origin_id: organization.id),
+ params: { affiliation: { inactive: "1" } }
+
+ expect(affiliation.reload).not_to be_active
+ end
+
+ it "keeps the ended state when a later edit changes a date with the box still ticked" do
+ affiliation.update!(start_date: Date.current, end_date: nil, inactive: true)
+
+ patch affiliation_path(affiliation, return_to: "organization", origin_id: organization.id),
+ params: { affiliation: { start_date: 1.month.ago.to_date.to_s, inactive: "1" } }
+
+ expect(affiliation.reload).not_to be_active
+ end
+
+ it "still derives the flag from the dates when the form omits it" do
+ affiliation.update!(start_date: 1.year.ago.to_date, end_date: nil, inactive: false)
+
+ patch affiliation_path(affiliation, return_to: "organization", origin_id: organization.id),
+ params: { affiliation: { end_date: 1.month.ago.to_date.to_s } }
+
+ expect(affiliation.reload).not_to be_active
+ end
end
context "as a non-admin" do
diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb
new file mode 100644
index 0000000000..a5f30db21d
--- /dev/null
+++ b/spec/requests/events/reconcile_affiliations_spec.rb
@@ -0,0 +1,212 @@
+require "rails_helper"
+
+RSpec.describe "Events::ReconcileAffiliations", type: :request do
+ let(:admin) { create(:user, :admin) }
+ let(:organization) { create(:organization) }
+ let(:event) { create(:event, :ended, facilitator_training: true) }
+
+ # A registrant of `event` who linked `organization`, with an owned facilitator
+ # affiliation created (as the registration flow would).
+ def registrant_with_affiliation(status:)
+ person = create(:person)
+ reg = create(:event_registration, event: event, registrant: person, status: status)
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ affiliation = create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 1.month.ago.to_date,
+ event_registration: reg)
+ [ person, affiliation ]
+ end
+
+ before { sign_in admin }
+
+ describe "GET index" do
+ it "previews the no-show as a deactivation, checked by default" do
+ person, _affiliation = registrant_with_affiliation(status: "no_show")
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(person.name)
+ expect(response.body).to include("Deactivate affiliation")
+ end
+
+ it "previews a missing affiliation as a creation before the event" do
+ upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now)
+ person = create(:person)
+ reg = create(:event_registration, event: upcoming, registrant: person, status: "registered")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+
+ get reconcile_affiliations_event_path(upcoming)
+
+ expect(response.body).to include("Will be created")
+ end
+
+ it "previews a facilitator affiliation on a non-training event as a deletion" do
+ non_training = create(:event, :ended, facilitator_training: false)
+ person = create(:person)
+ reg = create(:event_registration, event: non_training, registrant: person, status: "attended")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 1.month.ago.to_date, event_registration: reg)
+
+ get reconcile_affiliations_event_path(non_training)
+
+ expect(response.body).to include("Will be deleted")
+ end
+
+ it "lists a no-action registrant under Not reconciled with the reason and attendance status" do
+ person, _affiliation = registrant_with_affiliation(status: "attended")
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response.body).to include("Not reconciled")
+ expect(response.body).to include("Active — attended")
+ expect(response.body).to include("Attended")
+ expect(response.body).to include(person.name)
+ end
+
+ it "reconciles a hand-entered (unowned) facilitator affiliation too" do
+ person = create(:person)
+ reg = create(:event_registration, event: event, registrant: person, status: "no_show")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ create(:affiliation, person: person, organization: organization, title: "Facilitator", start_date: 1.year.ago.to_date)
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response.body).to include("Deactivate affiliation")
+ end
+
+ it "denies a non-admin" do
+ sign_in create(:user)
+
+ get reconcile_affiliations_event_path(event)
+
+ expect(response).not_to have_http_status(:ok)
+ end
+ end
+
+ describe "toggling attendance from the reconcile page" do
+ it "opts the attendance form out of Turbo, so the redirect runs and the row's actions re-render" do
+ person, _affiliation = registrant_with_affiliation(status: "no_show")
+ registration = person.event_registrations.first
+
+ get reconcile_affiliations_event_path(event)
+
+ form = Nokogiri::HTML(response.body).at_css("form[action*='/event_registrations/#{registration.id}']")
+ expect(form["data-turbo"]).to eq("false")
+ expect(form["action"]).to include("return_to=reconcile_affiliations")
+ end
+
+ it "stays on the reconcile page with a flash instead of leaving for the roster" do
+ person, _affiliation = registrant_with_affiliation(status: "no_show")
+ registration = person.event_registrations.first
+
+ patch event_registration_path(registration, return_to: "reconcile_affiliations"),
+ params: { event_registration: { status: "attended" } }
+
+ expect(response).to redirect_to(reconcile_affiliations_event_path(event, anchor: "attendance_status_event_registration_#{registration.id}"))
+ expect(flash[:notice]).to be_present
+ end
+ end
+
+ describe "POST confirm (preview changes)" do
+ it "shows the selected change without writing" do
+ _person, affiliation = registrant_with_affiliation(status: "no_show")
+
+ post reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "deactivate" } }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Confirm affiliation changes")
+ expect(response.body).to include("Perform changes")
+ expect(affiliation.reload).to be_active
+ end
+
+ it "redirects back when nothing is selected" do
+ _person, affiliation = registrant_with_affiliation(status: "no_show")
+
+ post reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "keep" } }
+
+ expect(response).to redirect_to(reconcile_affiliations_event_path(event))
+ end
+ end
+
+ describe "POST perform" do
+ it "deactivates the chosen non-completer and stamps the event" do
+ _person, affiliation = registrant_with_affiliation(status: "no_show")
+
+ post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "deactivate" } }
+
+ expect(response).to redirect_to(registrants_event_path(event))
+ expect(affiliation.reload).not_to be_active
+ expect(event.reload.affiliations_reconciled_at).to be_present
+ end
+
+ it "deactivates a no-show whose one-day training started and ended today" do
+ same_day = create(:event, facilitator_training: true, start_date: 3.hours.ago,
+ end_date: 1.hour.ago, registration_close_date: 4.hours.ago)
+ person = create(:person)
+ reg = create(:event_registration, event: same_day, registrant: person, status: "no_show")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ affiliation = create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: Date.current, event_registration: reg)
+
+ post perform_reconcile_affiliations_event_path(same_day), params: { outcome: { "aff:#{affiliation.id}" => "deactivate" } }
+
+ expect(affiliation.reload).not_to be_active
+ end
+
+ it "spares a row set to keep" do
+ _person, affiliation = registrant_with_affiliation(status: "no_show")
+
+ post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "keep" } }
+
+ expect(affiliation.reload).to be_active
+ end
+
+ it "creates a missing affiliation before the event when chosen" do
+ upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now)
+ person = create(:person)
+ reg = create(:event_registration, event: upcoming, registrant: person, status: "registered")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+
+ expect {
+ post perform_reconcile_affiliations_event_path(upcoming), params: { outcome: { "create:#{person.id}:#{organization.id}" => "create" } }
+ }.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1)
+ end
+
+ it "deletes when the delete outcome is chosen" do
+ _person, affiliation = registrant_with_affiliation(status: "no_show")
+
+ post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "delete" } }
+
+ expect(Affiliation.exists?(affiliation.id)).to be(false)
+ end
+
+ it "deactivates a hand-entered facilitator affiliation when chosen" do
+ person = create(:person)
+ reg = create(:event_registration, event: event, registrant: person, status: "no_show")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ hand_entered = create(:affiliation, person: person, organization: organization, title: "Facilitator", start_date: 1.year.ago.to_date)
+
+ post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{hand_entered.id}" => "deactivate" } }
+
+ expect(hand_entered.reload).not_to be_active
+ end
+
+ it "deletes a facilitator affiliation auto-created off a non-training event, keeping the job affiliation" do
+ non_training = create(:event, :ended, facilitator_training: false)
+ person = create(:person)
+ reg = create(:event_registration, event: non_training, registrant: person, status: "attended")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ facilitator = create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 1.month.ago.to_date, event_registration: reg)
+ job = create(:affiliation, person: person, organization: organization, title: "Counselor",
+ event_registration: reg)
+
+ post perform_reconcile_affiliations_event_path(non_training), params: { outcome: { "aff:#{facilitator.id}" => "delete" } }
+
+ expect(Affiliation.exists?(facilitator.id)).to be(false)
+ expect(Affiliation.exists?(job.id)).to be(true)
+ end
+ end
+end
diff --git a/spec/services/affiliation_services/reconcile_person_spec.rb b/spec/services/affiliation_services/reconcile_person_spec.rb
new file mode 100644
index 0000000000..0b3fd00227
--- /dev/null
+++ b/spec/services/affiliation_services/reconcile_person_spec.rb
@@ -0,0 +1,300 @@
+require "rails_helper"
+
+RSpec.describe AffiliationServices::ReconcilePerson do
+ let(:person) { create(:person) }
+ let(:organization) { create(:organization) }
+
+ # A facilitator-training registration for `person` linking `organization`.
+ def training_registration(status:, ended: true)
+ event = create(:event, *(ended ? [ :ended ] : []), facilitator_training: true)
+ reg = create(:event_registration, registrant: person, event: event, status: status)
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ reg
+ end
+
+ # A "Facilitator" affiliation for (person, organization) owned by `registration`.
+ # Defaults to the training's own date, which is what the registration flow sets
+ # (ADR-0001 D8) and what makes it "the row this training minted" (ADR-0002 D6).
+ def owned_facilitator(registration:, start_date: nil)
+ create(:affiliation,
+ person: person,
+ organization: organization,
+ title: "Facilitator",
+ start_date: start_date || registration.event.start_date.to_date,
+ event_registration: registration)
+ end
+
+ def reconcile(registration, **options)
+ described_class.call(person: person, organization: organization, event: registration.event, **options)
+ end
+
+ describe "deactivation" do
+ it "same-days the owned facilitator affiliation when the person never attended" do
+ reg = training_registration(status: "no_show")
+ affiliation = owned_facilitator(registration: reg)
+
+ reconcile(reg)
+ affiliation.reload
+
+ expect(affiliation.end_date).to eq(affiliation.start_date)
+ expect(affiliation).to be_inactive
+ expect(affiliation).not_to be_active
+ end
+
+ %w[ incomplete_attendance registered cancelled transferred_out ].each do |status|
+ it "deactivates when the only registration is #{status}" do
+ reg = training_registration(status: status)
+ affiliation = owned_facilitator(registration: reg)
+
+ reconcile(reg)
+
+ expect(affiliation.reload).not_to be_active
+ end
+ end
+
+ it "deactivates on the day a one-day training ends, when the affiliation starts that same day" do
+ event = create(:event, facilitator_training: true, start_date: 3.hours.ago,
+ end_date: 1.hour.ago, registration_close_date: 4.hours.ago)
+ reg = create(:event_registration, registrant: person, event: event, status: "no_show")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ affiliation = owned_facilitator(registration: reg, start_date: Date.current)
+
+ reconcile(reg)
+
+ expect(affiliation.reload).not_to be_active
+ end
+
+ it "leaves an assumptive affiliation alone while its training is still upcoming" do
+ reg = training_registration(status: "registered", ended: false)
+ affiliation = owned_facilitator(registration: reg, start_date: Date.current)
+
+ reconcile(reg)
+
+ expect(affiliation.reload).to be_active
+ expect(affiliation.end_date).to be_nil
+ end
+
+ it "leaves an unowned (hand-created) facilitator affiliation untouched" do
+ reg = training_registration(status: "no_show")
+ hand_created = create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 1.month.ago.to_date)
+
+ reconcile(reg)
+
+ expect(hand_created.reload).to be_active
+ expect(hand_created.end_date).to be_nil
+ end
+
+ it "reconciles a hand-created affiliation when the caller opts in" do
+ reg = training_registration(status: "no_show")
+ hand_created = create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 1.month.ago.to_date)
+
+ reconcile(reg, include_unowned: true)
+
+ expect(hand_created.reload).not_to be_active
+ end
+
+ it "ends an older affiliation at the training, keeping the years it really facilitated" do
+ reg = training_registration(status: "no_show")
+ started_on = 2.years.ago.to_date
+ hand_created = create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: started_on)
+
+ reconcile(reg, include_unowned: true)
+
+ expect(hand_created.reload.end_date).to eq(reg.event.start_date.to_date)
+ expect(hand_created.start_date).to eq(started_on)
+ end
+
+ it "same-days an older affiliation that starts after the training rather than ending it before it began" do
+ reg = training_registration(status: "no_show")
+ later = create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: Date.current)
+
+ reconcile(reg, include_unowned: true)
+
+ expect(later.reload.end_date).to eq(later.start_date)
+ end
+ end
+
+ describe "the comment reconciliation leaves behind" do
+ it "records why a row was ended, and who did it" do
+ user = create(:user, :admin)
+ Current.user = user
+ reg = training_registration(status: "no_show")
+ affiliation = owned_facilitator(registration: reg)
+
+ reconcile(reg)
+
+ comment = affiliation.reload.comments.last
+ expect(comment.topic).to eq(described_class::COMMENT_TOPIC)
+ expect(comment.body).to include("marked inactive by reconciliation")
+ expect(comment.body).to include(reg.event.title)
+ expect(comment.created_by).to eq(user)
+ ensure
+ Current.user = nil
+ end
+
+ it "records why a returning facilitator's new row appeared" do
+ create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: Date.new(2023, 1, 1), end_date: Date.new(2024, 1, 1))
+ reg = training_registration(status: "attended")
+
+ described_class.call(person: person, organization: organization, event: reg.event,
+ registration: reg, include_unowned: true)
+
+ fresh = person.affiliations.facilitators.active.where(organization: organization).last
+ expect(fresh.comments.last.body).to include("Created by reconciliation")
+ end
+
+ it "distinguishes a row it ended from one an admin ended" do
+ reg = training_registration(status: "no_show")
+ admin_ended = create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 3.years.ago.to_date, end_date: 2.years.ago.to_date)
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event,
+ registration: reg, include_unowned: true).plan
+
+ expect(plan.map(&:reason)).to include(described_class::ALREADY_ENDED)
+ expect(plan.map(&:reason)).not_to include(described_class::ALREADY_DEACTIVATED)
+ expect(admin_ended.reload.end_date).to eq(2.years.ago.to_date)
+ end
+ end
+
+ describe "keeping / activating" do
+ it "keeps the affiliation active when the person attended" do
+ reg = training_registration(status: "attended")
+ affiliation = owned_facilitator(registration: reg)
+
+ reconcile(reg)
+
+ expect(affiliation.reload).to be_active
+ expect(affiliation.end_date).to be_nil
+ end
+
+ it "keeps active when the person no-showed one training but attended another for the same org" do
+ no_show = training_registration(status: "no_show")
+ affiliation = owned_facilitator(registration: no_show)
+ training_registration(status: "attended")
+
+ reconcile(no_show)
+
+ expect(affiliation.reload).to be_active
+ end
+
+ it "records a return as a NEW affiliation, leaving the ended one ended" do
+ reg = training_registration(status: "attended")
+ ended = owned_facilitator(registration: reg, start_date: 1.month.ago.to_date)
+ ended.update!(end_date: ended.start_date)
+ expect(ended.reload).not_to be_active
+
+ expect { described_class.call(person: person, organization: organization, event: reg.event, registration: reg) }
+ .to change { person.affiliations.facilitators.where(organization: organization).count }.by(1)
+
+ expect(ended.reload.end_date).to eq(ended.start_date)
+ expect(person.affiliations.facilitators.active.where(organization: organization).count).to eq(1)
+ end
+
+ it "keeps the lapse visible instead of swallowing it into one unbroken stretch" do
+ lapsed = create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: Date.new(2023, 1, 1), end_date: Date.new(2024, 1, 1))
+ reg = training_registration(status: "attended")
+
+ described_class.call(person: person, organization: organization, event: reg.event,
+ registration: reg, include_unowned: true)
+
+ expect(lapsed.reload.end_date).to eq(Date.new(2024, 1, 1))
+ expect(organization.reload.facilitator_status_on(Date.new(2025, 1, 1))).to eq(:reinstated)
+ end
+
+ it "plans no action on a lapsed row, explaining why" do
+ create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: Date.new(2023, 1, 1), end_date: Date.new(2024, 1, 1))
+ reg = training_registration(status: "attended")
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event,
+ registration: reg, include_unowned: true).plan
+
+ expect(plan.map(&:action)).to contain_exactly(:noop, :create)
+ expect(plan.map(&:reason)).to include(described_class::LAPSED)
+ end
+ end
+
+ describe "creating" do
+ it "creates the missing facilitator affiliation for an attendee" do
+ reg = training_registration(status: "attended")
+
+ expect { described_class.call(person: person, organization: organization, event: reg.event, registration: reg) }
+ .to change { person.affiliations.facilitators.where(organization: organization).count }.by(1)
+ end
+
+ it "proposes nothing when the caller passes no registration to own the new row" do
+ reg = training_registration(status: "attended")
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event).plan
+
+ expect(plan).to be_empty
+ end
+ end
+
+ describe "idempotence" do
+ it "is stable across repeated runs" do
+ reg = training_registration(status: "no_show")
+ affiliation = owned_facilitator(registration: reg)
+
+ reconcile(reg)
+ first = affiliation.reload.end_date
+ reconcile(reg)
+
+ expect(affiliation.reload.end_date).to eq(first)
+ end
+ end
+
+ describe "#plan (dry run)" do
+ it "reports :deactivate without writing" do
+ reg = training_registration(status: "no_show")
+ affiliation = owned_facilitator(registration: reg)
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event).plan
+
+ expect(plan.map(&:action)).to eq([ :deactivate ])
+ expect(affiliation.reload).to be_active
+ end
+
+ it "reports the reason a row needs no action" do
+ reg = training_registration(status: "attended")
+ owned_facilitator(registration: reg)
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event).plan
+
+ expect(plan.map(&:action)).to eq([ :noop ])
+ expect(plan.first.reason).to eq(described_class::ACTIVE_ATTENDED)
+ expect(plan.first).not_to be_actionable
+ end
+
+ it "plans nothing when there is no owned facilitator affiliation" do
+ reg = training_registration(status: "no_show")
+
+ plan = described_class.new(person: person, organization: organization, event: reg.event).plan
+
+ expect(plan).to be_empty
+ end
+ end
+
+ describe "a non-training event" do
+ it "deletes only the facilitator affiliation auto-created off that event" do
+ event = create(:event, :ended, facilitator_training: false)
+ reg = create(:event_registration, registrant: person, event: event, status: "attended")
+ create(:event_registration_organization, event_registration: reg, organization: organization)
+ off_this_event = owned_facilitator(registration: reg)
+ hand_created = create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 2.years.ago.to_date)
+
+ reconcile(reg)
+
+ expect(Affiliation.exists?(off_this_event.id)).to be(false)
+ expect(hand_created.reload).to be_active
+ end
+ end
+end
diff --git a/spec/services/analytics/person_activity_events_spec.rb b/spec/services/analytics/person_activity_events_spec.rb
index 6866483745..139eb60b7c 100644
--- a/spec/services/analytics/person_activity_events_spec.rb
+++ b/spec/services/analytics/person_activity_events_spec.rb
@@ -44,6 +44,18 @@ def event(resource_type:, resource_id:, name: "update.record", properties: {})
expect(described_class.new(person).relation).to include(target)
end
+ it "includes payment events recorded under the STI subclass the tracker writes" do
+ payment = create(:payment, person: person, type: "CashPayment")
+ target = event(resource_type: "CashPayment", resource_id: payment.id, name: "create.payment")
+ expect(described_class.new(person).relation).to include(target)
+ end
+
+ it "includes events about comments on the person's affiliations" do
+ comment = create(:comment, commentable: create(:affiliation, person: person))
+ target = event(resource_type: "Comment", resource_id: comment.id, name: "create.comment")
+ expect(described_class.new(person).relation).to include(target)
+ end
+
it "includes events about the person's continuing education registrations" do
registration = create(:event_registration, registrant: person)
ce = create(:continuing_education_registration, event_registration: registration)
diff --git a/spec/services/data_health/checks_spec.rb b/spec/services/data_health/checks_spec.rb
new file mode 100644
index 0000000000..d683f52718
--- /dev/null
+++ b/spec/services/data_health/checks_spec.rb
@@ -0,0 +1,144 @@
+require "rails_helper"
+
+RSpec.describe DataHealth do
+ describe ".find" do
+ it "resolves a check by its key" do
+ expect(described_class.find("legacy_organization_status_drift"))
+ .to be_a(DataHealth::LegacyOrganizationStatusDrift)
+ end
+
+ it "is nil for an unknown key, so a bad param can't run anything" do
+ expect(described_class.find("../../etc/passwd")).to be_nil
+ expect(described_class.find("Affiliation")).to be_nil
+ end
+ end
+
+ it "gives every check a distinct key" do
+ keys = described_class.checks.map(&:key)
+
+ expect(keys.uniq).to eq(keys)
+ end
+
+ it "keeps every check's scope a relation, so counting doesn't load the table" do
+ described_class.checks.each do |check|
+ expect(check.scope).to be_a(ActiveRecord::Relation), "#{check.key} returned #{check.scope.class}"
+ end
+ end
+end
+
+RSpec.describe DataHealth::FacilitatorAffiliationsFromNonTrainings do
+ let(:organization) { create(:organization) }
+ let(:person) { create(:person) }
+
+ def affiliation_from(facilitator_training:, title: "Facilitator")
+ event = create(:event, :ended, facilitator_training: facilitator_training)
+ registration = create(:event_registration, event: event, registrant: person, status: "attended")
+ create(:affiliation, person: person, organization: organization, title: title,
+ start_date: 1.year.ago.to_date, event_registration: registration)
+ end
+
+ it "finds a facilitator affiliation minted by a non-training registration" do
+ offender = affiliation_from(facilitator_training: false)
+
+ expect(described_class.new.scope).to include(offender)
+ end
+
+ it "leaves one minted by a real training alone" do
+ affiliation_from(facilitator_training: true)
+
+ expect(described_class.new).not_to be_any
+ end
+
+ it "ignores job affiliations — only the facilitator title confers status" do
+ affiliation_from(facilitator_training: false, title: "Counselor")
+
+ expect(described_class.new).not_to be_any
+ end
+
+ it "ignores hand-entered rows, which have no minting registration" do
+ create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 1.year.ago.to_date)
+
+ expect(described_class.new).not_to be_any
+ end
+
+ it "deletes them and reports how many, keeping the org's status in step" do
+ offender = affiliation_from(facilitator_training: false)
+ check = described_class.new
+
+ expect(check.repair!).to eq(1)
+ expect(Affiliation.exists?(offender.id)).to be(false)
+ expect(described_class.new).not_to be_any
+ end
+end
+
+RSpec.describe DataHealth::MisalignedAffiliationProvenance do
+ let(:organization) { create(:organization) }
+ let(:other_organization) { create(:organization) }
+ let(:person) { create(:person) }
+ let(:registration) do
+ create(:event_registration, event: create(:event, :ended, facilitator_training: true),
+ registrant: person, status: "attended")
+ end
+
+ it "finds a row whose minting registration is linked to a different organization" do
+ create(:event_registration_organization, event_registration: registration, organization: other_organization)
+ offender = create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 1.year.ago.to_date, event_registration: registration)
+
+ expect(described_class.new.scope).to include(offender)
+ end
+
+ it "leaves a row whose registration is linked to its own organization" do
+ create(:event_registration_organization, event_registration: registration, organization: organization)
+ create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 1.year.ago.to_date, event_registration: registration)
+
+ expect(described_class.new).not_to be_any
+ end
+
+ it "ignores hand-entered rows — a missing link is not a stale one" do
+ create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 1.year.ago.to_date)
+
+ expect(described_class.new).not_to be_any
+ end
+
+ it "unlinks rather than deletes, so the row survives as hand-entered" do
+ create(:event_registration_organization, event_registration: registration, organization: other_organization)
+ offender = create(:affiliation, person: person, organization: organization, title: "Facilitator",
+ start_date: 1.year.ago.to_date, event_registration: registration)
+
+ expect(described_class.new.repair!).to eq(1)
+
+ expect(offender.reload.event_registration_id).to be_nil
+ expect(offender).to be_persisted
+ expect(described_class.new).not_to be_any
+ end
+end
+
+RSpec.describe DataHealth::LegacyOrganizationStatusDrift do
+ let!(:active_status) { OrganizationStatus.find_or_create_by!(name: "Active") }
+ let!(:inactive_status) { OrganizationStatus.find_or_create_by!(name: "Inactive") }
+
+ it "finds an organization stored Active with no facilitator affiliation" do
+ drifted = create(:organization, organization_status: active_status)
+
+ expect(described_class.new.scope).to include(drifted)
+ end
+
+ it "leaves an organization whose stored status agrees with its affiliations" do
+ organization = create(:organization, organization_status: active_status)
+ create(:affiliation, organization: organization, title: "Facilitator",
+ start_date: 1.year.ago.to_date)
+
+ expect(described_class.new.scope).not_to include(organization.reload)
+ end
+
+ it "reports only — there is no stored value meaning 'never active'" do
+ check = described_class.new
+
+ expect(check).not_to be_repairable
+ expect { check.repair! }.to raise_error(NotImplementedError)
+ end
+end
diff --git a/spec/services/facilitator_program_status_math_spec.rb b/spec/services/facilitator_program_status_math_spec.rb
new file mode 100644
index 0000000000..0e9d38b5b0
--- /dev/null
+++ b/spec/services/facilitator_program_status_math_spec.rb
@@ -0,0 +1,205 @@
+require "rails_helper"
+
+# How several people's facilitator affiliations add up to ONE verdict for the
+# organization — at an anchor date (New / Ongoing / Reinstated) and right now
+# (Active / Formerly active / Never active). ADR-0002 D3–D5.
+#
+# The single-affiliation boundary cases live in facilitator_program_status_spec.rb;
+# this file is about the arithmetic across people, across anchors, and the
+# relationship between the two questions.
+RSpec.describe "facilitator affiliation math" do
+ let(:organization) { create(:organization) }
+
+ def facilitator(start_date:, end_date: nil, title: "Facilitator")
+ create(:affiliation, organization: organization, person: create(:person),
+ title: title, start_date: start_date, end_date: end_date)
+ end
+
+ def status_on(date)
+ organization.reload.facilitator_status_on(date)
+ end
+
+ def bucket
+ organization.reload.decorate.organization_status_bucket
+ end
+
+ describe "several people at one anchor" do
+ let(:anchor) { Date.new(2026, 6, 15) }
+
+ it "is :ongoing when any one person is still facilitating, even if others have left" do
+ facilitator(start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 1, 1))
+ facilitator(start_date: Date.new(2019, 1, 1), end_date: Date.new(2020, 1, 1))
+ facilitator(start_date: Date.new(2021, 1, 1))
+
+ expect(status_on(anchor)).to eq(:ongoing)
+ end
+
+ it "is :reinstated only when EVERY earlier person has ended" do
+ facilitator(start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 1, 1))
+ facilitator(start_date: Date.new(2019, 1, 1), end_date: Date.new(2020, 1, 1))
+
+ expect(status_on(anchor)).to eq(:reinstated)
+ end
+
+ it "is :new when every person starts on or after the anchor" do
+ facilitator(start_date: anchor)
+ facilitator(start_date: anchor)
+ facilitator(start_date: anchor + 1.day)
+
+ expect(status_on(anchor)).to eq(:new)
+ end
+
+ it "does not let people arriving at the training rescue a lapsed program" do
+ facilitator(start_date: Date.new(2015, 1, 1), end_date: Date.new(2018, 1, 1))
+ facilitator(start_date: anchor)
+ facilitator(start_date: anchor)
+
+ expect(status_on(anchor)).to eq(:reinstated)
+ end
+
+ it "counts only facilitators — a roomful of other titles is still :new" do
+ facilitator(start_date: Date.new(2010, 1, 1), title: "Volunteer")
+ facilitator(start_date: Date.new(2011, 1, 1), title: "Counselor")
+ facilitator(start_date: Date.new(2012, 1, 1), title: "Lead Facilitator")
+
+ expect(status_on(anchor)).to eq(:new)
+ end
+ end
+
+ describe "the same organization read at different anchors" do
+ it "reads :new on Jan 1 and :ongoing on Dec 31 when the program starts mid-year" do
+ facilitator(start_date: Date.new(2026, 5, 4))
+
+ expect(status_on(Date.new(2026, 1, 1))).to eq(:new)
+ expect(status_on(Date.new(2026, 12, 31))).to eq(:ongoing)
+ end
+
+ it "reads :ongoing on Jan 1 and :reinstated on Dec 31 when the program lapses mid-year" do
+ facilitator(start_date: Date.new(2022, 3, 1), end_date: Date.new(2026, 5, 4))
+
+ expect(status_on(Date.new(2026, 1, 1))).to eq(:ongoing)
+ expect(status_on(Date.new(2026, 12, 31))).to eq(:reinstated)
+ end
+
+ it "walks new → ongoing → reinstated → ongoing across a lapse and a return" do
+ facilitator(start_date: Date.new(2020, 2, 1), end_date: Date.new(2022, 8, 1))
+ facilitator(start_date: Date.new(2025, 9, 1))
+
+ expect(status_on(Date.new(2019, 1, 1))).to eq(:new)
+ expect(status_on(Date.new(2021, 1, 1))).to eq(:ongoing)
+ expect(status_on(Date.new(2024, 1, 1))).to eq(:reinstated)
+ expect(status_on(Date.new(2026, 1, 1))).to eq(:ongoing)
+ end
+
+ it "still reports what was true then after the program later ends" do
+ affiliation = facilitator(start_date: Date.new(2020, 1, 1))
+ expect(status_on(Date.new(2023, 1, 1))).to eq(:ongoing)
+
+ affiliation.update!(end_date: Date.new(2024, 6, 1))
+
+ expect(status_on(Date.new(2023, 1, 1))).to eq(:ongoing)
+ expect(status_on(Date.new(2026, 1, 1))).to eq(:reinstated)
+ end
+ end
+
+ describe "now (Active / Formerly active / Never active)" do
+ it "is :never_active with no facilitator affiliation, whatever else the org has" do
+ facilitator(start_date: 5.years.ago.to_date, title: "Volunteer")
+
+ expect(bucket).to eq(:never_active)
+ end
+
+ it "is :active while any one person is still facilitating" do
+ facilitator(start_date: 5.years.ago.to_date, end_date: 3.years.ago.to_date)
+ facilitator(start_date: 2.years.ago.to_date)
+
+ expect(bucket).to eq(:active)
+ end
+
+ it "is :formerly_active once every facilitator has ended — a subset of not-active" do
+ facilitator(start_date: 5.years.ago.to_date, end_date: 3.years.ago.to_date)
+ facilitator(start_date: 2.years.ago.to_date, end_date: 1.year.ago.to_date)
+
+ expect(bucket).to eq(:formerly_active)
+ expect(organization.reload.affiliations.facilitators.active).to be_empty
+ end
+
+ it "is :formerly_active when the flag ends a row the dates still call active" do
+ affiliation = facilitator(start_date: 2.years.ago.to_date)
+ expect(bucket).to eq(:active)
+
+ affiliation.inactive_supplied = true
+ affiliation.update!(inactive: true)
+
+ expect(bucket).to eq(:formerly_active)
+ end
+
+ it "agrees with the SQL scope the index filter uses" do
+ facilitator(start_date: 5.years.ago.to_date, end_date: 3.years.ago.to_date)
+
+ expect(bucket).to eq(:formerly_active)
+ expect(Organization.program_status(:formerly_active)).to include(organization)
+ expect(Organization.program_status(:active)).not_to include(organization)
+ end
+ end
+
+ describe "the two questions are independent" do
+ it "reads Ongoing at a past training while reading Formerly active today" do
+ facilitator(start_date: 4.years.ago.to_date, end_date: 1.year.ago.to_date)
+
+ expect(status_on(2.years.ago.to_date)).to eq(:ongoing)
+ expect(bucket).to eq(:formerly_active)
+ end
+
+ it "reads New at a past date while reading Active today" do
+ facilitator(start_date: 1.year.ago.to_date)
+
+ expect(status_on(3.years.ago.to_date)).to eq(:new)
+ expect(bucket).to eq(:active)
+ end
+ end
+
+ describe "reconciliation does not move an anchored verdict" do
+ it "keeps the training-date status when a no-show's older affiliation is ended" do
+ person = create(:person)
+ event = create(:event, :ended, facilitator_training: true)
+ anchor = event.start_date.to_date
+ older = create(:affiliation, organization: organization, person: person,
+ title: "Facilitator", start_date: 3.years.ago.to_date)
+ registration = create(:event_registration, event: event, registrant: person, status: "no_show")
+ create(:event_registration_organization, event_registration: registration, organization: organization)
+
+ expect(status_on(anchor)).to eq(:ongoing)
+
+ AffiliationServices::ReconcilePerson.new(
+ person: person, organization: organization, event: event,
+ registration: registration, include_unowned: true
+ ).perform(:deactivate, affiliation: older)
+
+ expect(status_on(anchor)).to eq(:ongoing)
+ expect(status_on(anchor + 1.year)).to eq(:reinstated)
+ expect(bucket).to eq(:formerly_active)
+ end
+
+ it "leaves the verdict alone when the row the training minted is same-dayed" do
+ person = create(:person)
+ event = create(:event, :ended, facilitator_training: true)
+ anchor = event.start_date.to_date
+ registration = create(:event_registration, event: event, registrant: person, status: "no_show")
+ create(:event_registration_organization, event_registration: registration, organization: organization)
+ minted = create(:affiliation, organization: organization, person: person, title: "Facilitator",
+ start_date: anchor, event_registration: registration)
+
+ expect(status_on(anchor)).to eq(:new)
+
+ AffiliationServices::ReconcilePerson.new(
+ person: person, organization: organization, event: event,
+ registration: registration, include_unowned: true
+ ).perform(:deactivate, affiliation: minted)
+
+ expect(status_on(anchor)).to eq(:new)
+ expect(minted.reload.end_date).to eq(anchor)
+ expect(bucket).to eq(:formerly_active)
+ end
+ end
+end
diff --git a/spec/services/person_comment_aggregator_spec.rb b/spec/services/person_comment_aggregator_spec.rb
index ae9a804ab4..9641576136 100644
--- a/spec/services/person_comment_aggregator_spec.rb
+++ b/spec/services/person_comment_aggregator_spec.rb
@@ -6,9 +6,12 @@
let(:person) { create(:person) }
describe "#comments" do
- it "gathers comments from the person, their registrations, scholarships, CE registrations, stories, story ideas, and user account" do
+ it "gathers comments from the person, their affiliations, registrations, scholarships, CE registrations, stories, story ideas, and user account" do
profile_comment = create(:comment, commentable: person)
+ affiliation = create(:affiliation, person: person)
+ affiliation_comment = create(:comment, commentable: affiliation)
+
registration = create(:event_registration, registrant: person)
registration_comment = create(:comment, commentable: registration)
@@ -30,8 +33,8 @@
user_comment = create(:comment, commentable: person.user)
expect(aggregator.comments).to contain_exactly(
- profile_comment, registration_comment, scholarship_comment, ce_comment, subscription_comment,
- story_comment, story_idea_comment, user_comment
+ profile_comment, affiliation_comment, registration_comment, scholarship_comment, ce_comment,
+ subscription_comment, story_comment, story_idea_comment, user_comment
)
end
diff --git a/spec/system/affiliation_edit_live_styling_spec.rb b/spec/system/affiliation_edit_live_styling_spec.rb
new file mode 100644
index 0000000000..d61b86cf4a
--- /dev/null
+++ b/spec/system/affiliation_edit_live_styling_spec.rb
@@ -0,0 +1,118 @@
+require "rails_helper"
+
+# The standalone affiliation editor reuses inactive-toggle, the same live styling
+# the nested rows on the person/organization editors use.
+RSpec.describe "Affiliation editor live styling", type: :system do
+ let(:admin) { create(:user, :admin) }
+ let!(:person) { create(:person, user: admin) }
+ let!(:organization) { create(:organization) }
+ let!(:affiliation) do
+ create(:affiliation, person: person, organization: organization,
+ title: "Facilitator", start_date: 2.years.ago.to_date)
+ end
+
+ before do
+ driven_by(:selenium_chrome_headless)
+ sign_in admin
+ visit edit_affiliation_path(affiliation)
+ end
+
+ def row = find("[data-inactive-toggle-target='row']")
+
+ # The controller ends a row on the *browser's* today, while Ruby's Date.current
+ # follows the Rails zone — they disagree for part of each day. Ask the browser.
+ def browser_today
+ page.evaluate_script(
+ "(() => { const d = new Date(); const p = n => String(n).padStart(2, '0'); " \
+ "return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })()"
+ )
+ end
+
+ def set_end_date(value)
+ page.execute_script(
+ "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', { bubbles: true }))",
+ find("[data-inactive-toggle-target~='endDate']"), value
+ )
+ end
+
+
+ it "tints an active facilitator row without striking it through" do
+ expect(row[:class]).to include("bg-purple-50")
+ expect(row[:class]).not_to include("aff-ended")
+ end
+
+ it "strikes the row through as soon as the Inactive box is ticked" do
+ find("[data-inactive-toggle-target='inactiveCheckbox']").click
+
+ expect(row[:class]).to include("aff-ended")
+ end
+
+ it "strikes it through for an end date of today, which the date rule alone calls active" do
+ set_end_date(browser_today)
+
+ expect(row[:class]).to include("aff-ended")
+ end
+
+ def checkbox = find("[data-inactive-toggle-target='inactiveCheckbox']")
+
+ describe "the Inactive checkbox following the end date" do
+ it "ticks itself for a past end date, so the flag submits with the form" do
+ expect(checkbox).not_to be_checked
+
+ set_end_date(1.month.ago.to_date.strftime("%Y-%m-%d"))
+
+ expect(checkbox).to be_checked
+ end
+
+ it "ticks itself for an end date of today, which the date rule alone calls active" do
+ set_end_date(browser_today)
+
+ expect(checkbox).to be_checked
+ end
+
+ it "unticks itself for a future end date" do
+ set_end_date(1.month.ago.to_date.strftime("%Y-%m-%d"))
+ expect(checkbox).to be_checked
+
+ set_end_date(1.year.from_now.to_date.strftime("%Y-%m-%d"))
+
+ expect(checkbox).not_to be_checked
+ end
+
+ it "unticks itself when the end date is cleared" do
+ set_end_date(1.month.ago.to_date.strftime("%Y-%m-%d"))
+
+ set_end_date("")
+
+ expect(checkbox).not_to be_checked
+ end
+
+ # The point of the whole mechanism: the flag has to survive the round trip.
+ it "persists inactive after saving an end date of today" do
+ set_end_date(browser_today)
+ click_button "Save changes"
+
+ expect(page).to have_text("successfully updated")
+ expect(affiliation.reload.inactive).to be(true)
+ expect(affiliation).not_to be_active
+ end
+
+ # Only the end date drives the box; a hand tick with no end date must stick.
+ it "leaves a hand-ticked box alone" do
+ checkbox.click
+
+ expect(checkbox).to be_checked
+ expect(row[:class]).to include("aff-ended")
+ end
+ end
+
+ it "switches the hue when the title stops being Facilitator" do
+ title = find("[data-inactive-toggle-target~='title']")
+ page.execute_script(
+ "arguments[0].value = 'Counselor'; arguments[0].dispatchEvent(new Event('input', { bubbles: true }))",
+ title
+ )
+
+ expect(row[:class]).to include("bg-blue-50")
+ end
+end
diff --git a/spec/system/affiliation_filter_tabs_spec.rb b/spec/system/affiliation_filter_tabs_spec.rb
new file mode 100644
index 0000000000..ae144107b6
--- /dev/null
+++ b/spec/system/affiliation_filter_tabs_spec.rb
@@ -0,0 +1,88 @@
+require "rails_helper"
+
+RSpec.describe "Affiliation Active/Inactive tabs", type: :system do
+ let(:admin) { create(:user, :admin) }
+ let!(:person) { create(:person, user: admin) }
+ let!(:current_org) { create(:organization, name: "Currently Facilitating") }
+ let!(:ended_org) { create(:organization, name: "Long Since Ended") }
+
+ before do
+ driven_by(:selenium_chrome_headless)
+ create(:affiliation, person: person, organization: current_org,
+ title: "Facilitator", start_date: 2.years.ago.to_date)
+ create(:affiliation, person: person, organization: ended_org, title: "Facilitator",
+ start_date: 4.years.ago.to_date, end_date: 1.year.ago.to_date)
+ sign_in admin
+ visit edit_person_path(person)
+ end
+
+ def row_for(name)
+ find("[data-paginated-fields-target='item']", text: name, visible: :all)
+ end
+
+ it "shows only the active affiliation on the Active tab" do
+ expect(row_for("Currently Facilitating")).to be_visible
+ expect(row_for("Long Since Ended")).not_to be_visible
+ end
+
+ it "swaps to the ended one on the Inactive tab, and back" do
+ find("label", text: "Inactive").click
+
+ expect(row_for("Long Since Ended")).to be_visible
+ expect(row_for("Currently Facilitating")).not_to be_visible
+
+ find("label", text: "Active").click
+
+ expect(row_for("Currently Facilitating")).to be_visible
+ expect(row_for("Long Since Ended")).not_to be_visible
+ end
+
+ # The tabs wrapper is a NAMED group. An unnamed one collided with the per-row
+ # comment icon's own `.group`, so hovering one icon opened every row's tooltip.
+ # Both commented rows must be on the SAME tab, or the hidden one masks the bug.
+ it "opens only the hovered row's comment tooltip" do
+ second = create(:affiliation, person: person, organization: create(:organization),
+ title: "Facilitator", start_date: 1.year.ago.to_date)
+ person.affiliations.find_by(organization: current_org).comments.create!(body: "Note about the first")
+ second.comments.create!(body: "Note about the second")
+ visit edit_person_path(person)
+
+ expect(all(".fa-comment").size).to eq(2)
+
+ all(".fa-comment").first.hover
+
+ expect(page).to have_text("Note about the first", wait: 2)
+ expect(page).to have_no_text("Note about the second")
+ end
+
+ it "opens the affiliation editor's comments when the icon is clicked" do
+ affiliation = person.affiliations.find_by(organization: current_org)
+ affiliation.comments.create!(body: "Why this ended")
+ visit edit_person_path(person)
+
+ link = find(".fa-comment").find(:xpath, "..")
+ expect(link[:href]).to end_with("#comments-section")
+ expect(link[:target]).to eq("_blank")
+
+ visit link[:href]
+
+ expect(page).to have_css("#comments-section")
+ # Comments read as text until "Edit comments" is clicked.
+ expect(page).to have_text("Why this ended")
+ end
+
+ # The point of doing this client-side: a row you end mid-edit must not vanish
+ # from under you. It restyles in place and only changes tab after a save.
+ it "keeps a row you end on the Active tab, restyled" do
+ row = row_for("Currently Facilitating")
+ end_date = row.find("input[data-inactive-toggle-target~='endDate']", visible: :all)
+
+ page.execute_script(
+ "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('change', { bubbles: true }))",
+ end_date, 1.month.ago.to_date.strftime("%Y-%m-%d")
+ )
+
+ expect(row).to be_visible
+ expect(row).to have_css(".aff-ended")
+ end
+end
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index 19beebcdbf..aa4d2c22cd 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -112,6 +112,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/admin/data_health/index.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",
@@ -121,6 +122,8 @@
"app/views/events/signins.html.erb" => "admin-or-owner bg-blue-100",
"app/views/events/sample_ticket.html.erb" => "admin-or-owner bg-blue-100",
"app/views/events/bulk_payments/index.html.erb" => "admin-or-owner bg-blue-100",
+ "app/views/events/reconcile_affiliations/index.html.erb" => "admin-or-owner bg-blue-100",
+ "app/views/events/reconcile_affiliations/confirm.html.erb" => "admin-or-owner bg-blue-100",
"app/views/events/edit_staff.html.erb" => "admin-or-owner bg-blue-100",
"app/views/events/recipients.html.erb" => "admin-or-owner bg-blue-100",
"app/views/events/registrants.html.erb" => "admin-or-owner bg-blue-100",