diff --git a/AGENTS.md b/AGENTS.md index 375ebb393d..b65ed8e2eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,7 @@ action, or `authorize! :workshop, to: :summary?`). - `Analytics::AhoyTracker` — Coordinates ahoy event tracking - `Analytics::PersonActivityEvents` — Aggregates Ahoy events for a person, their user, and associated data (powers the person edit History card + `person_id` filter on the Ahoy activities index) +- `DataHealth` + `DataHealth::Check` subclasses — Consistency checks spanning the whole database, rendered on the admin Data health page (`/admin/data_health`). Each subclass supplies `scope` (a relation, so counting doesn't load the table), `title`/`explanation`, and either a `repair!` or nothing — report-only is the default, because a wrong row isn't always one we know how to put right. Register a new check in `DataHealth::CHECKS`. Current checks: facilitator affiliations minted by non-training registrations (deletes), affiliations whose minting registration belongs to another org (unlinks, restoring ADR-0002 D2a's invariant), and legacy organization-status drift (reports only) ### Business Logic - `AffiliationPeriods` — Merges an organization's affiliation date-intervals into periods, at year precision for "Affiliated since" (e.g. "2010-2012, 2026") or month precision for "Art program since" (e.g. "Aug 2015 – Jun 2018, Feb 2024"); rendered server-side on the org show/index/edit pages, with `affiliation_dates_controller.js` mirroring it only to live-update the edit form @@ -255,6 +256,8 @@ action, or `authorize! :workshop, to: :summary?`). ### Affiliations - `AffiliationServices::CreateFromRegistration` — On registration / org linking, creates a "job affiliation" with the typed title (when present) plus a standing "Facilitator" affiliation, in one transaction. Skips the facilitator one only when the person already has an active-or-pending affiliation titled exactly "Facilitator" with that org (a current one or one dated to a future training); an ended facilitator affiliation gets a fresh second one. Dedupe is by title + org + dates, so a job title like "Lead Facilitator" still gets its own Facilitator affiliation. Accepts an optional `organization_address:` and sets it on every affiliation it creates (the registrant's typed agency address, upserted onto the org); when an affiliation already exists and is skipped, it backfills that address onto the existing one only if it has none (an admin-set address is never overwritten) +- `AffiliationServices::ReconcilePerson` — **The single classifier** for facilitator affiliations, per `(person, organization)` in the context of one `event:`. `#plan` returns a `Decision` (`affiliation`, `action`, `reason`) per affiliation in scope, plus a create/no-create decision when the person has none — no writes. Actions: `:create` (pre-event for anyone, post-event only for attendees), `:deactivate` (**same-days** it — `end_date := start_date` plus an explicit `inactive: true`, since the model's date rule alone still reads a row ending today or later as active), `:reactivate`, `:delete` (non-training event: a row auto-created off it), or `:noop` with a reason. Completion is "any `attended` facilitator-training registration to that org", so no-showing one training but attending another keeps them active; deactivation waits for the governing training to end, so a pre-event run never deactivates. `#perform(action, affiliation:)` applies one decision, `#call` applies them all. `include_unowned:` is the auto-vs-manual gate — false (default) touches only rows the registration flow minted, true reconciles hand-entered rows too. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Walks the event's registrants and their linked orgs, iterating `ReconcilePerson` (with `include_unowned: true`, one memoized instance per person+org) and turning its decisions into individually-selectable rows — every rule lives in `ReconcilePerson`, every key/grouping/timestamp concern here. `#actionable_person_groups` groups actionable rows by person (with attendance registration and other-org facilitator affiliations for context); `#skipped_reason_sections` groups no-action rows by reason. `#planned_changes(outcome:)` and `#apply(outcome:)` take an `outcome` map `{ row.key => choice }` (choice is the action or "keep") — the confirm screen previews planned `Change`s, apply performs them and stamps `affiliations_reconciled_at`. Job affiliations are never touched. The controller is a two-step flow: `index` (edit) → `confirm` (preview, no writes) → `create` (perform). ### Sectors diff --git a/app/controllers/admin/data_health_controller.rb b/app/controllers/admin/data_health_controller.rb new file mode 100644 index 0000000000..afa1ff01e0 --- /dev/null +++ b/app/controllers/admin/data_health_controller.rb @@ -0,0 +1,24 @@ +module Admin + # Data health: consistency checks that span the whole database, each with a count + # and — where a correct fix exists — a button to apply it. See DataHealth::Check. + class DataHealthController < ApplicationController + include AhoyTracking + + def index + authorize! :data_health, to: :index? + track_view("admin.data_health") + + @checks = DataHealth.checks + end + + def repair + authorize! :data_health, to: :repair? + + check = DataHealth.find(params[:check]) + return redirect_to admin_data_health_path, alert: "Unknown check." unless check&.repairable? + + repaired = check.repair! + redirect_to admin_data_health_path, notice: check.repaired_message(repaired) + end + end +end diff --git a/app/controllers/affiliations_controller.rb b/app/controllers/affiliations_controller.rb index da1278bf2c..7f7f4875a7 100644 --- a/app/controllers/affiliations_controller.rb +++ b/app/controllers/affiliations_controller.rb @@ -7,6 +7,8 @@ def edit def update authorize! @affiliation + # This form always posts the Inactive checkbox, so whatever it sends is deliberate. + @affiliation.inactive_supplied = affiliation_params.key?(:inactive) @affiliation.assign_attributes(affiliation_params) @affiliation.comments.select(&:new_record?).each { |c| c.created_by = current_user; c.updated_by = current_user } @affiliation.comments.select { |c| c.persisted? && c.body_changed? }.each { |c| c.updated_by = current_user } @@ -64,14 +66,14 @@ def set_affiliation def affiliation_params params.require(:affiliation).permit( - :person_id, :organization_id, :title, :start_date, :end_date, :primary_contact, :organization_address_id, + :person_id, :organization_id, :title, :start_date, :end_date, :inactive, :primary_contact, :organization_address_id, comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ] ) end # Return to whichever edit page the gear was clicked from, scrolled to the row # (or the affiliations section after a delete removes the row). - def affiliation_return_path(anchor: helpers.dom_id(@affiliation)) + def affiliation_return_path(anchor: @affiliation.decorate.return_anchor) case params[:return_to] when "person" edit_person_path(params[:origin_id], anchor: anchor) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 6dd3206172..6422552cfd 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -121,6 +121,7 @@ def update when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other when "attendees" then redirect_to attendees_events_path, notice: notice, status: :see_other when "roster" then redirect_to roster_event_path(@event_registration.event), notice: notice, status: :see_other + when "reconcile_affiliations" then redirect_to reconcile_affiliations_event_path(@event_registration.event, anchor: helpers.dom_id(@event_registration, :attendance_status)), notice: notice, status: :see_other # Two ways back to the recipients page: the shout-outs section (the # feature-a-shout-out flow) or the recipient's own card (their name). when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb new file mode 100644 index 0000000000..80d9769915 --- /dev/null +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -0,0 +1,59 @@ +module Events + # The "Reconcile affiliations" bulk action: index (edit) → confirm (preview, no + # writes) → create (perform). `AffiliationServices::ReconcilePerson` holds the rules. + class ReconcileAffiliationsController < ApplicationController + include AhoyTracking + before_action :set_event + + def index + authorize! @event, to: :reconcile_affiliations? + track_view("events.reconcile_affiliations", { event_id: @event.id }) + + reconcile = AffiliationServices::ReconcileEvent.new(@event) + @person_groups = reconcile.actionable_person_groups + @skipped_sections = reconcile.skipped_reason_sections + @has_rows = reconcile.any_rows? + # Restore the admin's per-row radio choices when they come back from confirm. + @pre_outcome = params[:outcome] + @event = @event.decorate + end + + def confirm + authorize! @event, to: :reconcile_affiliations? + + @outcome = outcome_params + @changes = AffiliationServices::ReconcileEvent.new(@event).planned_changes(outcome: @outcome) + @event = @event.decorate + + redirect_to reconcile_affiliations_event_path(@event), notice: "Nothing selected to change." and return if @changes.empty? + end + + def create + authorize! @event, to: :reconcile_affiliations? + + changed = AffiliationServices::ReconcileEvent.new(@event).apply(outcome: outcome_params) + redirect_to registrants_event_path(@event), notice: reconcile_notice(changed) + end + + private + + def set_event + @event = Event.find(params[:id]) + end + + # Dynamic keys, so read as a plain string hash (never mass-assigned); the service + # only acts on known choices. + def outcome_params + raw = params[:outcome] + return {} unless raw.respond_to?(:each_pair) + + raw.each_pair.map { |key, value| [ key.to_s, value.to_s ] }.to_h + end + + def reconcile_notice(changed) + return "No affiliations needed reconciling." if changed.zero? + + "Reconciled #{changed} #{'affiliation'.pluralize(changed)}." + end + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 3cd7e9e08d..8866f7f802 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1098,7 +1098,7 @@ def event_registrations_csv_string def event_registration_csv_row(registration, cost_required, include_ce = false) person = registration.registrant orgs = person.affiliations - .select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) } + .select(&:active?) .map(&:organization).compact.uniq org_names = orgs.map(&:name).join("; ") total_cents = registration.allocations_sum diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index bd38693390..4e73558f05 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -170,8 +170,7 @@ def set_form_variables affiliations = affiliations.includes(:person) unless affiliations.loaded? sorted = affiliations.to_a .sort_by { |affiliation| - expired = affiliation.inactive? || (affiliation.end_date.present? && affiliation.end_date < Date.current) - [ expired ? 1 : 0, + [ affiliation.active? ? 0 : 1, affiliation.person&.first_name.to_s.downcase, affiliation.person&.last_name.to_s.downcase ] } @@ -252,6 +251,7 @@ def organization_params :id, :person_id, :inactive, + :inactive_supplied, :primary_contact, :title, :start_date, diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 4314137b9e..7b779aba80 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -339,8 +339,7 @@ def set_form_variables affiliations = affiliations.includes(:organization) unless affiliations.loaded? sorted = affiliations.to_a .sort_by { |affiliation| - expired = affiliation.inactive? || (affiliation.end_date.present? && affiliation.end_date < Date.current) - [ expired ? 1 : 0, + [ affiliation.active? ? 0 : 1, affiliation.organization&.name.to_s.downcase ] } @person.affiliations.proxy_association.target.replace(sorted) @@ -632,6 +631,7 @@ def person_params :organization_id, :title, :inactive, + :inactive_supplied, :primary_contact, :start_date, :end_date, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index beb31a2175..adf1af5c78 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -429,7 +429,7 @@ def user_params ##### comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], - affiliations_attributes: [ :id, :organization_id, :position, :title, :inactive, :primary_contact, :start_date, :end_date, :_destroy ], + affiliations_attributes: [ :id, :organization_id, :position, :title, :inactive, :inactive_supplied, :primary_contact, :start_date, :end_date, :_destroy ], ) end end diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 86f3f24dc7..2c16e8b205 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -2,4 +2,18 @@ class AffiliationDecorator < ApplicationDecorator def detail(length: nil) "#{person.full_name}: #{title.presence || position} - #{organization.name}" end + + # Where a back link should land on the person/organization editor. An inactive + # row sits on the Inactive tab, so jumping to the row itself would scroll to + # something the page isn't showing — land on the section instead. + def return_anchor + active? ? h.dom_id(object) : "affiliations" + end + + # e.g. "Oct 13, 2026 – present" + def date_range + start = start_date ? start_date.strftime("%b %-d, %Y") : "no start date" + finish = end_date ? end_date.strftime("%b %-d, %Y") : "present" + "#{start} – #{finish}" + end end diff --git a/app/frontend/javascript/controllers/inactive_toggle_controller.js b/app/frontend/javascript/controllers/inactive_toggle_controller.js index 5dad1705e6..8be0de1707 100644 --- a/app/frontend/javascript/controllers/inactive_toggle_controller.js +++ b/app/frontend/javascript/controllers/inactive_toggle_controller.js @@ -5,18 +5,47 @@ import { Controller } from "@hotwired/stimulus"; // is the saturation (active = full, inactive = super-light). Inactive rows also // strike their fields (.aff-ended). export default class extends Controller { - static targets = ["endDate", "title", "row", "accentBar", "valueField"] + static targets = ["endDate", "title", "row", "accentBar", "valueField", "inactiveField", "suppliedField", "inactiveCheckbox"] static values = { expired: Boolean } connect() { + // A row flagged inactive whose dates still read as current is one where the + // flag is doing real work, so mark it authoritative up front — otherwise an + // unrelated date edit would let the server re-derive it away. + if (this.expiredValue && !this.endsOnOrBeforeToday()) this.markSupplied(); if (this.hasTitleTarget) this.updateBorder(); else this.apply(); } + // Entering an end date of today or earlier ticks Inactive for you, so the flag + // travels with the form — the date rule alone compares strictly and would still + // call today "active". Clearing the date (or a future one) unticks it again. + // + // Only the end date drives this. Ticking the box by hand has to stick, which it + // would not if the checkbox's own action recomputed it from the dates. + endDateChanged() { + const ended = this.endsOnOrBeforeToday(); + if (this.hasInactiveCheckboxTarget) this.inactiveCheckboxTarget.checked = ended; + if (this.hasInactiveFieldTarget) this.inactiveFieldTarget.value = ended ? "1" : "0"; + this.markSupplied(); + this.apply(); + } + toggle() { this.apply(); } + markSupplied() { + if (this.hasSuppliedFieldTarget) this.suppliedFieldTarget.value = "1"; + } + + endsOnOrBeforeToday() { + const value = this.hasEndDateTarget ? this.endDateTarget.value : ""; + if (!value) return false; + + return new Date(value) <= new Date(new Date().toDateString()); + } + updateBorder() { if (!this.hasTitleTarget) return; if (this.hasAccentBarTarget) { @@ -87,6 +116,12 @@ export default class extends Controller { // With an end date, compute from it (live); without one, the JS can't see the // server's inactive flag, so trust the server-rendered `expired` value. isPast() { + // The standalone editor has an explicit Inactive checkbox, and on that form it + // is the whole truth: ticked, or ended on/before today. + if (this.hasInactiveCheckboxTarget) { + return this.inactiveCheckboxTarget.checked || this.endsOnOrBeforeToday(); + } + const value = this.hasEndDateTarget ? this.endDateTarget.value : ""; if (value) return new Date(value) < new Date(new Date().toDateString()); return this.expiredValue; diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index cd4b6491ba..fb1b1875dd 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -28,6 +28,7 @@ def system_cards def user_content_cards [ custom_card("Portal activity", admin_activities_counts_path, icon: "📊"), + custom_card("Data health", admin_data_health_path, icon: "🩺", color: :sky, intensity: 100), custom_card("Bookmarks tally", tally_bookmarks_path, icon: "🔖"), model_card(:notifications, icon: "🔔", title: t("communications.title")), custom_card("Event reports", reports_events_path, icon: "📊", color: :blue), diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index 94f9fd650c..a2b6517631 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -21,6 +21,17 @@ class Affiliation < ApplicationRecord # have this link. belongs_to :event_registration, optional: true, inverse_of: :affiliations + # Set by a caller that supplied `inactive` deliberately (the standalone editor's + # checkbox, or a nested row whose end date the admin just changed). Re-submitting + # the value it already holds isn't a change, so without this the date rule below + # would quietly undo a hand-set flag on the next date edit. Cast because it + # arrives from a form as "0"/"1", and "0" is truthy in Ruby. + attr_reader :inactive_supplied + + def inactive_supplied=(value) + @inactive_supplied = ActiveModel::Type::Boolean.new.cast(value) + end + has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } @@ -44,7 +55,7 @@ class Affiliation < ApplicationRecord # when a view must reflect a fixed point in time — e.g. the event dashboard # reporting organizations as they stood at the time of the event, so the # numbers don't drift as affiliations end after the fact. - scope :active_on, ->(date) { + scope :active_by_date_on, ->(date) { where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", date) .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", date) } @@ -53,7 +64,7 @@ class Affiliation < ApplicationRecord # "Lead Facilitator" or "facilitator" are deliberately excluded. BINARY forces # a case-sensitive comparison under MySQL's default case-insensitive collation; # TRIM mirrors the in-memory #facilitator? strip so stray whitespace still matches. - scope :facilitators, -> { where("BINARY TRIM(title) = ?", "Facilitator") } + scope :facilitators, -> { where("BINARY TRIM(affiliations.title) = ?", "Facilitator") } # Affiliations whose #status_on(date) equals the given status, expressed in SQL # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with @@ -163,7 +174,10 @@ def sole_address_id_for_new_organization addresses.first.id if addresses&.one? end + # An explicit assignment wins: the date rule alone still reads a row ending today + # or later as active. def set_inactive_from_dates + return if inactive_changed? || inactive_supplied return unless end_date_changed? || start_date_changed? self.inactive = end_date.present? && end_date < Date.current diff --git a/app/models/event.rb b/app/models/event.rb index c52483bb92..e72e1fd0fb 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -168,6 +168,13 @@ def ended? end_date < Time.current end + # A registrant changed since the last reconciliation, so it's worth re-running. + def affiliations_reconciliation_stale? + return false unless affiliations_reconciled_at + + event_registrations.where("event_registrations.updated_at > ?", affiliations_reconciled_at).exists? + end + # Whether the event shows as a full card on the events index. Unpublished # events and events that ended more than a month ago collapse into the compact # archive list instead of taking up a card. diff --git a/app/models/organization.rb b/app/models/organization.rb index 0302c9bf91..c9dfc1df0e 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -79,11 +79,9 @@ def self.awbw # Scopes # See TagFilterable, Trendable, WindowsTypeFilterable - scope :active, -> { - status_active = joins(:organization_status).where(organization_statuses: { name: "Active" }) - affiliation_active = where(id: Affiliation.active.select(:organization_id)) - status_active.or(affiliation_active) - } + # An org is active because someone is affiliated there, not because the legacy + # status column says so (ADR-0001 D3, ADR-0002 D4). + scope :active, -> { where(id: Affiliation.active.select(:organization_id)) } scope :address, ->(address) do return all if address.blank? terms = address.to_s.strip.split(/[\s,]+/).reject(&:blank?) @@ -249,10 +247,11 @@ def organization_locality end end - def published? # needed for my_bookmarks - return true if organization_status&.name == "Active" - # #active? is the in-memory twin of the `active` scope, so a list page that - # preloaded affiliations doesn't query once per row. + # Needed for my_bookmarks. Keys off affiliations only — the stored + # organization_status has drifted and is never consulted (ADR-0002 D4). + # The loaded branch is the in-memory twin of the `active` scope, so a list page + # that preloaded affiliations doesn't query once per row. + def published? return affiliations.any?(&:active?) if affiliations.loaded? affiliations.active.exists? diff --git a/app/policies/admin/data_health_policy.rb b/app/policies/admin/data_health_policy.rb new file mode 100644 index 0000000000..59e05a4dc6 --- /dev/null +++ b/app/policies/admin/data_health_policy.rb @@ -0,0 +1,14 @@ +module Admin + class DataHealthPolicy < ApplicationPolicy + def index? + admin? + end + + # Repairs delete or rewrite rows across the whole database. Same bar as the + # page itself — `admin?` is already super-user only — but spelled out so + # tightening one without the other is a deliberate edit. + def repair? + admin? + end + end +end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 602da91fe8..ab3457a1ee 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -119,6 +119,10 @@ def bulk_payments? manage? end + def reconcile_affiliations? + manage? + end + def invoice? manage? end diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb new file mode 100644 index 0000000000..6d3b52418d --- /dev/null +++ b/app/services/affiliation_services/reconcile_event.rb @@ -0,0 +1,136 @@ +module AffiliationServices + # Event-level orchestration for the "Reconcile affiliations" bulk action: asks + # `ReconcilePerson` about each registrant's linked orgs and turns its decisions + # into individually-selectable rows. Every rule lives there; keys, grouping and + # the timestamp live here. Job affiliations are never touched. + class ReconcileEvent + Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key, keyword_init: true) do + def actionable? + action != :noop + end + end + + def initialize(event) + @event = event + end + + # `other_facilitators` are the person's active facilitator affiliations with orgs + # they did NOT link on this event. + def actionable_person_groups + all_rows.select(&:actionable?).group_by(&:person).map do |person, rows| + { person:, registration: rows.first.registration, rows:, other_facilitators: other_facilitators(person) } + end + end + + # "Active — attended" sorts second-to-last and the trivial "no affiliation" bucket + # last; the rest alphabetical. + def skipped_reason_sections + grouped = all_rows.reject(&:actionable?).group_by(&:reason) + grouped.keys.sort_by { |reason| [ reason_rank(reason), reason ] }.map { |reason| [ reason, grouped[reason] ] } + end + + def reason_rank(reason) + case reason + when ReconcilePerson::ACTIVE_ATTENDED then 8 + when ReconcilePerson::NOT_ATTENDED then 9 + else 0 + end + end + + def any_rows? + all_rows.any? + end + + Change = Struct.new(:person, :organization, :affiliation, :action, keyword_init: true) + + # One radio choice per row: the action itself, or "keep". + ACTION_FOR_CHOICE = { "deactivate" => :deactivate, "delete" => :delete, "create" => :create }.freeze + + # What the `{ row.key => choice }` map will change, for the confirmation screen. + def planned_changes(outcome:) + outcome = outcome.to_h + + all_rows.select(&:actionable?).filter_map do |row| + action = ACTION_FOR_CHOICE[outcome[row.key]] + next unless action + + Change.new(person: row.person, organization: row.organization, affiliation: row.affiliation, action:) + end + end + + # Apply each row's chosen outcome, stamp the event, and return the number of + # rows actually changed ("keep"/unknown choices are no-ops). + def apply(outcome:) + outcome = outcome.to_h + + changed = all_rows.count do |row| + row.actionable? && perform_outcome(row, outcome[row.key]) + end + + @event.update!(affiliations_reconciled_at: Time.current) + changed + end + + private + + def all_rows + @all_rows ||= registrations_by_person.flat_map do |person, registrations| + registration = registrations.first + linked_organizations(registrations).flat_map do |organization| + reconciler(person, registration, organization).plan.map do |decision| + row_for(person, registration, organization, decision) + end + end + end + end + + def row_for(person, registration, organization, decision) + Row.new(person:, registration:, organization:, affiliation: decision.affiliation, + action: decision.action, reason: decision.reason, + key: row_key(person, organization, decision)) + end + + # Stable per-row identity for the outcome map: the affiliation itself when there + # is one, else the (person, org) pair the row would create an affiliation for. + def row_key(person, organization, decision) + return "aff:#{decision.affiliation.id}" if decision.affiliation + + "#{decision.action == :create ? 'create' : 'none'}:#{person.id}:#{organization.id}" + end + + def perform_outcome(row, choice) + action = ACTION_FOR_CHOICE[choice] + return false unless action + + reconciler(row.person, row.registration, row.organization).perform(action, affiliation: row.affiliation) + end + + # One reconciler per (person, org) — reused for both planning and applying so the + # attendance lookup behind each decision runs once. + def reconciler(person, registration, organization) + @reconcilers ||= {} + @reconcilers[[ person.id, organization.id ]] ||= ReconcilePerson.new( + person:, organization:, event: @event, registration:, include_unowned: true + ) + end + + def other_facilitators(person) + person.affiliations.active.facilitators + .where.not(organization_id: linked_org_ids(person)) + .includes(:organization) + .to_a + end + + def linked_org_ids(person) + linked_organizations(registrations_by_person[person]).map(&:id) + end + + def linked_organizations(registrations) + registrations.flat_map(&:organizations).uniq + end + + def registrations_by_person + @registrations_by_person ||= @event.event_registrations.includes(:registrant, :organizations).group_by(&:registrant) + end + end +end diff --git a/app/services/affiliation_services/reconcile_person.rb b/app/services/affiliation_services/reconcile_person.rb new file mode 100644 index 0000000000..1ca4ccb629 --- /dev/null +++ b/app/services/affiliation_services/reconcile_person.rb @@ -0,0 +1,191 @@ +module AffiliationServices + # The single classifier for one person's facilitator affiliations with one + # organization, in the context of one event. `ReconcileEvent` iterates it across + # an event's registrants; it also stands alone for a single person. + # + # A person is a facilitator of an org iff they have at least one `attended` + # registration to that org from a facilitator training — across ALL their + # training registrations, so no-showing one but attending another keeps them + # active. + # + # `include_unowned: false` (the default) touches only rows the registration flow + # minted, leaving hand-created ones alone; the bulk page passes true. + class ReconcilePerson + Decision = Struct.new(:affiliation, :action, :reason, keyword_init: true) do + def actionable? + action != :noop + end + end + + ACTIVE_ATTENDED = "Active — attended".freeze + TRAINING_PENDING = "Training hasn't ended yet".freeze + ALREADY_DEACTIVATED = "Already deactivated — didn't attend".freeze + ALREADY_ENDED = "Already ended — not by reconciliation".freeze + LAPSED = "Ended — a return is recorded as a new affiliation".freeze + # Topic on the comment reconciliation leaves behind, so a row can say why it + # ended without a dedicated column (ADR-0002 D6b). + COMMENT_TOPIC = "Reconciliation".freeze + NOT_ATTENDED = "Didn't attend — no affiliation created".freeze + + def self.call(person:, organization:, event:, registration: nil, include_unowned: false) + new(person:, organization:, event:, registration:, include_unowned:).call + end + + def initialize(person:, organization:, event:, registration: nil, include_unowned: false) + @person = person + @organization = organization + @event = event + @registration = registration + @include_unowned = include_unowned + end + + # One Decision per affiliation in scope, plus a create/no-create decision when + # the person has none. No writes. + def plan + @plan ||= @event.facilitator_training? ? training_plan : non_training_plan + end + + # Returns whether anything changed, so callers can count real changes. + def perform(action, affiliation: nil) + return false if affiliation.nil? && action != :create + + case action + when :create then create_and_note + when :delete then affiliation.destroy! + when :deactivate then deactivate(affiliation) + else return false + end + true + end + + def call + plan.select(&:actionable?).filter_map do |decision| + decision.action if perform(decision.action, affiliation: decision.affiliation) + end + end + + private + + def completed_training? + return @completed_training if defined?(@completed_training) + + @completed_training = @person.event_registrations.attended + .joins(:event).where(events: { facilitator_training: true }) + .joins(:event_registration_organizations) + .where(event_registration_organizations: { organization_id: @organization.id }) + .exists? + end + + def deactivate(affiliation) + ends_on = deactivation_end_date(affiliation) + affiliation.update!(end_date: ends_on, inactive: true) + note(affiliation, "Ended #{ends_on.strftime('%b %-d, %Y')} and marked inactive by reconciliation " \ + "for #{@event.title} — no attended facilitator training for #{@organization.name} on record.") + end + + def create_and_note + created = @person.affiliations.facilitators.where(organization: @organization).pluck(:id) + create_affiliation + fresh = @person.affiliations.facilitators.where(organization: @organization).where.not(id: created) + fresh.each { |affiliation| note(affiliation, "Created by reconciliation for #{@event.title}.") } + end + + # Why a row changed, on the affiliation's own comments rather than a dedicated + # column — the edit page and its history already surface them (ADR-0002 D6b). + def note(affiliation, body) + affiliation.comments.create!(topic: COMMENT_TOPIC, body: body, + created_by: Current.user, updated_by: Current.user) + end + + def ended_by_reconciliation?(affiliation) + affiliation.comments.any? { |comment| comment.topic == COMMENT_TOPIC } + end + + def minted_here?(affiliation) + affiliation.event_registration&.event_id == @event.id + end + + # Where a deactivation ends the row (ADR-0002 D6). The row this training minted + # is an assumption that never came true, so it collapses to nothing. Any older + # row records facilitation that really happened: it ends at this training, so + # the years before it survive and anchored program status doesn't move. + def deactivation_end_date(affiliation) + return affiliation.start_date if minted_here?(affiliation) && affiliation.start_date + + [ @event.start_date&.to_date || Date.current, affiliation.start_date ].compact.max + end + + # A non-training event confers no facilitation, so it only removes what was + # auto-created off it. + def non_training_plan + facilitator_affiliations.filter_map do |affiliation| + next unless minted_here?(affiliation) + + Decision.new(affiliation:, action: :delete) + end + end + + def training_plan + decisions = reconcilable_affiliations.map { |affiliation| classify(affiliation) } + decisions << creation_decision if needs_affiliation? + decisions + end + + # Someone with no active facilitator affiliation needs one when they have never + # had one, or when they completed a training here and are returning after a + # lapse — the return is a NEW row, never a resurrected one (ADR-0002 D6a). + def needs_affiliation? + return false unless @registration + return false if facilitator_affiliations.any?(&:active?) + + facilitator_affiliations.empty? || completed_training? + end + + def classify(affiliation) + if completed_training? + return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if affiliation.active? + + Decision.new(affiliation:, action: :noop, reason: LAPSED) + elsif !affiliation.active? + reason = ended_by_reconciliation?(affiliation) ? ALREADY_DEACTIVATED : ALREADY_ENDED + Decision.new(affiliation:, action: :noop, reason:) + elsif deactivation_ready?(affiliation) + Decision.new(affiliation:, action: :deactivate) + else + Decision.new(affiliation:, action: :noop, reason: TRAINING_PENDING) + end + end + + # Waits for the governing training to end, so a pre-event run never deactivates. + # A hand-entered row has no source training, so it waits on this event. + def deactivation_ready?(affiliation) + affiliation.event_registration_id ? affiliation.event_registration&.event&.ended? : @event.ended? + end + + def creation_decision + return Decision.new(action: :create) if !@event.ended? || completed_training? + + Decision.new(action: :noop, reason: NOT_ATTENDED) + end + + def create_affiliation + CreateFromRegistration.call( + person: @person, organization: @organization, facilitator_training: true, + training_date: @event.start_date, event_registration: @registration + ) + end + + def reconcilable_affiliations + return facilitator_affiliations if @include_unowned + + facilitator_affiliations.select(&:event_registration_id) + end + + def facilitator_affiliations + @facilitator_affiliations ||= @person.affiliations.facilitators + .where(organization: @organization) + .includes(:comments, event_registration: :event) + .to_a + end + end +end diff --git a/app/services/analytics/person_activity_events.rb b/app/services/analytics/person_activity_events.rb index c879e6c2dd..894a0ab755 100644 --- a/app/services/analytics/person_activity_events.rb +++ b/app/services/analytics/person_activity_events.rb @@ -5,6 +5,8 @@ module Analytics # "Associated records" panel. Powers the person edit "History" card and the # `person_id` filter on the admin Ahoy activities index. class PersonActivityEvents + PAYMENT_TYPES = %w[ Payment FilemakerPayment ExternalProcessorPayment CheckPayment CashPayment ].freeze + def initialize(person) @person = person end @@ -45,7 +47,8 @@ def resource_ids_by_type "ContinuingEducationRegistration" => ContinuingEducationRegistration.where(event_registration_id: @person.event_registrations.select(:id)).select(:id), "FormSubmission" => @person.form_submissions.select(:id), "Grant" => @person.grants.select(:id), - "Payment" => Payment.where(person_id: @person.id).select(:id), + # Lifecycle events record the STI subclass ("CashPayment", …), not "Payment". + PAYMENT_TYPES => Payment.where(person_id: @person.id).select(:id), "Scholarship" => @person.scholarships.select(:id), "TopicSubscription" => @person.topic_subscriptions.select(:id), "CommunityNews" => @person.community_news_as_author.select(:id), diff --git a/app/services/data_health.rb b/app/services/data_health.rb new file mode 100644 index 0000000000..1c43726e43 --- /dev/null +++ b/app/services/data_health.rb @@ -0,0 +1,16 @@ +module DataHealth + # Every check on the admin Data health page, in the order it renders. Adding one + # is a Check subclass plus a line here. + CHECKS = [ + FacilitatorAffiliationsFromNonTrainings, + MisalignedAffiliationProvenance, + LegacyOrganizationStatusDrift + ].freeze + + def self.checks = CHECKS.map(&:new) + + def self.find(key) + klass = CHECKS.find { |check| check.key == key.to_s } + klass&.new + end +end diff --git a/app/services/data_health/check.rb b/app/services/data_health/check.rb new file mode 100644 index 0000000000..c2d0542aa6 --- /dev/null +++ b/app/services/data_health/check.rb @@ -0,0 +1,61 @@ +module DataHealth + # Base for one data-consistency check on the admin Data health page. + # + # A check answers three things: which rows are wrong (`scope`), how to say that + # in a sentence (`title` / `explanation`), and whether it can put them right + # (`repairable?` / `repair!`). Everything on the page is derived from those, so + # adding a check is one subclass plus a line in `DataHealth::CHECKS`. + # + # `scope` must be a relation — the page counts it without loading, and only the + # first `PREVIEW_LIMIT` rows are rendered. + class Check + PREVIEW_LIMIT = 25 + + def self.key = name.demodulize.underscore + + def key = self.class.key + + def count + @count ||= scope.count + end + + def any? = count.positive? + + def preview + @preview ||= scope.limit(PREVIEW_LIMIT).to_a + end + + def more_than_preview = count - preview.size + + # Checks that can only report are the honest default: a wrong row is not + # always a row we know how to put right (see OrphanedProvenance). + def repairable? = false + + def repair! + raise NotImplementedError, "#{self.class.name} reports only" + end + + def scope + raise NotImplementedError + end + + def title + raise NotImplementedError + end + + def explanation + raise NotImplementedError + end + + # What the fix button says, and what the flash reports afterwards. + def repair_label = "Fix" + + def repaired_message(number) + "Fixed #{number} #{'record'.pluralize(number)}." + end + + def describe(record) + record.to_s + end + end +end diff --git a/app/services/data_health/facilitator_affiliations_from_non_trainings.rb b/app/services/data_health/facilitator_affiliations_from_non_trainings.rb new file mode 100644 index 0000000000..84bd7a9c34 --- /dev/null +++ b/app/services/data_health/facilitator_affiliations_from_non_trainings.rb @@ -0,0 +1,44 @@ +module DataHealth + # Facilitator affiliations minted by a registration to an event that is not a + # facilitator training. Being a facilitator is conferred by a training, not by + # attending anything org-linked (ADR-0002 D1), so these rows should not exist — + # they inflate an organization's program status and its Facilitators-since. + # + # The reconcile page removes them one event at a time; this finds them across + # every event at once. + class FacilitatorAffiliationsFromNonTrainings < Check + def title = "Facilitator affiliations from non-training events" + + def explanation + "Only a facilitator training confers facilitator status. These rows were created from a " \ + "registration to some other event, so they count toward program status without anyone " \ + "having trained." + end + + def scope + Affiliation.facilitators + .joins(event_registration: :event) + .where(events: { facilitator_training: false }) + .includes(:person, :organization, event_registration: :event) + end + + def repairable? = true + + def repair_label = "Delete them" + + def repaired_message(number) + "Deleted #{number} facilitator #{'affiliation'.pluralize(number)}." + end + + # destroy, not delete_all: the organization's status and affiliation dates are + # kept in step by Affiliation's after_destroy callbacks. + def repair! + scope.to_a.each(&:destroy!).size + end + + def describe(affiliation) + "#{affiliation.person&.name} — #{affiliation.organization&.name} " \ + "(from #{affiliation.event_registration&.event&.title})" + end + end +end diff --git a/app/services/data_health/legacy_organization_status_drift.rb b/app/services/data_health/legacy_organization_status_drift.rb new file mode 100644 index 0000000000..ca7f9f0f0f --- /dev/null +++ b/app/services/data_health/legacy_organization_status_drift.rb @@ -0,0 +1,52 @@ +module DataHealth + # Organizations whose stored `organization_status` disagrees with what their + # facilitator affiliations say. The column is legacy and nothing reads it for a + # decision (ADR-0001 D3a) — this surfaces the drift the org edit form warns about, + # counted across every organization at once. + # + # Report-only on purpose. The stored vocabulary has no value that means + # "never active" the way the derived bucket does, and the affiliation callbacks + # only ever write Active/Inactive, so any automatic rewrite would drift straight + # back. Deciding what these organizations should say is a human call. + class LegacyOrganizationStatusDrift < Check + def title = "Organizations whose stored status contradicts their affiliations" + + def explanation + "The legacy status column was maintained by hand and has drifted. Nothing reads it for a " \ + "decision, so this is informational — an organization is active because someone facilitates " \ + "there, not because the column says so." + end + + def scope + Organization.where(id: drifted_ids).includes(:organization_status) + end + + def describe(organization) + deco = organization.decorate + "#{organization.name} — stored #{organization.organization_status&.name.presence || 'none'}, " \ + "affiliations say #{deco.organization_status_label}" + end + + private + + # Per derived bucket, the organizations in it whose stored status maps to a + # different bucket (a missing status counts as a mismatch unless the bucket is + # the one a missing status maps to). + def drifted_ids + OrganizationStatus::PROGRAM_STATUS_BUCKETS.values.uniq.flat_map do |bucket| + ids = status_ids_for(bucket) + in_bucket = Organization.program_status(bucket) + next in_bucket.pluck(:id) if ids.empty? + + in_bucket.where( + "organizations.organization_status_id IS NULL OR organizations.organization_status_id NOT IN (?)", ids + ).pluck(:id) + end + end + + def status_ids_for(bucket) + names = OrganizationStatus::PROGRAM_STATUS_BUCKETS.select { |_name, b| b == bucket }.keys + OrganizationStatus.where(name: names).pluck(:id) + end + end +end diff --git a/app/services/data_health/misaligned_affiliation_provenance.rb b/app/services/data_health/misaligned_affiliation_provenance.rb new file mode 100644 index 0000000000..19e7db6ccb --- /dev/null +++ b/app/services/data_health/misaligned_affiliation_provenance.rb @@ -0,0 +1,48 @@ +module DataHealth + # Affiliations whose minting registration is not linked to the affiliation's own + # organization. ADR-0002 D2a's invariant is "FK present ⟺ this row was auto-minted + # for its *current* organization", and reconciliation's auto-vs-manual gate reads + # that FK — so a stale link makes a row look auto-minted for an org it was never + # minted for. + # + # `reset_org_scoped_links_on_org_change` clears the FK when an admin repoints the + # org through the affiliation editor. Rows that predate that guard, or that were + # repointed another way, are what this finds. + class MisalignedAffiliationProvenance < Check + def title = "Affiliations linked to a registration for another organization" + + def explanation + "The registration recorded as creating each row is not linked to that row's organization, " \ + "so reconciliation treats it as auto-created for an organization it never belonged to." + end + + def scope + linked = EventRegistrationOrganization + .where("event_registration_organizations.event_registration_id = affiliations.event_registration_id") + .where("event_registration_organizations.organization_id = affiliations.organization_id") + + Affiliation.where.not(event_registration_id: nil) + .where.not(linked.arel.exists) + .includes(:person, :organization, event_registration: :event) + end + + def repairable? = true + + def repair_label = "Unlink them" + + def repaired_message(number) + "Unlinked #{number} #{'affiliation'.pluralize(number)} from their stale registration." + end + + # Clearing the link is the conservative direction: the row becomes + # hand-entered, which reconciliation spares by default. + def repair! + scope.to_a.each { |affiliation| affiliation.update_column(:event_registration_id, nil) }.size + end + + def describe(affiliation) + "#{affiliation.person&.name} — #{affiliation.organization&.name} " \ + "(linked to a registration for #{affiliation.event_registration&.event&.title})" + end + end +end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 1d31598567..f19b806226 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -577,7 +577,7 @@ def organization_registrant_ids_by_org .joins(:event_registration) .where(event_registration_id: active_registration_ids) .pluck(:organization_id, "event_registrations.registrant_id") - affiliated = Affiliation.active_on(reference_date) + affiliated = Affiliation.active_by_date_on(reference_date) .where(person_id: registrant_ids) .pluck(:organization_id, :person_id) (snapshot + affiliated).each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |(organization_id, person_id), map| @@ -1389,7 +1389,7 @@ def organization_ids snapshot_ids = EventRegistrationOrganization .where(event_registration_id: active_registration_ids) .pluck(:organization_id) - affiliated_ids = Affiliation.active_on(reference_date) + affiliated_ids = Affiliation.active_by_date_on(reference_date) .where(person_id: registrant_ids) .pluck(:organization_id) (snapshot_ids + affiliated_ids).compact.uniq diff --git a/app/services/person_comment_aggregator.rb b/app/services/person_comment_aggregator.rb index 1bc626d11f..9eac6ad08a 100644 --- a/app/services/person_comment_aggregator.rb +++ b/app/services/person_comment_aggregator.rb @@ -1,13 +1,13 @@ # Gathers every comment connected to a person into a single newest-first feed — # their own profile comments plus the comments left on the records that hang off -# them: event registrations, scholarships, CE registrations, the stories and -# story ideas they're credited on, and their login account. Returns one +# them: affiliations, event registrations, scholarships, CE registrations, the +# stories and story ideas they're credited on, and their login account. Returns one # ActiveRecord::Relation of Comment so callers can filter, paginate, and preload # uniformly. Payments carry no comments, so they never appear here. class PersonCommentAggregator # commentable_type => class, in the order sources are surfaced. Kept as strings # so the query never has to instantiate the classes. - SOURCE_TYPES = %w[ Person EventRegistration Scholarship ContinuingEducationRegistration TopicSubscription Story StoryIdea User ].freeze + SOURCE_TYPES = %w[ Person Affiliation EventRegistration Scholarship ContinuingEducationRegistration TopicSubscription Story StoryIdea User ].freeze def initialize(person) @person = person @@ -16,6 +16,7 @@ def initialize(person) def comments scopes = [ scope_for("Person", [ @person.id ]), + scope_for("Affiliation", affiliation_ids), scope_for("EventRegistration", registration_ids), scope_for("Scholarship", scholarship_ids), scope_for("ContinuingEducationRegistration", ce_registration_ids), @@ -37,6 +38,10 @@ def scope_for(type, ids) Comment.where(commentable_type: type, commentable_id: ids) end + def affiliation_ids + person.affiliations.ids + end + def registration_ids @registration_ids ||= person.event_registrations.ids end diff --git a/app/views/admin/data_health/index.html.erb b/app/views/admin/data_health/index.html.erb new file mode 100644 index 0000000000..c03bada408 --- /dev/null +++ b/app/views/admin/data_health/index.html.erb @@ -0,0 +1,63 @@ +<% content_for(:page_title, "Data health") %> +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% clean = @checks.none?(&:any?) %> + +
+
+ <%= link_to "← Admin", admin_path, class: "text-sm #{eyebrow_link_class}" %> +
+ +

Data health

+

+ Consistency checks that span every record, not one page at a time. A check with nothing to report + stays quiet. Repairs are logged like any other change, so you can see what ran and when. +

+ + <% if clean %> +
+ + Everything checks out — no inconsistencies found. +
+ <% end %> + +
+ <% @checks.each do |check| %> +
+
+
+

+ <%= check.title %> + "> + <%= check.count %> + +

+

<%= check.explanation %>

+
+ + <% if check.any? && check.repairable? %> + <%= button_to check.repair_label, admin_data_health_repair_path(check: check.key), + method: :post, class: "btn btn-danger-outline shrink-0", + form: { data: { turbo_confirm: "#{check.repair_label} for #{check.count} #{'record'.pluralize(check.count)}? This can't be undone." } } %> + <% elsif check.any? %> + + Review by hand + + <% end %> +
+ + <% if check.any? %> +
    + <% check.preview.each do |record| %> +
  • <%= check.describe(record) %>
  • + <% end %> +
+ <% if check.more_than_preview.positive? %> +

+ …and <%= check.more_than_preview %> more. A repair covers all <%= check.count %>, not just the ones listed. +

+ <% end %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/affiliations/_address_picker.html.erb b/app/views/affiliations/_address_picker.html.erb index 99e8fe08bf..41e3ff139d 100644 --- a/app/views/affiliations/_address_picker.html.erb +++ b/app/views/affiliations/_address_picker.html.erb @@ -13,7 +13,7 @@ <% inline = local_assigns.fetch(:hide_label, false) %> <% if options.any? %>
- +
diff --git a/app/views/affiliations/edit.html.erb b/app/views/affiliations/edit.html.erb index 6671c45a5e..c2ae3322b4 100644 --- a/app/views/affiliations/edit.html.erb +++ b/app/views/affiliations/edit.html.erb @@ -1,11 +1,12 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% anchor = @affiliation.decorate.return_anchor %> <% back_path = case params[:return_to] - when "person" then edit_person_path(params[:origin_id], anchor: dom_id(@affiliation)) - when "organization" then edit_organization_path(params[:origin_id], anchor: dom_id(@affiliation)) + when "person" then edit_person_path(params[:origin_id], anchor: anchor) + when "organization" then edit_organization_path(params[:origin_id], anchor: anchor) end %> -
-
+
+
<% if back_path %> <%= link_to back_path, class: "text-sm #{eyebrow_link_class}" do %> @@ -17,8 +18,8 @@ <%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class}" %>
-

Edit affiliation

-

+

Edit affiliation

+

<%= @affiliation.person&.full_name %> at <%= @affiliation.organization&.name %>

@@ -26,8 +27,14 @@ url: affiliation_path(@affiliation, return_to: params[:return_to].presence, origin_id: params[:origin_id].presence), method: :patch, html: { id: "affiliation_form", data: { turbo: false } } do |f| %> -
-
+ <%# Same live styling as the nested rows on the person/organization editors: + role is the hue, status the saturation, ended rows strike through. %> +
+ +
<%= f.input :person_id, collection: @affiliation.person ? [ [ @affiliation.person.full_name, @affiliation.person.id ] ] : [], selected: @affiliation.person_id, @@ -40,12 +47,12 @@ } %>
- Primary organization contact + Primary organization contact <%= render "affiliations/primary_contact_toggle", f: f %>
-
+
<%= f.input :organization_id, collection: @affiliation.organization ? [ [ @affiliation.organization.name, @affiliation.organization.id ] ] : [], @@ -88,24 +95,39 @@ <% end %>
-
+
<%= f.input :title, label_html: { class: "block text-sm font-medium text-gray-700 mb-1" }, - hint: "\"Facilitator\" = AWBW Facilitator." %> + hint: "\"Facilitator\" = AWBW Facilitator.", + input_html: { data: { inactive_toggle_target: "title valueField", + action: "inactive-toggle#updateBorder" } } %>
<%= f.input :start_date, as: :string, label: "Start", label_html: { class: "block text-sm font-medium text-gray-700 mb-1" }, - input_html: { type: "date", value: @affiliation.start_date&.strftime("%Y-%m-%d") } %> + input_html: { type: "date", value: @affiliation.start_date&.strftime("%Y-%m-%d"), + data: { inactive_toggle_target: "valueField", + action: "change->inactive-toggle#toggle" } } %>
<%= f.input :end_date, as: :string, label: "End", label_html: { class: "block text-sm font-medium text-gray-700 mb-1" }, - input_html: { type: "date", value: @affiliation.end_date&.strftime("%Y-%m-%d") } %> + input_html: { type: "date", value: @affiliation.end_date&.strftime("%Y-%m-%d"), + data: { inactive_toggle_target: "endDate valueField", + action: "change->inactive-toggle#endDateChanged" } } %> +
+
+ <%= f.input :inactive, + as: :boolean, + label: "Inactive", + hint: "Overrides the dates — tick to end an affiliation the dates still call active.", + input_html: { class: "mr-2 rounded focus:ring-blue-500 text-blue-600", + data: { inactive_toggle_target: "inactiveCheckbox", + action: "inactive-toggle#toggle" } } %>
@@ -138,7 +160,7 @@ <% end %> + +
+ <% @skipped_sections.each do |reason, rows| %> +
+ + <%= reason %> (<%= rows.size %>) + +
+ <% rows.each do |row| %> +
+ + <%= link_to row.person.name, edit_event_registration_path(row.registration), target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> — + <% if row.affiliation %> + <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "hover:underline hover:text-blue-700" do %> + <%= row.organization.name %> · <%= row.affiliation.decorate.date_range %> + <% end %> + <% else %> + <%= row.organization.name %> + <% end %> + + <%= render "event_registrations/attendance_status_badge", registration: row.registration, return_to: "reconcile_affiliations" %> +
+ <% end %> +
+
+ <% end %> +
+ + <% end %> +
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index d8b7280b7b..25abd72685 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -333,23 +333,8 @@ <% end %>
<% if allowed_to?(:manage?, Organization) %> -
- <% if f.object.affiliations.present? %> - <%= render "affiliations/header", label: "Person" %> - <% end %> - <%= f.fields_for :affiliations do |affiliation_form| %> -
- <%= render "affiliation_fields", - f: affiliation_form %> -
- <% end %> -
- -
<%= link_to_add_association "➕ Add Affiliation", - f, - :affiliations, - class: "btn btn-secondary-outline" %>
-
+ <%= render "affiliations/editor", f: f, label: "Person", scope: "organization", + add_class: "btn btn-secondary-outline" %> <% else %> <% f.object.user&.person&.affiliations&.each do |affiliation| %>
diff --git a/app/views/organizations/organizations_results.html.erb b/app/views/organizations/organizations_results.html.erb index 5e654ecf19..fe4d77a77b 100644 --- a/app/views/organizations/organizations_results.html.erb +++ b/app/views/organizations/organizations_results.html.erb @@ -26,7 +26,8 @@ <% published = organization.published? %> "> - <% status_label = published ? nil : organization.organization_status&.name %> + <%# The affiliation-derived bucket, never the drifted legacy column (ADR-0002 D4). %> + <% status_label = published ? nil : organization.decorate.organization_status_label %> <%= organization_profile_button(organization, truncate_at: 30, subtitle: organization.organization_locality, label: status_label, data: { turbo_frame: "_top" }) %> diff --git a/app/views/organizations/show.html.erb b/app/views/organizations/show.html.erb index ac5c942255..26a4500efe 100644 --- a/app/views/organizations/show.html.erb +++ b/app/views/organizations/show.html.erb @@ -125,7 +125,7 @@ Affiliations <% active_affiliations = @organization.affiliations - .select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) && a.person.present? } + .select { |a| a.active? && a.person.present? } .sort_by { |a| [a.person.first_name.to_s.downcase, a.person.last_name.to_s.downcase] } %> <% grouped = active_affiliations.group_by(&:person) %> <% if grouped.any? %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index b395fb5ef7..1d81ca562b 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -299,23 +299,8 @@
<% if allowed_to?(:manage?, Person) %> -
- <% if f.object.affiliations.present? %> - <%= render "affiliations/header", label: "Organization" %> - <% end %> - <%= f.fields_for :affiliations do |affiliation_form| %> -
- <%= render "affiliation_fields", - f: affiliation_form %> -
- <% end %> -
- -
<%= link_to_add_association "➕ Add Affiliation", - f, - :affiliations, - class: "admin-only bg-blue-100 btn btn-secondary-outline" %>
-
+ <%= render "affiliations/editor", f: f, label: "Organization", scope: "person", + add_class: "admin-only bg-blue-100 btn btn-secondary-outline" %> <% else %> <% f.object.affiliations.each do |affiliation| %>
diff --git a/app/views/people/people_results.html.erb b/app/views/people/people_results.html.erb index 162eeb2dba..6391c6497d 100644 --- a/app/views/people/people_results.html.erb +++ b/app/views/people/people_results.html.erb @@ -1,27 +1,27 @@ <%= turbo_frame_tag :people_results do %> <%= turbo_stream.replace("people_count", partial: "people_count") %> -
+
<% if @people.any? %>
- - - - - - + + + + + + <% if allowed_to?(:manage?, Person) %> - + <% end %> <% @people.each do |person| %> <% cache [ person, current_user.super_user?, current_user.person_id == person.id ] do %> - "> + "> - <% if allowed_to?(:manage?, Person) %> -
NameAffiliated sincePrimary sectorPrimary age rangeAffiliation(s)SocialsNameAffiliated sincePrimary sectorPrimary age rangeAffiliation(s)SocialsActionsActions
<% 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 %> + <%= 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",