From 489cd9f48e3608527af66c43f45784593ad3813d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 00:42:55 -0400 Subject: [PATCH 01/22] =?UTF-8?q?Add=20affiliation=E2=86=94registration=20?= =?UTF-8?q?link=20and=20event=20reconciled-at=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for facilitator-affiliation reconciliation: an ownership FK so reconcile only ever touches rows the registration flow created, and a timestamp on events recording when affiliations were last reconciled. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...044207_add_affiliations_reconciled_at_to_events.rb | 11 +++++++++++ db/schema.rb | 1 + 2 files changed, 12 insertions(+) create mode 100644 db/migrate/20260814044207_add_affiliations_reconciled_at_to_events.rb 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 000000000..4f25e87ba --- /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 8da6a717f..a13812425 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -544,6 +544,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 From c6918361d769af33a9d70f1ddbaca066626ab8ea Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 00:51:34 -0400 Subject: [PATCH 02/22] Add ReconcileFacilitatorAffiliation service Per (person, org): keep the owned facilitator affiliation active iff they have an attended facilitator-training registration for that org; otherwise same-day it (end_date := start_date). Hand-created rows are left alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reconcile_facilitator_affiliation.rb | 83 ++++++++++++ .../reconcile_facilitator_affiliation_spec.rb | 127 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 app/services/affiliation_services/reconcile_facilitator_affiliation.rb create mode 100644 spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb diff --git a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb new file mode 100644 index 000000000..60e2bcde8 --- /dev/null +++ b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb @@ -0,0 +1,83 @@ +module AffiliationServices + # Reconciles a person's **owned** facilitator affiliation for one organization + # against whether they actually completed a facilitator training there. + # + # "Owned" means auto-minted by the registration flow (`event_registration_id` + # present) — hand-created / historical rows have no link and are left alone. + # + # A person is an active facilitator of an org iff they have at least one + # `attended` registration to that org from a facilitator-training event. Anyone + # else (no_show, cancelled, incomplete_attendance, still-registered, …) is not, + # so we **same-day** their owned facilitator affiliation — set `end_date` to its + # `start_date`, which the model's `set_inactive_from_dates` turns into + # `inactive: true`. It preserves `start_date` and is reversible: if the person is + # later marked attended, a re-run clears `end_date` and reactivates the row. + # + # The decision is per (person, org) across ALL their training registrations, so + # no-showing one training but attending another for the same org keeps them + # active. + class ReconcileFacilitatorAffiliation + def self.call(person:, organization:) + new(person:, organization:).call + end + + def initialize(person:, organization:) + @person = person + @organization = organization + end + + # Apply the reconciliation. Returns the action taken (:deactivate, :reactivate, + # or :noop). + def call + rows = owned_facilitator_affiliations.to_a + return :noop if rows.empty? + + completed_training? ? reactivate(rows) : deactivate(rows) + end + + # What #call would do, without writing. Returns :deactivate, :reactivate, or :noop. + def plan + rows = owned_facilitator_affiliations.to_a + return :noop if rows.empty? + + if completed_training? + rows.any? { |affiliation| !affiliation.active? } ? :reactivate : :noop + else + rows.any?(&:active?) ? :deactivate : :noop + end + end + + private + + def deactivate(rows) + active = rows.select(&:active?) + return :noop if active.empty? + + active.each { |affiliation| affiliation.update!(end_date: affiliation.start_date || Date.current) } + :deactivate + end + + def reactivate(rows) + ended = rows.reject(&:active?) + return :noop if ended.empty? + + ended.each { |affiliation| affiliation.update!(end_date: nil) } + :reactivate + end + + def owned_facilitator_affiliations + @person.affiliations.facilitators + .where(organization: @organization) + .where.not(event_registration_id: nil) + end + + # Any `attended` registration to this org from a facilitator-training event. + def 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 + end +end diff --git a/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb b/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb new file mode 100644 index 000000000..b429fa64f --- /dev/null +++ b/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb @@ -0,0 +1,127 @@ +require "rails_helper" + +RSpec.describe AffiliationServices::ReconcileFacilitatorAffiliation 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`. + def owned_facilitator(registration:, start_date: 1.month.ago.to_date) + create(:affiliation, + person: person, + organization: organization, + title: "Facilitator", + start_date: start_date, + event_registration: registration) + 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) + + described_class.call(person: person, organization: organization) + 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) + + described_class.call(person: person, organization: organization) + + expect(affiliation.reload).not_to be_active + end + end + + it "leaves an unowned (hand-created) facilitator affiliation untouched" do + training_registration(status: "no_show") + hand_created = create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: 1.month.ago.to_date) + + described_class.call(person: person, organization: organization) + + expect(hand_created.reload).to be_active + expect(hand_created.end_date).to be_nil + 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) + + described_class.call(person: person, organization: organization) + + 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") + + described_class.call(person: person, organization: organization) + + expect(affiliation.reload).to be_active + end + + it "reactivates a previously same-day'd affiliation once the person is marked attended" do + reg = training_registration(status: "attended") + affiliation = owned_facilitator(registration: reg, start_date: 1.month.ago.to_date) + affiliation.update!(end_date: affiliation.start_date) + expect(affiliation.reload).not_to be_active + + described_class.call(person: person, organization: organization) + + expect(affiliation.reload).to be_active + expect(affiliation.end_date).to be_nil + end + end + + describe "idempotence" do + it "is stable across repeated runs" do + reg = training_registration(status: "no_show") + affiliation = owned_facilitator(registration: reg) + + described_class.call(person: person, organization: organization) + first = affiliation.reload.end_date + described_class.call(person: person, organization: organization) + + 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).plan + + expect(plan).to eq(:deactivate) + expect(affiliation.reload).to be_active + end + + it "reports :noop when there is no owned facilitator affiliation" do + training_registration(status: "no_show") + + plan = described_class.new(person: person, organization: organization).plan + + expect(plan).to eq(:noop) + end + end +end From ef2e43911daa648e5238098ff7cead03c05f540a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 00:57:03 -0400 Subject: [PATCH 03/22] Add Reconcile affiliations bulk action with preview and opt-out A preview-and-confirm page (under Bulk actions on facilitator trainings) that same-days the owned facilitator affiliation of anyone who didn't complete the training, keeps/reactivates completers, and records when it last ran. Admins can opt individual rows out before applying. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + .../reconcile_affiliations_controller.rb | 47 +++++++++++ app/models/event.rb | 9 +++ app/policies/event_policy.rb | 4 + .../affiliation_services/reconcile_event.rb | 77 +++++++++++++++++++ .../reconcile_facilitator_affiliation.rb | 17 +++- app/views/events/_bulk_actions_menu.html.erb | 3 + .../reconcile_affiliations/index.html.erb | 63 +++++++++++++++ config/routes.rb | 2 + .../events/reconcile_affiliations_spec.rb | 70 +++++++++++++++++ .../reconcile_facilitator_affiliation_spec.rb | 10 +++ spec/views/page_bg_class_alignment_spec.rb | 1 + 12 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 app/controllers/events/reconcile_affiliations_controller.rb create mode 100644 app/services/affiliation_services/reconcile_event.rb create mode 100644 app/views/events/reconcile_affiliations/index.html.erb create mode 100644 spec/requests/events/reconcile_affiliations_spec.rb diff --git a/AGENTS.md b/AGENTS.md index cf151739d..78929432d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. `#preview` returns the actionable `(person, org)` rows (via `ReconcileFacilitatorAffiliation#plan`) for the confirm page; `#apply(included_keys:)` reconciles the rows the admin kept and stamps the event's `affiliations_reconciled_at`. ### Sectors diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb new file mode 100644 index 000000000..b368857d4 --- /dev/null +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -0,0 +1,47 @@ +module Events + # The "Reconcile affiliations" bulk action: a preview-and-confirm page that + # brings each registrant's owned facilitator affiliation in line with whether + # they actually completed this facilitator training. Post-event it same-days the + # affiliations of non-completers; the admin can opt individual rows out before + # applying. Only facilitator-training events have facilitator affiliations to + # reconcile, so the action is limited to them. + class ReconcileAffiliationsController < ApplicationController + include AhoyTracking + before_action :set_event + before_action :require_facilitator_training + + def index + authorize! @event, to: :reconcile_affiliations? + track_view("events.reconcile_affiliations", { event_id: @event.id }) + + @rows = AffiliationServices::ReconcileEvent.new(@event).preview + @event = @event.decorate + end + + def create + authorize! @event, to: :reconcile_affiliations? + + changed = AffiliationServices::ReconcileEvent.new(@event).apply(included_keys: params[:included]) + redirect_to registrants_event_path(@event), notice: reconcile_notice(changed) + end + + private + + def set_event + @event = Event.find(params[:id]) + end + + def require_facilitator_training + return if @event.facilitator_training? + + redirect_to registrants_event_path(@event), + alert: "Affiliation reconciliation applies to facilitator trainings only." + 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/models/event.rb b/app/models/event.rb index 32312bb10..955fddb37 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -158,6 +158,15 @@ def ended? end_date < Time.current end + # A registrant's status changed after affiliations were last reconciled, so the + # reconciliation may be out of date and worth re-running. False when never + # reconciled (nothing to be stale against). + 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/policies/event_policy.rb b/app/policies/event_policy.rb index 602da91fe..ab3457a1e 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 000000000..1de7685f4 --- /dev/null +++ b/app/services/affiliation_services/reconcile_event.rb @@ -0,0 +1,77 @@ +module AffiliationServices + # Event-level orchestration for the "Reconcile affiliations" bulk action. Walks + # the event's registrants and the organizations they linked, and reconciles each + # (person, org)'s owned facilitator affiliation via ReconcileFacilitatorAffiliation. + # + # `preview` returns the actionable rows (nothing is written) so the admin can see + # what will change and opt individual rows out. `apply` reconciles the rows the + # admin kept (by key) and stamps the event's `affiliations_reconciled_at`. + class ReconcileEvent + Row = Struct.new(:person, :organization, :affiliation, :action, :key, keyword_init: true) + + def self.key_for(person, organization) + "#{person.id}:#{organization.id}" + end + + def initialize(event) + @event = event + end + + # Actionable rows (:deactivate / :reactivate) for the preview. Never writes. + def preview + pairs.filter_map do |person, organization| + action = ReconcileFacilitatorAffiliation.new(person:, organization:).plan + next if action == :noop + + Row.new( + person:, + organization:, + affiliation: owned_facilitator(person, organization), + action:, + key: self.class.key_for(person, organization) + ) + end + end + + # Reconcile the (person, org) pairs whose keys are in `included_keys`, stamp the + # event, and return the number of pairs actually changed. + def apply(included_keys:) + keys = Array(included_keys).to_set + + changed = pairs.count do |person, organization| + next false unless keys.include?(self.class.key_for(person, organization)) + + ReconcileFacilitatorAffiliation.call(person:, organization:) != :noop + end + + @event.update!(affiliations_reconciled_at: Time.current) + changed + end + + private + + # Distinct (person, organization) pairs from the event's registrants and the + # organizations each linked to their registration. + def pairs + @pairs ||= begin + seen = Set.new + @event.event_registrations.includes(:registrant, :organizations).flat_map do |registration| + registration.organizations.filter_map do |organization| + key = [ registration.registrant_id, organization.id ] + next if seen.include?(key) + + seen << key + [ registration.registrant, organization ] + end + end + end + end + + def owned_facilitator(person, organization) + person.affiliations.facilitators + .where(organization:) + .where.not(event_registration_id: nil) + .first + end + end +end diff --git a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb index 60e2bcde8..db3e086cb 100644 --- a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb +++ b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb @@ -42,21 +42,30 @@ def plan if completed_training? rows.any? { |affiliation| !affiliation.active? } ? :reactivate : :noop + elsif rows.any? { |affiliation| affiliation.active? && source_training_ended?(affiliation) } + :deactivate else - rows.any?(&:active?) ? :deactivate : :noop + :noop end end private def deactivate(rows) - active = rows.select(&:active?) - return :noop if active.empty? + # Only same-day affiliations whose source training has actually ended. A row + # tied to a still-upcoming training is a legitimate assumptive/upcoming + # affiliation — leave it alone until that training is over. + ended = rows.select { |affiliation| affiliation.active? && source_training_ended?(affiliation) } + return :noop if ended.empty? - active.each { |affiliation| affiliation.update!(end_date: affiliation.start_date || Date.current) } + ended.each { |affiliation| affiliation.update!(end_date: affiliation.start_date || Date.current) } :deactivate end + def source_training_ended?(affiliation) + affiliation.event_registration&.event&.ended? + end + def reactivate(rows) ended = rows.reject(&:active?) return :noop if ended.empty? diff --git a/app/views/events/_bulk_actions_menu.html.erb b/app/views/events/_bulk_actions_menu.html.erb index 72c7dac7f..4d61faffd 100644 --- a/app/views/events/_bulk_actions_menu.html.erb +++ b/app/views/events/_bulk_actions_menu.html.erb @@ -24,6 +24,9 @@ <% else %> <%= link_to "Sign-ins", attendance_event_path(@event, return_to: "registrants"), class: item_class %> <% end %> + <% if @event.facilitator_training? %> + <%= link_to "Reconcile affiliations", reconcile_affiliations_event_path(@event), class: item_class %> + <% end %> <%= link_to registrants_event_path(@event, format: :csv), class: item_class, data: { turbo_frame: "_top" } do %> Download CSV <% end %> diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb new file mode 100644 index 000000000..a6b56c659 --- /dev/null +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -0,0 +1,63 @@ +<% content_for(:page_title, "Reconcile affiliations — #{@event.title}") %> +<% content_for(:page_bg_class, "admin-or-owner bg-blue-100") %> +
+
+ <%= link_to "← Registrants", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= render "events/subnav", event: @event, current: :registrants %> +
+ +

Reconcile affiliations

+ +
+

+ Facilitator affiliations are created optimistically when someone registers for a training. This step brings + them in line with who actually attended: anyone who didn't complete the training has their + auto-created facilitator affiliation same-dayed (its end date is set to its start date, so it + no longer counts as active). Someone later marked attended is reactivated on the next run. +

+

+ Only affiliations this app created from a registration are touched — hand-entered affiliations are always left + alone. Uncheck a row to spare it this time. +

+ <% if @event.affiliations_reconciled_at %> +

Last reconciled <%= @event.affiliations_reconciled_at.to_fs(:long) %>.

+ <% end %> + <% if @event.affiliations_reconciliation_stale? %> +

Attendance has changed since the last reconciliation — re-run to bring affiliations up to date.

+ <% end %> +
+ + <% if @rows.empty? %> +
+ Nothing to reconcile — every facilitator affiliation already matches its attendance. +
+ <% else %> + <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %> +
+ <% @rows.each do |row| %> + + <% end %> +
+ +
+ <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= submit_tag "Reconcile affiliations", class: "btn btn-primary" %> +
+ <% end %> + <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 76743101a..be2c32ace 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -190,6 +190,8 @@ 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#create" get :preview_reminder patch :preview post :copy_registration_form diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb new file mode 100644 index 000000000..b7b80b361 --- /dev/null +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -0,0 +1,70 @@ +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("Will be deactivated") + end + + it "redirects for a non-training event" do + non_training = create(:event, :ended, facilitator_training: false) + + get reconcile_affiliations_event_path(non_training) + + expect(response).to redirect_to(registrants_event_path(non_training)) + 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 "POST create" do + it "deactivates the included non-completer and stamps the event" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + key = AffiliationServices::ReconcileEvent.key_for(affiliation.person, organization) + + post reconcile_affiliations_event_path(event), params: { included: [ key ] } + + 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 "spares an opted-out row" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post reconcile_affiliations_event_path(event), params: { included: [] } + + expect(affiliation.reload).to be_active + end + end +end diff --git a/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb b/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb index b429fa64f..d910e4bbb 100644 --- a/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb +++ b/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb @@ -46,6 +46,16 @@ def owned_facilitator(registration:, start_date: 1.month.ago.to_date) end 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) + + described_class.call(person: person, organization: organization) + + expect(affiliation.reload).to be_active + expect(affiliation.end_date).to be_nil + end + it "leaves an unowned (hand-created) facilitator affiliation untouched" do training_registration(status: "no_show") hand_created = create(:affiliation, person: person, organization: organization, diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index bd401c4af..2f6c99865 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -118,6 +118,7 @@ "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/edit_staff.html.erb" => "admin-or-owner bg-white", "app/views/events/recipients.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/registrants.html.erb" => "admin-or-owner bg-blue-100", From 49a52ae7163e72406025e5e8c86e07c0a2016154 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 01:15:47 -0400 Subject: [PATCH 04/22] Date facilitator affiliation to the training day; heal missing affiliations on reconcile Start the created facilitator affiliation on the actual training date rather than the first of its month. Extend the Reconcile affiliations action to also create missing facilitator affiliations (pre-event for anyone, post-event for attendees), shown as opt-out-able 'Will be created' rows alongside the deactivations. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- .../affiliation_services/reconcile_event.rb | 91 ++++++++++++++----- .../reconcile_facilitator_affiliation.rb | 19 ++-- .../reconcile_affiliations/index.html.erb | 15 ++- .../events/reconcile_affiliations_spec.rb | 23 +++++ 5 files changed, 112 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78929432d..08d5c8b54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ action, or `authorize! :workshop, to: :summary?`). - `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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. -- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. `#preview` returns the actionable `(person, org)` rows (via `ReconcileFacilitatorAffiliation#plan`) for the confirm page; `#apply(included_keys:)` reconciles the rows the admin kept and stamps the event's `affiliations_reconciled_at`. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Per `(person, org)` it decides `:create` (heal a missing facilitator affiliation — pre-event for anyone, post-event only for attendees), `:deactivate`, or `:reactivate`. `#preview` returns the actionable rows for the confirm page; `#apply(included_keys:)` performs the rows the admin kept (creating via `CreateFromRegistration`, otherwise via `ReconcileFacilitatorAffiliation`) and stamps the event's `affiliations_reconciled_at`. ### Sectors diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 1de7685f4..1c95e8f2e 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -1,13 +1,19 @@ module AffiliationServices # Event-level orchestration for the "Reconcile affiliations" bulk action. Walks - # the event's registrants and the organizations they linked, and reconciles each - # (person, org)'s owned facilitator affiliation via ReconcileFacilitatorAffiliation. + # the event's registrants and the organizations they linked, and for each + # (person, org) works out what should happen to their facilitator affiliation: # - # `preview` returns the actionable rows (nothing is written) so the admin can see - # what will change and opt individual rows out. `apply` reconciles the rows the - # admin kept (by key) and stamps the event's `affiliations_reconciled_at`. + # :create — no facilitator affiliation exists yet but one should (heal a + # missing affiliation): pre-event for any registrant, post-event + # only for those who attended. + # :deactivate — an owned affiliation whose (ended) training they didn't complete. + # :reactivate — an owned affiliation same-dayed earlier, now attended. + # + # `preview` returns the actionable rows without writing so the admin can see them + # and opt individual rows out; `apply(included_keys:)` performs the kept rows and + # stamps the event's `affiliations_reconciled_at`. class ReconcileEvent - Row = Struct.new(:person, :organization, :affiliation, :action, :key, keyword_init: true) + Row = Struct.new(:person, :organization, :registration, :affiliation, :action, :key, keyword_init: true) def self.key_for(person, organization) "#{person.id}:#{organization.id}" @@ -17,15 +23,34 @@ def initialize(event) @event = event end - # Actionable rows (:deactivate / :reactivate) for the preview. Never writes. def preview - pairs.filter_map do |person, organization| - action = ReconcileFacilitatorAffiliation.new(person:, organization:).plan + rows + end + + # Apply the rows whose keys are in `included_keys`, stamp the event, and return + # the number of pairs actually changed. + def apply(included_keys:) + keys = Array(included_keys).to_set + + changed = rows.count do |row| + keys.include?(row.key) && apply_row(row) + end + + @event.update!(affiliations_reconciled_at: Time.current) + changed + end + + private + + def rows + @rows ||= pairs.filter_map do |person, organization, registration| + action = action_for(person, organization) next if action == :noop Row.new( person:, organization:, + registration:, affiliation: owned_facilitator(person, organization), action:, key: self.class.key_for(person, organization) @@ -33,25 +58,45 @@ def preview end end - # Reconcile the (person, org) pairs whose keys are in `included_keys`, stamp the - # event, and return the number of pairs actually changed. - def apply(included_keys:) - keys = Array(included_keys).to_set + def action_for(person, organization) + reconcile = ReconcileFacilitatorAffiliation.new(person:, organization:).plan + return reconcile unless reconcile == :noop - changed = pairs.count do |person, organization| - next false unless keys.include?(self.class.key_for(person, organization)) + create_needed?(person, organization) ? :create : :noop + end - ReconcileFacilitatorAffiliation.call(person:, organization:) != :noop - end + def apply_row(row) + return apply_create(row) if row.action == :create - @event.update!(affiliations_reconciled_at: Time.current) - changed + ReconcileFacilitatorAffiliation.call(person: row.person, organization: row.organization) != :noop end - private + def apply_create(row) + AffiliationServices::CreateFromRegistration.call( + person: row.person, + organization: row.organization, + facilitator_training: true, + training_date: @event.start_date, + event_registration: row.registration + ) + true + end + + # A facilitator affiliation should exist but doesn't. Skip when an owned one + # already exists (reconcile handles it — including a deliberately same-dayed + # no-show we must not resurrect) or when a hand-created active-or-pending one + # already covers it. Otherwise create it pre-event for anyone, post-event only + # for those who attended. + def create_needed?(person, organization) + facilitators = person.affiliations.facilitators.where(organization:) + return false if facilitators.where.not(event_registration_id: nil).exists? + return false if facilitators.active_or_pending.exists? + + !@event.ended? || ReconcileFacilitatorAffiliation.new(person:, organization:).completed_training? + end - # Distinct (person, organization) pairs from the event's registrants and the - # organizations each linked to their registration. + # Distinct (person, organization, registration) triples from the event's + # registrants and the organizations each linked to their registration. def pairs @pairs ||= begin seen = Set.new @@ -61,7 +106,7 @@ def pairs next if seen.include?(key) seen << key - [ registration.registrant, organization ] + [ registration.registrant, organization, registration ] end end end diff --git a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb index db3e086cb..acc3bf54b 100644 --- a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb +++ b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb @@ -49,6 +49,16 @@ def plan end end + # Whether the person has any `attended` registration to this org from a + # facilitator-training event — i.e. actually became a facilitator there. + def 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 + private def deactivate(rows) @@ -79,14 +89,5 @@ def owned_facilitator_affiliations .where(organization: @organization) .where.not(event_registration_id: nil) end - - # Any `attended` registration to this org from a facilitator-training event. - def 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 end end diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index a6b56c659..4bcdf4441 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -10,10 +10,10 @@

- Facilitator affiliations are created optimistically when someone registers for a training. This step brings - them in line with who actually attended: anyone who didn't complete the training has their - auto-created facilitator affiliation same-dayed (its end date is set to its start date, so it - no longer counts as active). Someone later marked attended is reactivated on the next run. + This step brings facilitator affiliations in line with who registered and attended. Before the training it + creates any missing facilitator affiliations for linked organizations. After the training it + same-days the affiliation of anyone who didn't attend (its end date is set to its start + date, so it no longer counts as active), and reactivates anyone later marked attended.

Only affiliations this app created from a registration are touched — hand-entered affiliations are always left @@ -41,10 +41,15 @@ <%= row.person.name %> — <%= row.organization.name %> - <% if row.action == :deactivate %> + <% case row.action %> + <% when :deactivate %> Will be deactivated + <% when :create %> + + Will be created + <% else %> Will be reactivated diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index b7b80b361..3d5b29e5a 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -30,6 +30,17 @@ def registrant_with_affiliation(status:) expect(response.body).to include("Will be deactivated") 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 "redirects for a non-training event" do non_training = create(:event, :ended, facilitator_training: false) @@ -66,5 +77,17 @@ def registrant_with_affiliation(status:) expect(affiliation.reload).to be_active end + + it "creates a missing affiliation before the event when included" 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) + key = AffiliationServices::ReconcileEvent.key_for(person, organization) + + expect { + post reconcile_affiliations_event_path(upcoming), params: { included: [ key ] } + }.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1) + end end end From e11ef6898174a6a54e58cbcb594764165920c541 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 07:49:12 -0400 Subject: [PATCH 05/22] Reconcile non-training facilitator affiliations and offer delete-instead On a non-training event, the Reconcile affiliations action now deletes facilitator affiliations that were auto-created off it (job affiliations are left alone), shown as opt-out-able 'Will be deleted' rows. Same-day rows also gain a per-row 'Delete instead' checkbox. The action is now available on every event, not just trainings. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- .../reconcile_affiliations_controller.rb | 23 ++-- .../affiliation_services/reconcile_event.rb | 100 ++++++++++++------ .../reconcile_facilitator_affiliation.rb | 20 ++-- app/views/events/_bulk_actions_menu.html.erb | 4 +- .../reconcile_affiliations/index.html.erb | 37 +++++-- .../events/reconcile_affiliations_spec.rb | 35 +++++- 7 files changed, 150 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 08d5c8b54..1aefdc350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ action, or `authorize! :workshop, to: :summary?`). - `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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. -- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Per `(person, org)` it decides `:create` (heal a missing facilitator affiliation — pre-event for anyone, post-event only for attendees), `:deactivate`, or `:reactivate`. `#preview` returns the actionable rows for the confirm page; `#apply(included_keys:)` performs the rows the admin kept (creating via `CreateFromRegistration`, otherwise via `ReconcileFacilitatorAffiliation`) and stamps the event's `affiliations_reconciled_at`. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Per `(person, org)` it decides, for facilitator trainings, `:create` (heal a missing facilitator affiliation — pre-event for anyone, post-event only for attendees), `:deactivate`, or `:reactivate`; for non-training events, `:delete` (remove a facilitator affiliation auto-created off this event, leaving job affiliations). `#preview` returns the actionable rows for the confirm page; `#apply(included_keys:, delete_keys:)` performs the rows the admin kept — creating via `CreateFromRegistration`, deactivating/reactivating via `ReconcileFacilitatorAffiliation`, or deleting (a `:deactivate` row whose key is in `delete_keys` is deleted instead of same-dayed) — and stamps the event's `affiliations_reconciled_at`. ### Sectors diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb index b368857d4..f5d1d3391 100644 --- a/app/controllers/events/reconcile_affiliations_controller.rb +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -1,14 +1,13 @@ module Events # The "Reconcile affiliations" bulk action: a preview-and-confirm page that - # brings each registrant's owned facilitator affiliation in line with whether - # they actually completed this facilitator training. Post-event it same-days the - # affiliations of non-completers; the admin can opt individual rows out before - # applying. Only facilitator-training events have facilitator affiliations to - # reconcile, so the action is limited to them. + # brings each registrant's owned facilitator affiliation in line with reality. + # For a facilitator training it creates missing affiliations, same-days + # non-completers, and reactivates late attendees; for a non-training event it + # removes facilitator affiliations that were auto-created off it. The admin can + # opt individual rows out (and, for same-day rows, delete instead) before applying. class ReconcileAffiliationsController < ApplicationController include AhoyTracking before_action :set_event - before_action :require_facilitator_training def index authorize! @event, to: :reconcile_affiliations? @@ -21,7 +20,10 @@ def index def create authorize! @event, to: :reconcile_affiliations? - changed = AffiliationServices::ReconcileEvent.new(@event).apply(included_keys: params[:included]) + changed = AffiliationServices::ReconcileEvent.new(@event).apply( + included_keys: params[:included] || [], + delete_keys: params[:delete] || [] + ) redirect_to registrants_event_path(@event), notice: reconcile_notice(changed) end @@ -31,13 +33,6 @@ def set_event @event = Event.find(params[:id]) end - def require_facilitator_training - return if @event.facilitator_training? - - redirect_to registrants_event_path(@event), - alert: "Affiliation reconciliation applies to facilitator trainings only." - end - def reconcile_notice(changed) return "No affiliations needed reconciling." if changed.zero? diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 1c95e8f2e..aa6bc2ee6 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -1,17 +1,22 @@ module AffiliationServices # Event-level orchestration for the "Reconcile affiliations" bulk action. Walks - # the event's registrants and the organizations they linked, and for each - # (person, org) works out what should happen to their facilitator affiliation: + # the event's registrants and the organizations they linked, and per (person, + # org) works out what should happen to their **owned** facilitator affiliation + # (job affiliations are never touched): # - # :create — no facilitator affiliation exists yet but one should (heal a - # missing affiliation): pre-event for any registrant, post-event - # only for those who attended. - # :deactivate — an owned affiliation whose (ended) training they didn't complete. - # :reactivate — an owned affiliation same-dayed earlier, now attended. + # :create — facilitator training, none exists yet but one should (pre-event + # for anyone, post-event only for attendees). + # :deactivate — facilitator training, an owned affiliation whose (ended) + # training they didn't complete. The admin may choose to delete + # it instead of same-daying it (see `delete_keys`). + # :reactivate — facilitator training, an owned affiliation same-dayed earlier, + # now attended. + # :delete — NOT a facilitator training: an owned facilitator affiliation was + # auto-created off this event and shouldn't exist, so remove it. # # `preview` returns the actionable rows without writing so the admin can see them - # and opt individual rows out; `apply(included_keys:)` performs the kept rows and - # stamps the event's `affiliations_reconciled_at`. + # and opt individual rows out; `apply` performs the kept rows and stamps the + # event's `affiliations_reconciled_at`. class ReconcileEvent Row = Struct.new(:person, :organization, :registration, :affiliation, :action, :key, keyword_init: true) @@ -27,13 +32,15 @@ def preview rows end - # Apply the rows whose keys are in `included_keys`, stamp the event, and return - # the number of pairs actually changed. - def apply(included_keys:) - keys = Array(included_keys).to_set + # Apply the rows whose keys are in `included_keys`. For :deactivate rows whose + # key is also in `delete_keys`, delete the affiliation instead of same-daying + # it. Stamps the event and returns the number of pairs actually changed. + def apply(included_keys:, delete_keys: []) + included = Array(included_keys).to_set + delete_instead = Array(delete_keys).to_set changed = rows.count do |row| - keys.include?(row.key) && apply_row(row) + included.include?(row.key) && perform(row, delete_instead: delete_instead.include?(row.key)) end @event.update!(affiliations_reconciled_at: Time.current) @@ -43,32 +50,50 @@ def apply(included_keys:) private def rows - @rows ||= pairs.filter_map do |person, organization, registration| - action = action_for(person, organization) - next if action == :noop - - Row.new( - person:, - organization:, - registration:, - affiliation: owned_facilitator(person, organization), - action:, - key: self.class.key_for(person, organization) - ) + @rows ||= pairs.filter_map { |person, organization, registration| build_row(person, organization, registration) } + end + + def build_row(person, organization, registration) + if @event.facilitator_training? + action = training_action(person, organization) + return if action == :noop + + affiliation = action == :create ? nil : owned_facilitator(person, organization) + else + affiliation = owned_facilitator_from_event(person, organization) + return if affiliation.nil? + + action = :delete end + + Row.new(person:, organization:, registration:, affiliation:, action:, key: self.class.key_for(person, organization)) end - def action_for(person, organization) + def training_action(person, organization) reconcile = ReconcileFacilitatorAffiliation.new(person:, organization:).plan return reconcile unless reconcile == :noop create_needed?(person, organization) ? :create : :noop end - def apply_row(row) - return apply_create(row) if row.action == :create - - ReconcileFacilitatorAffiliation.call(person: row.person, organization: row.organization) != :noop + def perform(row, delete_instead:) + case row.action + when :create + apply_create(row) + true + when :delete + row.affiliation.destroy! + true + when :deactivate + service = ReconcileFacilitatorAffiliation.new(person: row.person, organization: row.organization) + targets = service.deactivatable_affiliations + return false if targets.empty? + + delete_instead ? targets.each(&:destroy!) : service.call + true + else # :reactivate + ReconcileFacilitatorAffiliation.call(person: row.person, organization: row.organization) != :noop + end end def apply_create(row) @@ -79,7 +104,6 @@ def apply_create(row) training_date: @event.start_date, event_registration: row.registration ) - true end # A facilitator affiliation should exist but doesn't. Skip when an owned one @@ -118,5 +142,17 @@ def owned_facilitator(person, organization) .where.not(event_registration_id: nil) .first end + + # An owned facilitator affiliation that was auto-created off *this* (non-training) + # event — the row a non-training reconcile removes. Hand-created rows (no link) + # and affiliations from other events are left alone. + def owned_facilitator_from_event(person, organization) + person.affiliations.facilitators + .where(organization:) + .where.not(event_registration_id: nil) + .joins(:event_registration) + .where(event_registrations: { event_id: @event.id }) + .first + end end end diff --git a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb index acc3bf54b..b4c837651 100644 --- a/app/services/affiliation_services/reconcile_facilitator_affiliation.rb +++ b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb @@ -42,7 +42,7 @@ def plan if completed_training? rows.any? { |affiliation| !affiliation.active? } ? :reactivate : :noop - elsif rows.any? { |affiliation| affiliation.active? && source_training_ended?(affiliation) } + elsif deactivatable_affiliations.any? :deactivate else :noop @@ -59,16 +59,20 @@ def completed_training? .exists? end + # The owned facilitator affiliations #call would same-day: active, and tied to a + # training that has already ended. Exposed so the bulk action can offer "delete + # instead of same-day" over the exact same set. + def deactivatable_affiliations + owned_facilitator_affiliations.select { |affiliation| affiliation.active? && source_training_ended?(affiliation) } + end + private - def deactivate(rows) - # Only same-day affiliations whose source training has actually ended. A row - # tied to a still-upcoming training is a legitimate assumptive/upcoming - # affiliation — leave it alone until that training is over. - ended = rows.select { |affiliation| affiliation.active? && source_training_ended?(affiliation) } - return :noop if ended.empty? + def deactivate(_rows) + targets = deactivatable_affiliations + return :noop if targets.empty? - ended.each { |affiliation| affiliation.update!(end_date: affiliation.start_date || Date.current) } + targets.each { |affiliation| affiliation.update!(end_date: affiliation.start_date || Date.current) } :deactivate end diff --git a/app/views/events/_bulk_actions_menu.html.erb b/app/views/events/_bulk_actions_menu.html.erb index 4d61faffd..8a598d2d1 100644 --- a/app/views/events/_bulk_actions_menu.html.erb +++ b/app/views/events/_bulk_actions_menu.html.erb @@ -24,9 +24,7 @@ <% else %> <%= link_to "Sign-ins", attendance_event_path(@event, return_to: "registrants"), class: item_class %> <% end %> - <% if @event.facilitator_training? %> - <%= link_to "Reconcile affiliations", reconcile_affiliations_event_path(@event), class: item_class %> - <% end %> + <%= link_to "Reconcile affiliations", reconcile_affiliations_event_path(@event), class: item_class %> <%= link_to registrants_event_path(@event, format: :csv), class: item_class, data: { turbo_frame: "_top" } do %> Download CSV <% end %> diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 4bcdf4441..b6ca9f0b7 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -9,12 +9,19 @@

Reconcile affiliations

-

- This step brings facilitator affiliations in line with who registered and attended. Before the training it - creates any missing facilitator affiliations for linked organizations. After the training it - same-days the affiliation of anyone who didn't attend (its end date is set to its start - date, so it no longer counts as active), and reactivates anyone later marked attended. -

+ <% if @event.facilitator_training? %> +

+ This step brings facilitator affiliations in line with who registered and attended. Before the training it + creates any missing facilitator affiliations for linked organizations. After the training it + same-days the affiliation of anyone who didn't attend (its end date is set to its start + date, so it no longer counts as active), and reactivates anyone later marked attended. +

+ <% else %> +

+ This event isn't a facilitator training, so any facilitator affiliation auto-created from it shouldn't exist. + This deletes those. Job affiliations are left untouched. +

+ <% end %>

Only affiliations this app created from a registration are touched — hand-entered affiliations are always left alone. Uncheck a row to spare it this time. @@ -35,17 +42,25 @@ <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %>

<% @rows.each do |row| %> -
diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 3d5b29e5a..7b6851571 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -41,12 +41,17 @@ def registrant_with_affiliation(status:) expect(response.body).to include("Will be created") end - it "redirects for a non-training event" do + 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).to redirect_to(registrants_event_path(non_training)) + expect(response.body).to include("Will be deleted") end it "denies a non-admin" do @@ -89,5 +94,31 @@ def registrant_with_affiliation(status:) post reconcile_affiliations_event_path(upcoming), params: { included: [ key ] } }.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1) end + + it "deletes instead of same-daying when the delete option is checked" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + key = AffiliationServices::ReconcileEvent.key_for(affiliation.person, organization) + + post reconcile_affiliations_event_path(event), params: { included: [ key ], delete: [ key ] } + + expect(Affiliation.exists?(affiliation.id)).to be(false) + 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) + key = AffiliationServices::ReconcileEvent.key_for(person, organization) + + post reconcile_affiliations_event_path(non_training), params: { included: [ key ] } + + expect(Affiliation.exists?(facilitator.id)).to be(false) + expect(Affiliation.exists?(job.id)).to be(true) + end end end From b02b3ef23e62711be06b8904034ee2ee45e614d6 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 08:02:56 -0400 Subject: [PATCH 06/22] Show full reconcile picture: reasons for no-action rows and attendance status Preview now lists every registrant-org pair, grouped by action, and adds a 'Not reconciled' section explaining why each is left alone, with attendance status shown per row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../affiliation_services/reconcile_event.rb | 137 ++++++++++-------- .../reconcile_affiliations/index.html.erb | 102 ++++++++----- .../events/reconcile_affiliations_spec.rb | 11 ++ 3 files changed, 151 insertions(+), 99 deletions(-) diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index aa6bc2ee6..5f53babeb 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -1,24 +1,30 @@ module AffiliationServices # Event-level orchestration for the "Reconcile affiliations" bulk action. Walks - # the event's registrants and the organizations they linked, and per (person, - # org) works out what should happen to their **owned** facilitator affiliation - # (job affiliations are never touched): + # the event's registrants and the organizations they linked, and classifies each + # (person, org) so the confirm page can show exactly what will (and won't) happen + # to their **owned** facilitator affiliation. Job affiliations are never touched. # + # Actions: # :create — facilitator training, none exists yet but one should (pre-event # for anyone, post-event only for attendees). # :deactivate — facilitator training, an owned affiliation whose (ended) - # training they didn't complete. The admin may choose to delete - # it instead of same-daying it (see `delete_keys`). + # training they didn't complete. The admin may delete it instead + # of same-daying it (see `delete_keys`). # :reactivate — facilitator training, an owned affiliation same-dayed earlier, # now attended. - # :delete — NOT a facilitator training: an owned facilitator affiliation was - # auto-created off this event and shouldn't exist, so remove it. + # :delete — NOT a facilitator training: facilitator affiliation(s) + # auto-created off this event that shouldn't exist. + # :noop — nothing to do; the row carries a `reason` for the page. # - # `preview` returns the actionable rows without writing so the admin can see them - # and opt individual rows out; `apply` performs the kept rows and stamps the - # event's `affiliations_reconciled_at`. + # `preview` returns every pair (actionable and not) so the admin sees the full + # picture; `apply` performs the kept actionable rows and stamps the event's + # `affiliations_reconciled_at`. class ReconcileEvent - Row = Struct.new(:person, :organization, :registration, :affiliation, :action, :key, keyword_init: true) + Row = Struct.new(:person, :organization, :registration, :affiliation, :action, :reason, :key, keyword_init: true) do + def actionable? + action != :noop + end + end def self.key_for(person, organization) "#{person.id}:#{organization.id}" @@ -32,15 +38,15 @@ def preview rows end - # Apply the rows whose keys are in `included_keys`. For :deactivate rows whose - # key is also in `delete_keys`, delete the affiliation instead of same-daying - # it. Stamps the event and returns the number of pairs actually changed. + # Apply the actionable rows whose keys are in `included_keys`. For :deactivate + # rows whose key is also in `delete_keys`, delete the affiliation instead of + # same-daying it. Stamps the event and returns the number of pairs changed. def apply(included_keys:, delete_keys: []) included = Array(included_keys).to_set delete_instead = Array(delete_keys).to_set changed = rows.count do |row| - included.include?(row.key) && perform(row, delete_instead: delete_instead.include?(row.key)) + row.actionable? && included.include?(row.key) && perform(row, delete_instead: delete_instead.include?(row.key)) end @event.update!(affiliations_reconciled_at: Time.current) @@ -50,30 +56,45 @@ def apply(included_keys:, delete_keys: []) private def rows - @rows ||= pairs.filter_map { |person, organization, registration| build_row(person, organization, registration) } + @rows ||= pairs.map do |person, organization, registration| + action, reason, affiliation = classify(person, organization) + Row.new(person:, organization:, registration:, affiliation:, action:, reason:, key: self.class.key_for(person, organization)) + end + end + + def classify(person, organization) + owned = owned_facilitators(person, organization) + @event.facilitator_training? ? classify_training(person, organization, owned) : classify_non_training(person, organization, owned) end - def build_row(person, organization, registration) - if @event.facilitator_training? - action = training_action(person, organization) - return if action == :noop + def classify_training(person, organization, owned) + attended = completed_training?(person, organization) - affiliation = action == :create ? nil : owned_facilitator(person, organization) - else - affiliation = owned_facilitator_from_event(person, organization) - return if affiliation.nil? + if owned.any? + return [ :reactivate, nil, owned.find { |a| !a.active? } ] if attended && owned.any? { |a| !a.active? } + return [ :noop, "Active — attended", owned.first ] if attended - action = :delete - end + deactivatable = owned.select { |a| a.active? && source_ended?(a) } + return [ :deactivate, nil, deactivatable.first ] if deactivatable.any? + return [ :noop, "Already deactivated — didn't attend", owned.first ] if owned.none?(&:active?) - Row.new(person:, organization:, registration:, affiliation:, action:, key: self.class.key_for(person, organization)) + [ :noop, "Training hasn't ended yet", owned.first ] + elsif hand_facilitator?(person, organization) + [ :noop, "Hand-entered affiliation — left alone", nil ] + elsif !@event.ended? || attended + [ :create, nil, nil ] + else + [ :noop, "Didn't attend — no affiliation to create", nil ] + end end - def training_action(person, organization) - reconcile = ReconcileFacilitatorAffiliation.new(person:, organization:).plan - return reconcile unless reconcile == :noop + def classify_non_training(person, organization, owned) + from_event = owned.select { |a| a.event_registration&.event_id == @event.id } + return [ :delete, nil, from_event.first ] if from_event.any? + return [ :noop, "Facilitator affiliation from another event — left alone", owned.first ] if owned.any? + return [ :noop, "Hand-entered affiliation — left alone", nil ] if hand_facilitator?(person, organization) - create_needed?(person, organization) ? :create : :noop + [ :noop, "No facilitator affiliation", nil ] end def perform(row, delete_instead:) @@ -82,7 +103,7 @@ def perform(row, delete_instead:) apply_create(row) true when :delete - row.affiliation.destroy! + destroy_from_event(row.person, row.organization) true when :deactivate service = ReconcileFacilitatorAffiliation.new(person: row.person, organization: row.organization) @@ -106,17 +127,30 @@ def apply_create(row) ) end - # A facilitator affiliation should exist but doesn't. Skip when an owned one - # already exists (reconcile handles it — including a deliberately same-dayed - # no-show we must not resurrect) or when a hand-created active-or-pending one - # already covers it. Otherwise create it pre-event for anyone, post-event only - # for those who attended. - def create_needed?(person, organization) - facilitators = person.affiliations.facilitators.where(organization:) - return false if facilitators.where.not(event_registration_id: nil).exists? - return false if facilitators.active_or_pending.exists? + def destroy_from_event(person, organization) + owned_facilitators(person, organization) + .select { |a| a.event_registration&.event_id == @event.id } + .each(&:destroy!) + end + + def completed_training?(person, organization) + ReconcileFacilitatorAffiliation.new(person:, organization:).completed_training? + end - !@event.ended? || ReconcileFacilitatorAffiliation.new(person:, organization:).completed_training? + def source_ended?(affiliation) + affiliation.event_registration&.event&.ended? + end + + def hand_facilitator?(person, organization) + person.affiliations.facilitators.where(organization:, event_registration_id: nil).active_or_pending.exists? + end + + def owned_facilitators(person, organization) + person.affiliations.facilitators + .where(organization:) + .where.not(event_registration_id: nil) + .includes(event_registration: :event) + .to_a end # Distinct (person, organization, registration) triples from the event's @@ -135,24 +169,5 @@ def pairs end end end - - def owned_facilitator(person, organization) - person.affiliations.facilitators - .where(organization:) - .where.not(event_registration_id: nil) - .first - end - - # An owned facilitator affiliation that was auto-created off *this* (non-training) - # event — the row a non-training reconcile removes. Hand-created rows (no link) - # and affiliations from other events are left alone. - def owned_facilitator_from_event(person, organization) - person.affiliations.facilitators - .where(organization:) - .where.not(event_registration_id: nil) - .joins(:event_registration) - .where(event_registrations: { event_id: @event.id }) - .first - end end end diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index b6ca9f0b7..fdcdfdd26 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -34,50 +34,76 @@ <% end %>
+ <% actionable = @rows.select(&:actionable?) %> + <% skipped = @rows.reject(&:actionable?) %> + <% if @rows.empty? %>
- Nothing to reconcile — every facilitator affiliation already matches its attendance. + No registrants have linked an organization, so there's nothing to reconcile.
<% else %> - <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %> -
- <% @rows.each do |row| %> -
- <%= check_box_tag "included[]", row.key, true, id: "included_#{row.key}", class: "h-4 w-4 rounded border-gray-300 text-blue-600" %> - - <% case row.action %> - <% when :deactivate %> - - - Will be deactivated - - <% when :delete %> - - Will be deleted - - <% when :create %> - - Will be created - - <% else %> - - Will be reactivated - - <% end %> -
+ <% if actionable.any? %> + <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %> + <% sections = { create: [ "Will be created", "bg-blue-50 text-blue-700" ], + reactivate: [ "Will be reactivated", "bg-green-50 text-green-700" ], + deactivate: [ "Will be deactivated", "bg-red-50 text-red-700" ], + delete: [ "Will be deleted", "bg-red-100 text-red-800" ] } %> + <% sections.each do |action, (heading, badge_class)| %> + <% action_rows = actionable.select { |row| row.action == action } %> + <% next if action_rows.empty? %> +
+

<%= heading %> (<%= action_rows.size %>)

+
+ <% action_rows.each do |row| %> +
+ <%= check_box_tag "included[]", row.key, true, id: "included_#{row.key}", class: "h-4 w-4 rounded border-gray-300 text-blue-600" %> + + <%= row.registration.attendance_status_label %> + <% if row.action == :deactivate %> + + <% end %> + + <%= heading %> + +
+ <% end %> +
+
<% end %> -
-
- <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> - <%= submit_tag "Reconcile affiliations", class: "btn btn-primary" %> -
+
+ <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= submit_tag "Reconcile affiliations", class: "btn btn-primary" %> +
+ <% end %> + <% end %> + + <% if skipped.any? %> +
+

Not reconciled (<%= skipped.size %>)

+ <% skipped.group_by(&:reason).each do |reason, reason_rows| %> +
+

<%= reason %>

+
+ <% reason_rows.each do |row| %> +
+ + <%= row.person.name %> + — <%= row.organization.name %> + + <%= row.registration.attendance_status_label %> +
+ <% end %> +
+
+ <% end %> +
<% end %> <% end %>
diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 7b6851571..424f1dc2e 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -54,6 +54,17 @@ def registrant_with_affiliation(status:) 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 "denies a non-admin" do sign_in create(:user) From 7c33e81cd12b268a01c19536764f4d214b762ba7 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 08:06:45 -0400 Subject: [PATCH 07/22] Redesign reconcile page: group by person, per-affiliation, editable attendance Preview now groups actionable rows by person with the shared editable attendance chip and a note of their other-org facilitator affiliations; each facilitator affiliation is an individual row showing its date range with an Edit link to the person page. 'Not reconciled' is a collapsible section grouped by reason (hand-entered last), each reason collapsible too. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- .../reconcile_affiliations_controller.rb | 5 +- app/decorators/affiliation_decorator.rb | 8 + .../affiliation_services/reconcile_event.rb | 202 +++++++++--------- .../reconcile_affiliations/index.html.erb | 124 ++++++----- .../events/reconcile_affiliations_spec.rb | 11 +- 6 files changed, 189 insertions(+), 163 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1aefdc350..2290d150e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ action, or `authorize! :workshop, to: :summary?`). - `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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. -- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Per `(person, org)` it decides, for facilitator trainings, `:create` (heal a missing facilitator affiliation — pre-event for anyone, post-event only for attendees), `:deactivate`, or `:reactivate`; for non-training events, `:delete` (remove a facilitator affiliation auto-created off this event, leaving job affiliations). `#preview` returns the actionable rows for the confirm page; `#apply(included_keys:, delete_keys:)` performs the rows the admin kept — creating via `CreateFromRegistration`, deactivating/reactivating via `ReconcileFacilitatorAffiliation`, or deleting (a `:deactivate` row whose key is in `delete_keys` is deleted instead of same-dayed) — and stamps the event's `affiliations_reconciled_at`. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** tied to an org a registrant linked, classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. `#actionable_person_groups` groups the actionable rows by person (with their attendance registration and other-org facilitator affiliations for context) for the confirm page; `#skipped_reason_sections` groups the no-action rows by reason (hand-entered last). `#apply(included_keys:, delete_keys:)` performs the kept rows (keys are `aff:` or `create::`; a `:deactivate` row also in `delete_keys` is deleted instead of same-dayed) and stamps `affiliations_reconciled_at`. Job affiliations are never touched. ### Sectors diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb index f5d1d3391..85296e7de 100644 --- a/app/controllers/events/reconcile_affiliations_controller.rb +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -13,7 +13,10 @@ def index authorize! @event, to: :reconcile_affiliations? track_view("events.reconcile_affiliations", { event_id: @event.id }) - @rows = AffiliationServices::ReconcileEvent.new(@event).preview + reconcile = AffiliationServices::ReconcileEvent.new(@event) + @person_groups = reconcile.actionable_person_groups + @skipped_sections = reconcile.skipped_reason_sections + @has_rows = reconcile.any_rows? @event = @event.decorate end diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 86f3f24dc..2fb8221ea 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -2,4 +2,12 @@ class AffiliationDecorator < ApplicationDecorator def detail(length: nil) "#{person.full_name}: #{title.presence || position} - #{organization.name}" end + + # Compact "started – ended" range for the affiliation, e.g. "Sep 17, 2026 – present". + # Reads "no start date" when unset so a blank date isn't silently omitted. + def date_range + start = start_date ? h.l(start_date, format: :long) : "no start date" + finish = end_date ? h.l(end_date, format: :long) : "present" + "#{start} – #{finish}" + end end diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 5f53babeb..a369a1502 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -1,51 +1,63 @@ module AffiliationServices # Event-level orchestration for the "Reconcile affiliations" bulk action. Walks - # the event's registrants and the organizations they linked, and classifies each - # (person, org) so the confirm page can show exactly what will (and won't) happen - # to their **owned** facilitator affiliation. Job affiliations are never touched. + # the event's registrants and, for each facilitator affiliation tied to an org + # they linked, works out what should happen to it (job affiliations are never + # touched). Produces one row per affiliation so each is individually actionable. # # Actions: # :create — facilitator training, none exists yet but one should (pre-event # for anyone, post-event only for attendees). - # :deactivate — facilitator training, an owned affiliation whose (ended) - # training they didn't complete. The admin may delete it instead - # of same-daying it (see `delete_keys`). - # :reactivate — facilitator training, an owned affiliation same-dayed earlier, - # now attended. - # :delete — NOT a facilitator training: facilitator affiliation(s) - # auto-created off this event that shouldn't exist. - # :noop — nothing to do; the row carries a `reason` for the page. + # :deactivate — facilitator training, owned, its (ended) training wasn't + # completed. The admin may delete it instead of same-daying it. + # :reactivate — facilitator training, owned, same-dayed earlier, now attended. + # :delete — NOT a facilitator training: an owned affiliation auto-created + # off this event that shouldn't exist. + # :noop — nothing to do; the row carries a `reason`. # - # `preview` returns every pair (actionable and not) so the admin sees the full - # picture; `apply` performs the kept actionable rows and stamps the event's - # `affiliations_reconciled_at`. + # `actionable_person_groups` groups the actionable rows by person (with their + # attendance registration and other-org facilitator affiliations for context); + # `skipped_reason_sections` groups the no-action rows by reason (hand-entered + # last). `apply` performs the kept actionable rows and stamps the event. class ReconcileEvent - Row = Struct.new(:person, :organization, :registration, :affiliation, :action, :reason, :key, keyword_init: true) do + HAND_ENTERED = "Hand-entered affiliation — left alone".freeze + + Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key, keyword_init: true) do def actionable? action != :noop end end - def self.key_for(person, organization) - "#{person.id}:#{organization.id}" - end - def initialize(event) @event = event end - def preview - rows + # Actionable rows grouped by person: [{ person:, registration:, rows:, + # other_facilitators: }]. `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 + + # No-action rows grouped by reason, hand-entered last: [[reason, [rows]]]. + def skipped_reason_sections + grouped = all_rows.reject(&:actionable?).group_by(&:reason) + grouped.keys.sort_by { |reason| [ reason == HAND_ENTERED ? 1 : 0, reason ] }.map { |reason| [ reason, grouped[reason] ] } + end + + def any_rows? + all_rows.any? end # Apply the actionable rows whose keys are in `included_keys`. For :deactivate # rows whose key is also in `delete_keys`, delete the affiliation instead of - # same-daying it. Stamps the event and returns the number of pairs changed. + # same-daying it. Stamps the event and returns the number of rows changed. def apply(included_keys:, delete_keys: []) included = Array(included_keys).to_set delete_instead = Array(delete_keys).to_set - changed = rows.count do |row| + changed = all_rows.count do |row| row.actionable? && included.include?(row.key) && perform(row, delete_instead: delete_instead.include?(row.key)) end @@ -55,82 +67,78 @@ def apply(included_keys:, delete_keys: []) private - def rows - @rows ||= pairs.map do |person, organization, registration| - action, reason, affiliation = classify(person, organization) - Row.new(person:, organization:, registration:, affiliation:, action:, reason:, key: self.class.key_for(person, organization)) + def all_rows + @all_rows ||= registrations_by_person.flat_map do |person, registrations| + registration = registrations.first + linked_organizations(registrations).flat_map { |organization| rows_for(person, registration, organization) } end end - def classify(person, organization) - owned = owned_facilitators(person, organization) - @event.facilitator_training? ? classify_training(person, organization, owned) : classify_non_training(person, organization, owned) + def rows_for(person, registration, organization) + attended = completed_training?(person, organization) + facilitators = person.affiliations.facilitators + .where(organization:) + .includes(event_registration: :event) + .to_a + + rows = facilitators.map { |affiliation| affiliation_row(person, registration, organization, affiliation, attended) } + rows << create_row(person, registration, organization, attended) if facilitators.empty? && @event.facilitator_training? + rows.compact end - def classify_training(person, organization, owned) - attended = completed_training?(person, organization) + def affiliation_row(person, registration, organization, affiliation, attended) + action, reason = classify_affiliation(affiliation, attended) + Row.new(person:, registration:, organization:, affiliation:, action:, reason:, key: "aff:#{affiliation.id}") + end + + def classify_affiliation(affiliation, attended) + owned = affiliation.event_registration_id.present? - if owned.any? - return [ :reactivate, nil, owned.find { |a| !a.active? } ] if attended && owned.any? { |a| !a.active? } - return [ :noop, "Active — attended", owned.first ] if attended + unless @event.facilitator_training? + return [ :delete, nil ] if owned && affiliation.event_registration&.event_id == @event.id + return [ :noop, "Facilitator affiliation from another event" ] if owned - deactivatable = owned.select { |a| a.active? && source_ended?(a) } - return [ :deactivate, nil, deactivatable.first ] if deactivatable.any? - return [ :noop, "Already deactivated — didn't attend", owned.first ] if owned.none?(&:active?) + return [ :noop, HAND_ENTERED ] + end + + return [ :noop, HAND_ENTERED ] unless owned - [ :noop, "Training hasn't ended yet", owned.first ] - elsif hand_facilitator?(person, organization) - [ :noop, "Hand-entered affiliation — left alone", nil ] - elsif !@event.ended? || attended - [ :create, nil, nil ] + if attended + affiliation.active? ? [ :noop, "Active — attended" ] : [ :reactivate, nil ] + elsif affiliation.active? && source_ended?(affiliation) + [ :deactivate, nil ] + elsif affiliation.active? + [ :noop, "Training hasn't ended yet" ] else - [ :noop, "Didn't attend — no affiliation to create", nil ] + [ :noop, "Already deactivated — didn't attend" ] end end - def classify_non_training(person, organization, owned) - from_event = owned.select { |a| a.event_registration&.event_id == @event.id } - return [ :delete, nil, from_event.first ] if from_event.any? - return [ :noop, "Facilitator affiliation from another event — left alone", owned.first ] if owned.any? - return [ :noop, "Hand-entered affiliation — left alone", nil ] if hand_facilitator?(person, organization) - - [ :noop, "No facilitator affiliation", nil ] + def create_row(person, registration, organization, attended) + if !@event.ended? || attended + Row.new(person:, registration:, organization:, affiliation: nil, action: :create, reason: nil, + key: "create:#{person.id}:#{organization.id}") + else + Row.new(person:, registration:, organization:, affiliation: nil, action: :noop, + reason: "Didn't attend — no affiliation created", key: "none:#{person.id}:#{organization.id}") + end end def perform(row, delete_instead:) case row.action when :create - apply_create(row) - true + AffiliationServices::CreateFromRegistration.call( + person: row.person, organization: row.organization, facilitator_training: true, + training_date: @event.start_date, event_registration: row.registration + ) when :delete - destroy_from_event(row.person, row.organization) - true + row.affiliation.destroy! when :deactivate - service = ReconcileFacilitatorAffiliation.new(person: row.person, organization: row.organization) - targets = service.deactivatable_affiliations - return false if targets.empty? - - delete_instead ? targets.each(&:destroy!) : service.call - true - else # :reactivate - ReconcileFacilitatorAffiliation.call(person: row.person, organization: row.organization) != :noop + delete_instead ? row.affiliation.destroy! : row.affiliation.update!(end_date: row.affiliation.start_date || Date.current) + when :reactivate + row.affiliation.update!(end_date: nil) end - end - - def apply_create(row) - AffiliationServices::CreateFromRegistration.call( - person: row.person, - organization: row.organization, - facilitator_training: true, - training_date: @event.start_date, - event_registration: row.registration - ) - end - - def destroy_from_event(person, organization) - owned_facilitators(person, organization) - .select { |a| a.event_registration&.event_id == @event.id } - .each(&:destroy!) + true end def completed_training?(person, organization) @@ -141,33 +149,23 @@ def source_ended?(affiliation) affiliation.event_registration&.event&.ended? end - def hand_facilitator?(person, organization) - person.affiliations.facilitators.where(organization:, event_registration_id: nil).active_or_pending.exists? + def other_facilitators(person) + person.affiliations.active.facilitators + .where.not(organization_id: linked_org_ids(person)) + .includes(:organization) + .to_a end - def owned_facilitators(person, organization) - person.affiliations.facilitators - .where(organization:) - .where.not(event_registration_id: nil) - .includes(event_registration: :event) - .to_a + def linked_org_ids(person) + linked_organizations(registrations_by_person[person]).map(&:id) end - # Distinct (person, organization, registration) triples from the event's - # registrants and the organizations each linked to their registration. - def pairs - @pairs ||= begin - seen = Set.new - @event.event_registrations.includes(:registrant, :organizations).flat_map do |registration| - registration.organizations.filter_map do |organization| - key = [ registration.registrant_id, organization.id ] - next if seen.include?(key) - - seen << key - [ registration.registrant, organization, registration ] - end - end - 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/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index fdcdfdd26..dad86ce0a 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -13,8 +13,8 @@

This step brings facilitator affiliations in line with who registered and attended. Before the training it creates any missing facilitator affiliations for linked organizations. After the training it - same-days the affiliation of anyone who didn't attend (its end date is set to its start - date, so it no longer counts as active), and reactivates anyone later marked attended. + same-days the affiliation of anyone who didn't attend, and reactivates anyone later marked + attended. Job affiliations are never touched.

<% else %>

@@ -22,88 +22,108 @@ This deletes those. Job affiliations are left untouched.

<% end %> -

- Only affiliations this app created from a registration are touched — hand-entered affiliations are always left - alone. Uncheck a row to spare it this time. -

+

Only affiliations this app created from a registration are touched — hand-entered ones are always left alone.

<% if @event.affiliations_reconciled_at %>

Last reconciled <%= @event.affiliations_reconciled_at.to_fs(:long) %>.

<% end %> - <% if @event.affiliations_reconciliation_stale? %> -

Attendance has changed since the last reconciliation — re-run to bring affiliations up to date.

+ <% if @person_groups.any? && @event.affiliations_reconciliation_stale? %> +

Attendance has changed since the last reconciliation — apply again below to bring affiliations up to date.

<% end %> - <% actionable = @rows.select(&:actionable?) %> - <% skipped = @rows.reject(&:actionable?) %> - - <% if @rows.empty? %> + <% unless @has_rows %>
No registrants have linked an organization, so there's nothing to reconcile.
- <% else %> - <% if actionable.any? %> - <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %> - <% sections = { create: [ "Will be created", "bg-blue-50 text-blue-700" ], - reactivate: [ "Will be reactivated", "bg-green-50 text-green-700" ], - deactivate: [ "Will be deactivated", "bg-red-50 text-red-700" ], - delete: [ "Will be deleted", "bg-red-100 text-red-800" ] } %> - <% sections.each do |action, (heading, badge_class)| %> - <% action_rows = actionable.select { |row| row.action == action } %> - <% next if action_rows.empty? %> -
-

<%= heading %> (<%= action_rows.size %>)

-
- <% action_rows.each do |row| %> -
+ <% end %> + + <% badges = { create: [ "Will be created", "bg-blue-50 text-blue-700" ], + reactivate: [ "Will be reactivated", "bg-green-50 text-green-700" ], + deactivate: [ "Will be deactivated", "bg-red-50 text-red-700" ], + delete: [ "Will be deleted", "bg-red-100 text-red-800" ] } %> + + <% if @person_groups.any? %> + <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %> +
+ <% @person_groups.each do |group| %> +
+
+ <%= group[:person].name %> + <%= render "event_registrations/attendance_status_badge", registration: group[:registration] %> +
+ <% if group[:other_facilitators].any? %> +

+ Also a facilitator at <%= group[:other_facilitators].map { |a| a.organization.name }.to_sentence %>. +

+ <% end %> + +
+ <% group[:rows].each do |row| %> + <% heading, badge_class = badges[row.action] %> +
<%= check_box_tag "included[]", row.key, true, id: "included_#{row.key}", class: "h-4 w-4 rounded border-gray-300 text-blue-600" %> - - <%= row.registration.attendance_status_label %> + + <%= row.organization.name %> + <% if row.affiliation %> + · <%= row.affiliation.decorate.date_range %> + <% end %> + <% if row.action == :deactivate %> <% end %> - - <%= heading %> - + <% if row.affiliation %> + <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> + <% end %> + <%= heading %>
<% end %>
<% end %> +
-
- <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> - <%= submit_tag "Reconcile affiliations", class: "btn btn-primary" %> -
- <% end %> +
+ <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= submit_tag "Reconcile affiliations", class: "btn btn-primary" %> +
<% end %> + <% end %> - <% if skipped.any? %> -
-

Not reconciled (<%= skipped.size %>)

- <% skipped.group_by(&:reason).each do |reason, reason_rows| %> -
-

<%= reason %>

-
- <% reason_rows.each do |row| %> -
+ <% if @skipped_sections.any? %> + <% skipped_count = @skipped_sections.sum { |(_reason, rows)| rows.size } %> +
+ + Not reconciled (<%= skipped_count %>) + +
+ <% @skipped_sections.each do |reason, rows| %> +
+ + <%= reason %> (<%= rows.size %>) + +
+ <% rows.each do |row| %> +
<%= row.person.name %> — <%= row.organization.name %> + <% if row.affiliation %> + · <%= row.affiliation.decorate.date_range %> + <% end %> <%= row.registration.attendance_status_label %> + <% if row.affiliation %> + <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> + <% end %>
<% end %>
-
+
<% end %> -
- <% end %> +
+ <% end %>
diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 424f1dc2e..86d698f58 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -77,9 +77,8 @@ def registrant_with_affiliation(status:) describe "POST create" do it "deactivates the included non-completer and stamps the event" do _person, affiliation = registrant_with_affiliation(status: "no_show") - key = AffiliationServices::ReconcileEvent.key_for(affiliation.person, organization) - post reconcile_affiliations_event_path(event), params: { included: [ key ] } + post reconcile_affiliations_event_path(event), params: { included: [ "aff:#{affiliation.id}" ] } expect(response).to redirect_to(registrants_event_path(event)) expect(affiliation.reload).not_to be_active @@ -99,16 +98,15 @@ def registrant_with_affiliation(status:) person = create(:person) reg = create(:event_registration, event: upcoming, registrant: person, status: "registered") create(:event_registration_organization, event_registration: reg, organization: organization) - key = AffiliationServices::ReconcileEvent.key_for(person, organization) expect { - post reconcile_affiliations_event_path(upcoming), params: { included: [ key ] } + post reconcile_affiliations_event_path(upcoming), params: { included: [ "create:#{person.id}:#{organization.id}" ] } }.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1) end it "deletes instead of same-daying when the delete option is checked" do _person, affiliation = registrant_with_affiliation(status: "no_show") - key = AffiliationServices::ReconcileEvent.key_for(affiliation.person, organization) + key = "aff:#{affiliation.id}" post reconcile_affiliations_event_path(event), params: { included: [ key ], delete: [ key ] } @@ -124,9 +122,8 @@ def registrant_with_affiliation(status:) start_date: 1.month.ago.to_date, event_registration: reg) job = create(:affiliation, person: person, organization: organization, title: "Counselor", event_registration: reg) - key = AffiliationServices::ReconcileEvent.key_for(person, organization) - post reconcile_affiliations_event_path(non_training), params: { included: [ key ] } + post reconcile_affiliations_event_path(non_training), params: { included: [ "aff:#{facilitator.id}" ] } expect(Affiliation.exists?(facilitator.id)).to be(false) expect(Affiliation.exists?(job.id)).to be(true) From 74d0a1e7d2723a3724334e8a3173c5ea078a8833 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 08:10:45 -0400 Subject: [PATCH 08/22] Editable attendance chip in skipped rows, Edit first, shorter dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the shared attendance chip (not plain text) in the Not reconciled rows so status is editable there too, move the Edit link ahead of the status, and shorten the affiliation date range to 'Oct 13, 2026 – present'. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/affiliation_decorator.rb | 6 ++--- .../reconcile_affiliations/index.html.erb | 8 +++---- spec/decorators/affiliation_decorator_spec.rb | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 spec/decorators/affiliation_decorator_spec.rb diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 2fb8221ea..3fac6cece 100644 --- a/app/decorators/affiliation_decorator.rb +++ b/app/decorators/affiliation_decorator.rb @@ -3,11 +3,11 @@ def detail(length: nil) "#{person.full_name}: #{title.presence || position} - #{organization.name}" end - # Compact "started – ended" range for the affiliation, e.g. "Sep 17, 2026 – present". + # Compact "started – ended" range for the affiliation, e.g. "Oct 13, 2026 – present". # Reads "no start date" when unset so a blank date isn't silently omitted. def date_range - start = start_date ? h.l(start_date, format: :long) : "no start date" - finish = end_date ? h.l(end_date, format: :long) : "present" + 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/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index dad86ce0a..463a9c27a 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -68,15 +68,15 @@ · <%= row.affiliation.decorate.date_range %> <% end %> + <% if row.affiliation %> + <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> + <% end %> <% if row.action == :deactivate %> <% end %> - <% if row.affiliation %> - <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> - <% end %> <%= heading %> <% end %> @@ -114,10 +114,10 @@ · <%= row.affiliation.decorate.date_range %> <% end %> - <%= row.registration.attendance_status_label %> <% if row.affiliation %> <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> <% end %> + <%= render "event_registrations/attendance_status_badge", registration: row.registration %> <% end %> diff --git a/spec/decorators/affiliation_decorator_spec.rb b/spec/decorators/affiliation_decorator_spec.rb new file mode 100644 index 000000000..bb67da60b --- /dev/null +++ b/spec/decorators/affiliation_decorator_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe AffiliationDecorator do + describe "#date_range" do + it "reads 'present' when there is no end date" do + affiliation = build(:affiliation, start_date: Date.new(2026, 10, 13), end_date: nil) + + expect(affiliation.decorate.date_range).to eq("Oct 13, 2026 – present") + end + + it "shows both dates when the affiliation has ended" do + affiliation = build(:affiliation, start_date: Date.new(2026, 10, 13), end_date: Date.new(2026, 10, 13)) + + expect(affiliation.decorate.date_range).to eq("Oct 13, 2026 – Oct 13, 2026") + end + + it "reads 'no start date' when the start date is unset" do + affiliation = build(:affiliation, start_date: nil, end_date: nil) + + expect(affiliation.decorate.date_range).to eq("no start date – present") + end + end +end From 660165701c3a57af58cce0c649b4207eb5d9d9c4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 08:20:12 -0400 Subject: [PATCH 09/22] Reconcile all facilitator affiliations incl. hand-entered; clearer action controls Reconcile every facilitator affiliation for a linked org (not just app-created), gated to post-event so a pre-event run never deactivates and with per-row opt-out. Move the include checkbox into the action chip so it's clear checking it performs that action, move the other-org facilitator note below the rows, link org/dates to the specific affiliation anchor and names to the registration, and strengthen the Not reconciled section headers (open by default, expand/collapse all). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- .../affiliation_services/reconcile_event.rb | 44 ++++++----- .../reconcile_affiliations/index.html.erb | 77 +++++++++++-------- .../events/reconcile_affiliations_spec.rb | 22 ++++++ 4 files changed, 91 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2290d150e..c14bb88e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,7 +245,7 @@ action, or `authorize! :workshop, to: :summary?`). - `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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. -- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** tied to an org a registrant linked, classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. `#actionable_person_groups` groups the actionable rows by person (with their attendance registration and other-org facilitator affiliations for context) for the confirm page; `#skipped_reason_sections` groups the no-action rows by reason (hand-entered last). `#apply(included_keys:, delete_keys:)` performs the kept rows (keys are `aff:` or `create::`; a `:deactivate` row also in `delete_keys` is deleted instead of same-dayed) and stamps `affiliations_reconciled_at`. Job affiliations are never touched. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** for an org a registrant linked — **hand-entered rows included, not just app-created ones** — classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. Deactivation is gated to post-event (owned rows on their source training's `ended?`, hand-entered on this event's `ended?`), so a pre-event run never deactivates. `#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. `#apply(included_keys:, delete_keys:)` performs the kept rows (keys are `aff:` or `create::`; a `:deactivate` row also in `delete_keys` is deleted instead of same-dayed) and stamps `affiliations_reconciled_at`. Job affiliations are never touched. ### Sectors diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index a369a1502..958e38f8b 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -16,11 +16,10 @@ module AffiliationServices # # `actionable_person_groups` groups the actionable rows by person (with their # attendance registration and other-org facilitator affiliations for context); - # `skipped_reason_sections` groups the no-action rows by reason (hand-entered - # last). `apply` performs the kept actionable rows and stamps the event. + # `skipped_reason_sections` groups the no-action rows by reason. `apply` performs + # the kept actionable rows and stamps the event. Every facilitator affiliation for + # a linked org is reconciled — hand-entered rows included, not just app-created ones. class ReconcileEvent - HAND_ENTERED = "Hand-entered affiliation — left alone".freeze - Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key, keyword_init: true) do def actionable? action != :noop @@ -43,7 +42,7 @@ def actionable_person_groups # No-action rows grouped by reason, hand-entered last: [[reason, [rows]]]. def skipped_reason_sections grouped = all_rows.reject(&:actionable?).group_by(&:reason) - grouped.keys.sort_by { |reason| [ reason == HAND_ENTERED ? 1 : 0, reason ] }.map { |reason| [ reason, grouped[reason] ] } + grouped.keys.sort.map { |reason| [ reason, grouped[reason] ] } end def any_rows? @@ -75,14 +74,24 @@ def all_rows end def rows_for(person, registration, organization) - attended = completed_training?(person, organization) facilitators = person.affiliations.facilitators .where(organization:) .includes(event_registration: :event) .to_a + unless @event.facilitator_training? + # A non-training event confers no facilitation, so it only removes + # facilitator affiliations that were auto-created off it. + return facilitators.filter_map do |affiliation| + next unless affiliation.event_registration&.event_id == @event.id + + Row.new(person:, registration:, organization:, affiliation:, action: :delete, reason: nil, key: "aff:#{affiliation.id}") + end + end + + attended = completed_training?(person, organization) rows = facilitators.map { |affiliation| affiliation_row(person, registration, organization, affiliation, attended) } - rows << create_row(person, registration, organization, attended) if facilitators.empty? && @event.facilitator_training? + rows << create_row(person, registration, organization, attended) if facilitators.empty? rows.compact end @@ -91,21 +100,14 @@ def affiliation_row(person, registration, organization, affiliation, attended) Row.new(person:, registration:, organization:, affiliation:, action:, reason:, key: "aff:#{affiliation.id}") end + # Reconciles EVERY facilitator affiliation for the org — hand-entered ones + # included, not just app-created rows. Deactivation only applies once the + # governing training has ended (a hand-entered row has no source training, so + # it's gated on this event ending) — so a pre-event run never deactivates. def classify_affiliation(affiliation, attended) - owned = affiliation.event_registration_id.present? - - unless @event.facilitator_training? - return [ :delete, nil ] if owned && affiliation.event_registration&.event_id == @event.id - return [ :noop, "Facilitator affiliation from another event" ] if owned - - return [ :noop, HAND_ENTERED ] - end - - return [ :noop, HAND_ENTERED ] unless owned - if attended affiliation.active? ? [ :noop, "Active — attended" ] : [ :reactivate, nil ] - elsif affiliation.active? && source_ended?(affiliation) + elsif affiliation.active? && deactivation_ready?(affiliation) [ :deactivate, nil ] elsif affiliation.active? [ :noop, "Training hasn't ended yet" ] @@ -114,6 +116,10 @@ def classify_affiliation(affiliation, attended) end end + def deactivation_ready?(affiliation) + affiliation.event_registration_id ? source_ended?(affiliation) : @event.ended? + end + def create_row(person, registration, organization, attended) if !@event.ended? || attended Row.new(person:, registration:, organization:, affiliation: nil, action: :create, reason: nil, diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 463a9c27a..967d8ec4c 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -22,7 +22,7 @@ This deletes those. Job affiliations are left untouched.

<% end %> -

Only affiliations this app created from a registration are touched — hand-entered ones are always left alone.

+

Every facilitator affiliation for a linked organization is reconciled against attendance — including hand-entered ones. Review each row and uncheck any you want to leave as-is.

<% if @event.affiliations_reconciled_at %>

Last reconciled <%= @event.affiliations_reconciled_at.to_fs(:long) %>.

<% end %> @@ -47,40 +47,45 @@
<% @person_groups.each do |group| %>
-
- <%= group[:person].name %> +
+ <%= link_to group[:person].name, edit_event_registration_path(group[:registration]), target: "_blank", rel: "noopener", class: "font-semibold text-gray-900 hover:underline hover:text-blue-700" %> <%= render "event_registrations/attendance_status_badge", registration: group[:registration] %>
- <% if group[:other_facilitators].any? %> -

- Also a facilitator at <%= group[:other_facilitators].map { |a| a.organization.name }.to_sentence %>. -

- <% end %>
<% group[:rows].each do |row| %> <% heading, badge_class = badges[row.action] %>
- <%= check_box_tag "included[]", row.key, true, id: "included_#{row.key}", class: "h-4 w-4 rounded border-gray-300 text-blue-600" %> - - <%= row.organization.name %> - <% if row.affiliation %> + <% if row.affiliation %> + <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "flex-1 min-w-0 text-sm hover:underline" do %> + <%= row.organization.name %> · <%= row.affiliation.decorate.date_range %> <% end %> - - <% if row.affiliation %> - <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> + <% else %> + <%= row.organization.name %> <% end %> - <% if row.action == :deactivate %> -
<% end %>
+ + <% if group[:other_facilitators].any? %> +

+ Also a facilitator at <%= group[:other_facilitators].map { |a| a.organization.name }.to_sentence %>. +

+ <% end %>
<% end %>
@@ -94,29 +99,33 @@ <% if @skipped_sections.any? %> <% skipped_count = @skipped_sections.sum { |(_reason, rows)| rows.size } %> -
- - Not reconciled (<%= skipped_count %>) +
+ + Not reconciled (<%= skipped_count %>) -
+
+
+ +
<% @skipped_sections.each do |reason, rows| %> -
- - <%= reason %> (<%= rows.size %>) +
+ + <%= reason %> (<%= rows.size %>)
<% rows.each do |row| %>
- <%= row.person.name %> - — <%= row.organization.name %> + <%= 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 %> - · <%= row.affiliation.decorate.date_range %> + <%= 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 %> - <% if row.affiliation %> - <%= link_to "Edit", edit_person_path(row.person, anchor: "affiliations"), class: "text-xs text-blue-600 hover:underline", target: "_blank", rel: "noopener" %> - <% end %> <%= render "event_registrations/attendance_status_badge", registration: row.registration %>
<% end %> diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 86d698f58..71ee917a3 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -65,6 +65,17 @@ def registrant_with_affiliation(status:) 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("Will be deactivated") + end + it "denies a non-admin" do sign_in create(:user) @@ -113,6 +124,17 @@ def registrant_with_affiliation(status:) expect(Affiliation.exists?(affiliation.id)).to be(false) end + it "deactivates a hand-entered facilitator affiliation when included" 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 reconcile_affiliations_event_path(event), params: { included: [ "aff:#{hand_entered.id}" ] } + + 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) From 620eb7032273de2601b2def43910530e4b6ff9b4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 08:24:39 -0400 Subject: [PATCH 10/22] Action toggles as buttons with error-red on select, hover tooltips, header note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style the include/delete-instead controls as buttons that only turn error-red when selected (peer-checked, no JS); add hover tooltips explaining deactivate/delete (delete as bullets: this affiliation only, job + other-org affiliations untouched). Move the 'Also a facilitator at …' note beside the name, truncated and linking to the single affiliation anchor (or the affiliations section when several). Order the Not reconciled sections with 'Active — attended' second-to-last. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../affiliation_services/reconcile_event.rb | 13 +++++- .../reconcile_affiliations/_tooltip.html.erb | 17 +++++++ .../reconcile_affiliations/index.html.erb | 44 ++++++++++++------- 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 app/views/events/reconcile_affiliations/_tooltip.html.erb diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 958e38f8b..7cd9816cb 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -39,10 +39,19 @@ def actionable_person_groups end end - # No-action rows grouped by reason, hand-entered last: [[reason, [rows]]]. + # No-action rows grouped by reason: [[reason, [rows]]]. "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.map { |reason| [ reason, grouped[reason] ] } + grouped.keys.sort_by { |reason| [ reason_rank(reason), reason ] }.map { |reason| [ reason, grouped[reason] ] } + end + + def reason_rank(reason) + case reason + when "Active — attended" then 8 + when "Didn't attend — no affiliation created" then 9 + else 0 + end end def any_rows? diff --git a/app/views/events/reconcile_affiliations/_tooltip.html.erb b/app/views/events/reconcile_affiliations/_tooltip.html.erb new file mode 100644 index 000000000..582555db3 --- /dev/null +++ b/app/views/events/reconcile_affiliations/_tooltip.html.erb @@ -0,0 +1,17 @@ +<%# Hover explanation for a reconcile action. `kind` is the action symbol. %> + diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 967d8ec4c..1b74df0ae 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -46,15 +46,31 @@ <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %>
<% @person_groups.each do |group| %> + <% checked_class = { + create: "peer-checked:bg-blue-50 peer-checked:text-blue-700 peer-checked:border-blue-300", + reactivate: "peer-checked:bg-green-50 peer-checked:text-green-700 peer-checked:border-green-300", + deactivate: "peer-checked:bg-red-50 peer-checked:text-red-700 peer-checked:border-red-300", + delete: "peer-checked:bg-red-50 peer-checked:text-red-700 peer-checked:border-red-300" + } %> + <% button_base = "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm hover:bg-gray-50 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-400" %>
- <%= link_to group[:person].name, edit_event_registration_path(group[:registration]), target: "_blank", rel: "noopener", class: "font-semibold text-gray-900 hover:underline hover:text-blue-700" %> +
+ <%= link_to group[:person].name, edit_event_registration_path(group[:registration]), target: "_blank", rel: "noopener", class: "font-semibold text-gray-900 hover:underline hover:text-blue-700 shrink-0" %> + <% others = group[:other_facilitators] %> + <% if others.any? %> + <% note = "Also a facilitator at #{others.map { |a| a.organization.name }.to_sentence}." %> + <% anchor = others.one? ? dom_id(others.first) : "affiliations" %> + <%= link_to note, edit_person_path(group[:person], anchor: anchor), target: "_blank", rel: "noopener", + title: note, class: "text-xs text-gray-500 truncate hover:underline hover:text-blue-700 min-w-0" %> + <% end %> +
<%= render "event_registrations/attendance_status_badge", registration: group[:registration] %>
<% group[:rows].each do |row| %> - <% heading, badge_class = badges[row.action] %> + <% heading, _badge_class = badges[row.action] %>
<% if row.affiliation %> <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "flex-1 min-w-0 text-sm hover:underline" do %> @@ -64,28 +80,24 @@ <% else %> <%= row.organization.name %> <% end %> -
+
<% if row.action == :deactivate %> -
<% end %>
- - <% if group[:other_facilitators].any? %> -

- Also a facilitator at <%= group[:other_facilitators].map { |a| a.organization.name }.to_sentence %>. -

- <% end %>
<% end %>
From 700dd67b130bba899f2bea95368964c601d5968f Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 08:31:05 -0400 Subject: [PATCH 11/22] Move Collapse all into the Not reconciled header; more space between sections Co-Authored-By: Claude Opus 4.8 (1M context) --- .../events/reconcile_affiliations/index.html.erb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 1b74df0ae..cf3335c76 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -111,15 +111,13 @@ <% if @skipped_sections.any? %> <% skipped_count = @skipped_sections.sum { |(_reason, rows)| rows.size } %> -
- +
+ Not reconciled (<%= skipped_count %>) + -
-
- -
+
<% @skipped_sections.each do |reason, rows| %>
From a5c75a19987a74fa287421a3f4bf77e813617372 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:06:13 -0400 Subject: [PATCH 12/22] Show checkbox inside action buttons; make deactivate/delete mutually exclusive Move the checkbox back inside each button (has-[:checked] colors the whole button on select, error-red for deactivate/delete). Add an exclusive-checkboxes Stimulus controller so checking 'Delete instead' clears 'Will be deactivated' and vice versa; apply now treats a delete key as delete regardless of the include key. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../exclusive_checkboxes_controller.js | 17 ++++++++++++ .../affiliation_services/reconcile_event.rb | 19 +++++++++++--- .../reconcile_affiliations/index.html.erb | 26 +++++++++---------- 3 files changed, 45 insertions(+), 17 deletions(-) create mode 100644 app/frontend/javascript/controllers/exclusive_checkboxes_controller.js diff --git a/app/frontend/javascript/controllers/exclusive_checkboxes_controller.js b/app/frontend/javascript/controllers/exclusive_checkboxes_controller.js new file mode 100644 index 000000000..cd165d589 --- /dev/null +++ b/app/frontend/javascript/controllers/exclusive_checkboxes_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="exclusive-checkboxes" +// Makes a small group of checkboxes mutually exclusive — like radios, but any can +// be left unchecked. Checking one clears the others in the group (e.g. "Delete +// instead" and "Will be deactivated" are two choices for the same row). +export default class extends Controller { + static targets = ["box"] + + select(event) { + if (!event.target.checked) return + + this.boxTargets.forEach((box) => { + if (box !== event.target) box.checked = false + }) + } +} diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 7cd9816cb..9bab55a53 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -63,10 +63,19 @@ def any_rows? # same-daying it. Stamps the event and returns the number of rows changed. def apply(included_keys:, delete_keys: []) included = Array(included_keys).to_set - delete_instead = Array(delete_keys).to_set + deletes = Array(delete_keys).to_set changed = all_rows.count do |row| - row.actionable? && included.include?(row.key) && perform(row, delete_instead: delete_instead.include?(row.key)) + next false unless row.actionable? + + if deletes.include?(row.key) && row.affiliation + row.affiliation.destroy! + true + elsif included.include?(row.key) + perform(row) + else + false + end end @event.update!(affiliations_reconciled_at: Time.current) @@ -139,7 +148,7 @@ def create_row(person, registration, organization, attended) end end - def perform(row, delete_instead:) + def perform(row) case row.action when :create AffiliationServices::CreateFromRegistration.call( @@ -149,7 +158,9 @@ def perform(row, delete_instead:) when :delete row.affiliation.destroy! when :deactivate - delete_instead ? row.affiliation.destroy! : row.affiliation.update!(end_date: row.affiliation.start_date || Date.current) + # Same-day it: end_date = the affiliation's own start_date (start_date itself + # is never changed), which the model turns into inactive. + row.affiliation.update!(end_date: row.affiliation.start_date || Date.current) when :reactivate row.affiliation.update!(end_date: nil) end diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index cf3335c76..a0ced04a2 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -47,12 +47,12 @@
<% @person_groups.each do |group| %> <% checked_class = { - create: "peer-checked:bg-blue-50 peer-checked:text-blue-700 peer-checked:border-blue-300", - reactivate: "peer-checked:bg-green-50 peer-checked:text-green-700 peer-checked:border-green-300", - deactivate: "peer-checked:bg-red-50 peer-checked:text-red-700 peer-checked:border-red-300", - delete: "peer-checked:bg-red-50 peer-checked:text-red-700 peer-checked:border-red-300" + create: "has-[:checked]:bg-blue-50 has-[:checked]:text-blue-700 has-[:checked]:border-blue-300", + reactivate: "has-[:checked]:bg-green-50 has-[:checked]:text-green-700 has-[:checked]:border-green-300", + deactivate: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300", + delete: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300" } %> - <% button_base = "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm hover:bg-gray-50 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-400" %> + <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %>
@@ -80,18 +80,18 @@ <% else %> <%= row.organization.name %> <% end %> -
+ <%# Checkbox lives inside the button; has-[:checked] colors the whole button when selected. %> +
data-controller="exclusive-checkboxes"<% end %>> <% if row.action == :deactivate %> -
From a247dbb37ab0ef7eec3dd14025420ac1f8c8c3c8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:18:17 -0400 Subject: [PATCH 13/22] Two-step reconcile: Preview changes then confirmation screen 'Preview changes' now posts to a confirmation screen that shows exactly which affiliations get created/reactivated/deactivated/deleted (actioned rows only), with Go back to edit (selections restored) or Perform changes. Add per-row instructions under the action buttons and a header row with a warning that checked boxes change affiliations. New exclusive-checkboxes controller registered. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +- .../reconcile_affiliations_controller.rb | 15 ++++++ .../affiliation_services/reconcile_event.rb | 17 ++++++ .../reconcile_affiliations/confirm.html.erb | 52 +++++++++++++++++++ .../reconcile_affiliations/index.html.erb | 44 +++++++++++----- config/routes.rb | 3 +- .../events/reconcile_affiliations_spec.rb | 35 ++++++++++--- spec/views/page_bg_class_alignment_spec.rb | 1 + 8 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 app/views/events/reconcile_affiliations/confirm.html.erb diff --git a/AGENTS.md b/AGENTS.md index c14bb88e8..2d2c73008 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (78) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -245,7 +245,7 @@ action, or `authorize! :workshop, to: :summary?`). - `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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. -- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** for an org a registrant linked — **hand-entered rows included, not just app-created ones** — classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. Deactivation is gated to post-event (owned rows on their source training's `ended?`, hand-entered on this event's `ended?`), so a pre-event run never deactivates. `#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. `#apply(included_keys:, delete_keys:)` performs the kept rows (keys are `aff:` or `create::`; a `:deactivate` row also in `delete_keys` is deleted instead of same-dayed) and stamps `affiliations_reconciled_at`. Job affiliations are never touched. +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** for an org a registrant linked — **hand-entered rows included, not just app-created ones** — classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. Deactivation is gated to post-event (owned rows on their source training's `ended?`, hand-entered on this event's `ended?`), so a pre-event run never deactivates. `#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(included_keys:, delete_keys:)` returns the concrete `Change`s for the confirmation screen; `#apply(included_keys:, delete_keys:)` performs the kept rows (keys are `aff:` or `create::`; a `delete_keys` entry deletes that affiliation instead of same-daying it) 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/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb index 85296e7de..8c5f8d504 100644 --- a/app/controllers/events/reconcile_affiliations_controller.rb +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -17,9 +17,24 @@ def index @person_groups = reconcile.actionable_person_groups @skipped_sections = reconcile.skipped_reason_sections @has_rows = reconcile.any_rows? + # Restore the admin's selections when they come back from the confirm screen. + @pre_included = params[:included] + @pre_delete = Array(params[:delete]).to_set @event = @event.decorate end + # Step 2: show exactly what "Perform changes" will do (no writes yet). + def confirm + authorize! @event, to: :reconcile_affiliations? + + @included = Array(params[:included]) + @delete = Array(params[:delete]) + @changes = AffiliationServices::ReconcileEvent.new(@event).planned_changes(included_keys: @included, delete_keys: @delete) + @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? diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 9bab55a53..2308b4718 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -58,6 +58,23 @@ def any_rows? all_rows.any? end + Change = Struct.new(:person, :organization, :affiliation, :action, keyword_init: true) + + # The concrete changes the given selection will make, for the confirmation + # screen: a `:delete` key wins over its include (delete instead of same-day). + def planned_changes(included_keys:, delete_keys: []) + included = Array(included_keys).to_set + deletes = Array(delete_keys).to_set + + all_rows.select(&:actionable?).filter_map do |row| + if deletes.include?(row.key) && row.affiliation + Change.new(person: row.person, organization: row.organization, affiliation: row.affiliation, action: :delete) + elsif included.include?(row.key) + Change.new(person: row.person, organization: row.organization, affiliation: row.affiliation, action: row.action) + end + end + end + # Apply the actionable rows whose keys are in `included_keys`. For :deactivate # rows whose key is also in `delete_keys`, delete the affiliation instead of # same-daying it. Stamps the event and returns the number of rows changed. diff --git a/app/views/events/reconcile_affiliations/confirm.html.erb b/app/views/events/reconcile_affiliations/confirm.html.erb new file mode 100644 index 000000000..de5d63cb3 --- /dev/null +++ b/app/views/events/reconcile_affiliations/confirm.html.erb @@ -0,0 +1,52 @@ +<% content_for(:page_title, "Confirm affiliation changes — #{@event.title}") %> +<% content_for(:page_bg_class, "admin-or-owner bg-blue-100") %> +
+
+ <%= link_to "← Go back to edit", reconcile_affiliations_event_path(@event, included: @included, delete: @delete), class: "text-sm text-gray-500 hover:text-gray-700" %> +
+ +

Confirm affiliation changes

+

+ Performing will make the <%= @changes.size %> <%= "change".pluralize(@changes.size) %> below. Nothing else is affected. +

+ + <% sections = { + create: [ "Create", "bg-blue-50 text-blue-800 border-blue-200", "A new facilitator affiliation is created for this organization." ], + reactivate: [ "Reactivate", "bg-green-50 text-green-800 border-green-200", "The end date is cleared so the facilitator affiliation is active again." ], + deactivate: [ "Deactivate", "bg-red-50 text-red-800 border-red-200", "The facilitator affiliation is same-dayed (ended today) so it no longer counts as active. Reversible." ], + delete: [ "Delete", "bg-red-100 text-red-900 border-red-300", "The facilitator affiliation is permanently deleted. Job and other-org affiliations are untouched." ] + } %> + +
+ <% sections.each do |action, (label, header_class, description)| %> + <% action_changes = @changes.select { |change| change.action == action } %> + <% next if action_changes.empty? %> +
+
+ <%= label %> (<%= action_changes.size %>) +

<%= description %>

+
+
    + <% action_changes.each do |change| %> +
  • + <%= change.person.name %> + — <%= change.organization.name %> + <% if change.affiliation %> + · <%= change.affiliation.decorate.date_range %> + <% end %> +
  • + <% end %> +
+
+ <% end %> +
+ +
+ <%= link_to "← Go back to edit", reconcile_affiliations_event_path(@event, included: @included, delete: @delete), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= form_with url: perform_reconcile_affiliations_event_path(@event), method: :post do %> + <% @included.each do |key| %><%= hidden_field_tag "included[]", key %><% end %> + <% @delete.each do |key| %><%= hidden_field_tag "delete[]", key %><% end %> + <%= submit_tag "Perform changes", class: "btn btn-primary" %> + <% end %> +
+
diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index a0ced04a2..976a0b900 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -43,6 +43,13 @@ delete: [ "Will be deleted", "bg-red-100 text-red-800" ] } %> <% if @person_groups.any? %> + <% actionable_count = @person_groups.sum { |g| g[:rows].size } %> +
+

To reconcile (<%= actionable_count %>)

+ + Checked boxes change facilitator affiliations + +
<%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %>
<% @person_groups.each do |group| %> @@ -52,6 +59,12 @@ deactivate: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300", delete: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300" } %> + <% action_notes = { + create: "Uncheck to skip creating this facilitator affiliation.", + reactivate: "Uncheck to leave this facilitator affiliation inactive.", + deactivate: "Change attendance to Attended, or uncheck, to keep this facilitator affiliation active.", + delete: "Uncheck to keep this facilitator affiliation." + } %> <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %>
@@ -71,6 +84,8 @@
<% group[:rows].each do |row| %> <% heading, _badge_class = badges[row.action] %> + <% included_checked = @pre_included.nil? || @pre_included.include?(row.key) %> + <% delete_checked = @pre_delete.include?(row.key) %>
<% if row.affiliation %> <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "flex-1 min-w-0 text-sm hover:underline" do %> @@ -81,19 +96,22 @@ <%= row.organization.name %> <% end %> <%# Checkbox lives inside the button; has-[:checked] colors the whole button when selected. %> -
data-controller="exclusive-checkboxes"<% end %>> - <% if row.action == :deactivate %> -
<% end %> @@ -104,7 +122,7 @@
<%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> - <%= submit_tag "Reconcile affiliations", class: "btn btn-primary" %> + <%= submit_tag "Preview changes", class: "btn btn-primary" %>
<% end %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index be2c32ace..5426f8e5f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -191,7 +191,8 @@ 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#create" + 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/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 71ee917a3..f13b7a438 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -85,12 +85,33 @@ def registrant_with_affiliation(status:) end end - describe "POST create" do - it "deactivates the included non-completer and stamps the event" do + 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: { included: [ "aff:#{affiliation.id}" ] } + 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 + registrant_with_affiliation(status: "no_show") + + post reconcile_affiliations_event_path(event), params: { included: [] } + + expect(response).to redirect_to(reconcile_affiliations_event_path(event)) + end + end + + describe "POST perform" do + it "deactivates the included non-completer and stamps the event" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post perform_reconcile_affiliations_event_path(event), params: { included: [ "aff:#{affiliation.id}" ] } + 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 @@ -99,7 +120,7 @@ def registrant_with_affiliation(status:) it "spares an opted-out row" do _person, affiliation = registrant_with_affiliation(status: "no_show") - post reconcile_affiliations_event_path(event), params: { included: [] } + post perform_reconcile_affiliations_event_path(event), params: { included: [] } expect(affiliation.reload).to be_active end @@ -111,7 +132,7 @@ def registrant_with_affiliation(status:) create(:event_registration_organization, event_registration: reg, organization: organization) expect { - post reconcile_affiliations_event_path(upcoming), params: { included: [ "create:#{person.id}:#{organization.id}" ] } + post perform_reconcile_affiliations_event_path(upcoming), params: { included: [ "create:#{person.id}:#{organization.id}" ] } }.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1) end @@ -119,7 +140,7 @@ def registrant_with_affiliation(status:) _person, affiliation = registrant_with_affiliation(status: "no_show") key = "aff:#{affiliation.id}" - post reconcile_affiliations_event_path(event), params: { included: [ key ], delete: [ key ] } + post perform_reconcile_affiliations_event_path(event), params: { included: [ key ], delete: [ key ] } expect(Affiliation.exists?(affiliation.id)).to be(false) end @@ -130,7 +151,7 @@ def registrant_with_affiliation(status:) 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 reconcile_affiliations_event_path(event), params: { included: [ "aff:#{hand_entered.id}" ] } + post perform_reconcile_affiliations_event_path(event), params: { included: [ "aff:#{hand_entered.id}" ] } expect(hand_entered.reload).not_to be_active end @@ -145,7 +166,7 @@ def registrant_with_affiliation(status:) job = create(:affiliation, person: person, organization: organization, title: "Counselor", event_registration: reg) - post reconcile_affiliations_event_path(non_training), params: { included: [ "aff:#{facilitator.id}" ] } + post perform_reconcile_affiliations_event_path(non_training), params: { included: [ "aff:#{facilitator.id}" ] } expect(Affiliation.exists?(facilitator.id)).to be(false) expect(Affiliation.exists?(job.id)).to be(true) diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 2f6c99865..ef69342c1 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -119,6 +119,7 @@ "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-white", "app/views/events/recipients.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/registrants.html.erb" => "admin-or-owner bg-blue-100", From 86a085ab722a663ec7f09b58910faf79736d7b3d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:25:20 -0400 Subject: [PATCH 14/22] Keep attendance toggle on the reconcile page with a flash Thread return_to through the attendance status badge and add a reconcile case to EventRegistrations#update so toggling attendance from the reconcile page reloads it (with fresh attendance) and a success flash, instead of jumping to the roster. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/event_registrations_controller.rb | 1 + .../_attendance_status_badge.html.erb | 2 +- .../events/reconcile_affiliations/index.html.erb | 4 ++-- spec/requests/events/reconcile_affiliations_spec.rb | 13 +++++++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index d6c70cfd9..5463bf2aa 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -106,6 +106,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), 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/views/event_registrations/_attendance_status_badge.html.erb b/app/views/event_registrations/_attendance_status_badge.html.erb index 768ce79e3..b153629d0 100644 --- a/app/views/event_registrations/_attendance_status_badge.html.erb +++ b/app/views/event_registrations/_attendance_status_badge.html.erb @@ -1,6 +1,6 @@ <% deco = registration.decorate %>
- <%= form_with model: registration, url: event_registration_path(registration), method: :patch, data: { turbo_frame: "_top" } do |f| %> + <%= form_with model: registration, url: event_registration_path(registration, return_to: local_assigns[:return_to]), method: :patch, data: { turbo_frame: "_top" } do |f| %>
<%= f.select :status, diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 976a0b900..0f5826f2d 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -78,7 +78,7 @@ title: note, class: "text-xs text-gray-500 truncate hover:underline hover:text-blue-700 min-w-0" %> <% end %>
- <%= render "event_registrations/attendance_status_badge", registration: group[:registration] %> + <%= render "event_registrations/attendance_status_badge", registration: group[:registration], return_to: "reconcile_affiliations" %>
@@ -154,7 +154,7 @@ <%= row.organization.name %> <% end %> - <%= render "event_registrations/attendance_status_badge", registration: row.registration %> + <%= render "event_registrations/attendance_status_badge", registration: row.registration, return_to: "reconcile_affiliations" %>
<% end %>
diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index f13b7a438..c8c94613a 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -85,6 +85,19 @@ def registrant_with_affiliation(status:) end end + describe "toggling attendance from the reconcile page" do + 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)) + 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") From 5805acf02e9e90ae9376e204134a1bcb48e7ac3f Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:29:27 -0400 Subject: [PATCH 15/22] Register exclusive-checkboxes controller; clearer deactivate instruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stimulus manifest is explicit, so the new exclusive-checkboxes controller was never loaded — register it and use an explicit change event so Delete instead and Will be deactivated actually clear each other. Reword the deactivate row note to spell out the two options (mark Attended = permanent, uncheck = one-time). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/frontend/javascript/controllers/index.js | 3 +++ app/views/events/reconcile_affiliations/index.html.erb | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 64be221b9..9cc001dc6 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -84,6 +84,9 @@ application.register("dropdown", DropdownController) import ExpandAllController from "./expand_all_controller" application.register("expand-all", ExpandAllController) +import ExclusiveCheckboxesController from "./exclusive_checkboxes_controller" +application.register("exclusive-checkboxes", ExclusiveCheckboxesController) + import FilePreviewController from "./file_preview_controller" application.register("file-preview", FilePreviewController) diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 0f5826f2d..727ed2485 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -62,7 +62,7 @@ <% action_notes = { create: "Uncheck to skip creating this facilitator affiliation.", reactivate: "Uncheck to leave this facilitator affiliation inactive.", - deactivate: "Change attendance to Attended, or uncheck, to keep this facilitator affiliation active.", + deactivate: "To keep this facilitator affiliation active: mark them Attended (permanent), or just uncheck this box (one-time).", delete: "Uncheck to keep this facilitator affiliation." } %> <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %> @@ -100,13 +100,13 @@
data-controller="exclusive-checkboxes"<% end %>> <% if row.action == :deactivate %> <% end %> From 439ced89149ed6d34990863e1f3077594b48b021 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:31:45 -0400 Subject: [PATCH 16/22] Render deactivate row note as two lines --- .../events/reconcile_affiliations/index.html.erb | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 727ed2485..f8de48972 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -60,10 +60,10 @@ delete: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300" } %> <% action_notes = { - create: "Uncheck to skip creating this facilitator affiliation.", - reactivate: "Uncheck to leave this facilitator affiliation inactive.", - deactivate: "To keep this facilitator affiliation active: mark them Attended (permanent), or just uncheck this box (one-time).", - delete: "Uncheck to keep this facilitator affiliation." + create: [ "Uncheck to skip creating this facilitator affiliation." ], + reactivate: [ "Uncheck to leave this facilitator affiliation inactive." ], + deactivate: [ "Mark Attended to keep active (permanent),", "or uncheck this box to keep active (one-time)." ], + delete: [ "Uncheck to keep this facilitator affiliation." ] } %> <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %>
@@ -111,7 +111,11 @@ <%= render "tooltip", kind: row.action %>
-

<%= action_notes[row.action] %>

+
+ <% action_notes[row.action].each do |line| %> +

<%= line %>

+ <% end %> +
<% end %> From cc6ccc051f7ee682a423683e48728236d646985a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:33:01 -0400 Subject: [PATCH 17/22] Reword deactivate note to 'To keep Affiliation active: Mark as Attended or uncheck this box' --- app/views/events/reconcile_affiliations/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index f8de48972..a4aadc961 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -62,7 +62,7 @@ <% action_notes = { create: [ "Uncheck to skip creating this facilitator affiliation." ], reactivate: [ "Uncheck to leave this facilitator affiliation inactive." ], - deactivate: [ "Mark Attended to keep active (permanent),", "or uncheck this box to keep active (one-time)." ], + deactivate: [ "To keep Affiliation active:", "Mark as Attended or uncheck this box" ], delete: [ "Uncheck to keep this facilitator affiliation." ] } %> <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %> From cd810c04e4ecb94b66c60d9a0732dadd1abb9e31 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:34:54 -0400 Subject: [PATCH 18/22] Fix Preview changes: turbo:false so the confirm page renders on POST; note says 'both boxes' Turbo ignores a 200 HTML render on a form POST (only 4xx/5xx render), so the confirmation screen never showed. Submit the preview form with turbo disabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/reconcile_affiliations/index.html.erb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index a4aadc961..36b230363 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -50,7 +50,8 @@ Checked boxes change facilitator affiliations
- <%= form_with url: reconcile_affiliations_event_path(@event), method: :post do %> + <%# turbo: false so the POST renders the confirmation page (Turbo ignores a 200 HTML render on POST). %> + <%= form_with url: reconcile_affiliations_event_path(@event), method: :post, data: { turbo: false } do %>
<% @person_groups.each do |group| %> <% checked_class = { @@ -62,7 +63,7 @@ <% action_notes = { create: [ "Uncheck to skip creating this facilitator affiliation." ], reactivate: [ "Uncheck to leave this facilitator affiliation inactive." ], - deactivate: [ "To keep Affiliation active:", "Mark as Attended or uncheck this box" ], + deactivate: [ "To keep Affiliation active:", "Mark as Attended or uncheck both boxes" ], delete: [ "Uncheck to keep this facilitator affiliation." ] } %> <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %> From b1a09f150b9bf725989b2ba0af23dd80de95009a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 09:53:05 -0400 Subject: [PATCH 19/22] Radio outcomes with Keep-active; fix nested-form bug; scroll to item on attendance toggle Replace the deactivate/delete checkboxes with a radio group per row (Deactivate/Delete/Keep active, and action/keep for the others), styled as the same buttons via has-[:checked]. Radios are natively mutually exclusive, so remove the exclusive-checkboxes Stimulus controller and the per-row instruction note. Fix the real reason 'Preview changes' did nothing: the attendance chip's form was nested inside the reconcile form (invalid HTML), so the submit/inputs fell outside it. Render the reconcile form standalone and join the radios/submit via the HTML form= attribute. Switch the params to an outcome map { row.key => choice }. Toggling attendance now scrolls back to that item's anchor, not the top. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +- .../event_registrations_controller.rb | 2 +- .../reconcile_affiliations_controller.rb | 22 +-- .../exclusive_checkboxes_controller.js | 17 -- app/frontend/javascript/controllers/index.js | 3 - .../affiliation_services/reconcile_event.rb | 57 +++---- .../_attendance_status_badge.html.erb | 2 +- .../reconcile_affiliations/_tooltip.html.erb | 2 + .../reconcile_affiliations/confirm.html.erb | 7 +- .../reconcile_affiliations/index.html.erb | 147 ++++++++---------- .../events/reconcile_affiliations_spec.rb | 31 ++-- 11 files changed, 127 insertions(+), 167 deletions(-) delete mode 100644 app/frontend/javascript/controllers/exclusive_checkboxes_controller.js diff --git a/AGENTS.md b/AGENTS.md index 2d2c73008..ac1afcf47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (78) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -245,7 +245,7 @@ action, or `authorize! :workshop, to: :summary?`). - `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::ReconcileFacilitatorAffiliation` — Per `(person, organization)`, brings the person's **owned** facilitator affiliation (created by the registration flow, i.e. `event_registration_id` present) in line with attendance: keeps it active when they have any `attended` facilitator-training registration to that org; otherwise **same-days** it (`end_date := start_date`, which the model turns into `inactive: true`) once its source training has ended. Reactivates a previously same-dayed row when the person is later marked attended. Hand-created (unowned) rows are never touched. `#plan` returns the action (`:deactivate` / `:reactivate` / `:noop`) without writing. -- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** for an org a registrant linked — **hand-entered rows included, not just app-created ones** — classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. Deactivation is gated to post-event (owned rows on their source training's `ended?`, hand-entered on this event's `ended?`), so a pre-event run never deactivates. `#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(included_keys:, delete_keys:)` returns the concrete `Change`s for the confirmation screen; `#apply(included_keys:, delete_keys:)` performs the kept rows (keys are `aff:` or `create::`; a `delete_keys` entry deletes that affiliation instead of same-daying it) 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). +- `AffiliationServices::ReconcileEvent` — Event-level orchestration for the "Reconcile affiliations" bulk action. Builds one row **per facilitator affiliation** for an org a registrant linked — **hand-entered rows included, not just app-created ones** — classifying each `:create` / `:deactivate` / `:reactivate` (facilitator trainings) or `:delete` (facilitator affiliation auto-created off a non-training event), else `:noop` with a reason. Deactivation is gated to post-event (owned rows on their source training's `ended?`, hand-entered on this event's `ended?`), so a pre-event run never deactivates. `#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/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 5463bf2aa..c153f8b50 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -106,7 +106,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), 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 index 8c5f8d504..d434e1188 100644 --- a/app/controllers/events/reconcile_affiliations_controller.rb +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -17,9 +17,8 @@ def index @person_groups = reconcile.actionable_person_groups @skipped_sections = reconcile.skipped_reason_sections @has_rows = reconcile.any_rows? - # Restore the admin's selections when they come back from the confirm screen. - @pre_included = params[:included] - @pre_delete = Array(params[:delete]).to_set + # Restore the admin's per-row radio choices when they come back from confirm. + @pre_outcome = params[:outcome] @event = @event.decorate end @@ -27,9 +26,8 @@ def index def confirm authorize! @event, to: :reconcile_affiliations? - @included = Array(params[:included]) - @delete = Array(params[:delete]) - @changes = AffiliationServices::ReconcileEvent.new(@event).planned_changes(included_keys: @included, delete_keys: @delete) + @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? @@ -38,10 +36,7 @@ def confirm def create authorize! @event, to: :reconcile_affiliations? - changed = AffiliationServices::ReconcileEvent.new(@event).apply( - included_keys: params[:included] || [], - delete_keys: params[:delete] || [] - ) + changed = AffiliationServices::ReconcileEvent.new(@event).apply(outcome: outcome_params) redirect_to registrants_event_path(@event), notice: reconcile_notice(changed) end @@ -51,6 +46,13 @@ def set_event @event = Event.find(params[:id]) end + # `outcome` is a { row.key => choice } map with dynamic keys; the service only + # acts on known choices, so the actual values are validated downstream. + def outcome_params + outcome = params[:outcome] + outcome.respond_to?(:permit!) ? outcome.permit!.to_h : {} + end + def reconcile_notice(changed) return "No affiliations needed reconciling." if changed.zero? diff --git a/app/frontend/javascript/controllers/exclusive_checkboxes_controller.js b/app/frontend/javascript/controllers/exclusive_checkboxes_controller.js deleted file mode 100644 index cd165d589..000000000 --- a/app/frontend/javascript/controllers/exclusive_checkboxes_controller.js +++ /dev/null @@ -1,17 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="exclusive-checkboxes" -// Makes a small group of checkboxes mutually exclusive — like radios, but any can -// be left unchecked. Checking one clears the others in the group (e.g. "Delete -// instead" and "Will be deactivated" are two choices for the same row). -export default class extends Controller { - static targets = ["box"] - - select(event) { - if (!event.target.checked) return - - this.boxTargets.forEach((box) => { - if (box !== event.target) box.checked = false - }) - } -} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 9cc001dc6..64be221b9 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -84,9 +84,6 @@ application.register("dropdown", DropdownController) import ExpandAllController from "./expand_all_controller" application.register("expand-all", ExpandAllController) -import ExclusiveCheckboxesController from "./exclusive_checkboxes_controller" -application.register("exclusive-checkboxes", ExclusiveCheckboxesController) - import FilePreviewController from "./file_preview_controller" application.register("file-preview", FilePreviewController) diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 2308b4718..dffa15930 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -60,39 +60,30 @@ def any_rows? Change = Struct.new(:person, :organization, :affiliation, :action, keyword_init: true) - # The concrete changes the given selection will make, for the confirmation - # screen: a `:delete` key wins over its include (delete instead of same-day). - def planned_changes(included_keys:, delete_keys: []) - included = Array(included_keys).to_set - deletes = Array(delete_keys).to_set + # Each row's outcome is one radio choice keyed by row.key: the action itself + # (deactivate/delete/reactivate/create) or "keep" (do nothing). + ACTION_FOR_CHOICE = { "deactivate" => :deactivate, "delete" => :delete, "reactivate" => :reactivate, "create" => :create }.freeze + + # The concrete changes the given `outcome` map will make, for the confirmation + # screen. `outcome` is `{ row.key => choice }`. + def planned_changes(outcome:) + outcome = outcome.to_h all_rows.select(&:actionable?).filter_map do |row| - if deletes.include?(row.key) && row.affiliation - Change.new(person: row.person, organization: row.organization, affiliation: row.affiliation, action: :delete) - elsif included.include?(row.key) - Change.new(person: row.person, organization: row.organization, affiliation: row.affiliation, action: row.action) - end + 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 the actionable rows whose keys are in `included_keys`. For :deactivate - # rows whose key is also in `delete_keys`, delete the affiliation instead of - # same-daying it. Stamps the event and returns the number of rows changed. - def apply(included_keys:, delete_keys: []) - included = Array(included_keys).to_set - deletes = Array(delete_keys).to_set + # 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| - next false unless row.actionable? - - if deletes.include?(row.key) && row.affiliation - row.affiliation.destroy! - true - elsif included.include?(row.key) - perform(row) - else - false - end + row.actionable? && perform_outcome(row, outcome[row.key]) end @event.update!(affiliations_reconciled_at: Time.current) @@ -165,21 +156,23 @@ def create_row(person, registration, organization, attended) end end - def perform(row) - case row.action - when :create + def perform_outcome(row, choice) + case choice + when "create" AffiliationServices::CreateFromRegistration.call( person: row.person, organization: row.organization, facilitator_training: true, training_date: @event.start_date, event_registration: row.registration ) - when :delete + when "delete" row.affiliation.destroy! - when :deactivate + when "deactivate" # Same-day it: end_date = the affiliation's own start_date (start_date itself # is never changed), which the model turns into inactive. row.affiliation.update!(end_date: row.affiliation.start_date || Date.current) - when :reactivate + when "reactivate" row.affiliation.update!(end_date: nil) + else + return false # "keep" or unknown end true end diff --git a/app/views/event_registrations/_attendance_status_badge.html.erb b/app/views/event_registrations/_attendance_status_badge.html.erb index b153629d0..4fd6882c2 100644 --- a/app/views/event_registrations/_attendance_status_badge.html.erb +++ b/app/views/event_registrations/_attendance_status_badge.html.erb @@ -1,5 +1,5 @@ <% deco = registration.decorate %> -
+
<%= form_with model: registration, url: event_registration_path(registration, return_to: local_assigns[:return_to]), method: :patch, data: { turbo_frame: "_top" } do |f| %>
diff --git a/app/views/events/reconcile_affiliations/_tooltip.html.erb b/app/views/events/reconcile_affiliations/_tooltip.html.erb index 582555db3..a41b75283 100644 --- a/app/views/events/reconcile_affiliations/_tooltip.html.erb +++ b/app/views/events/reconcile_affiliations/_tooltip.html.erb @@ -13,5 +13,7 @@ Creates the facilitator affiliation for this organization. <% when :reactivate %> Clears the end date so this facilitator affiliation counts as active again. + <% when :keep %> + Leaves this facilitator affiliation exactly as it is — no change. <% end %> diff --git a/app/views/events/reconcile_affiliations/confirm.html.erb b/app/views/events/reconcile_affiliations/confirm.html.erb index de5d63cb3..885b8287d 100644 --- a/app/views/events/reconcile_affiliations/confirm.html.erb +++ b/app/views/events/reconcile_affiliations/confirm.html.erb @@ -2,7 +2,7 @@ <% content_for(:page_bg_class, "admin-or-owner bg-blue-100") %>
- <%= link_to "← Go back to edit", reconcile_affiliations_event_path(@event, included: @included, delete: @delete), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= link_to "← Go back to edit", reconcile_affiliations_event_path(@event, outcome: @outcome), class: "text-sm text-gray-500 hover:text-gray-700" %>

Confirm affiliation changes

@@ -42,10 +42,9 @@
- <%= link_to "← Go back to edit", reconcile_affiliations_event_path(@event, included: @included, delete: @delete), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= link_to "← Go back to edit", reconcile_affiliations_event_path(@event, outcome: @outcome), class: "text-sm text-gray-500 hover:text-gray-700" %> <%= form_with url: perform_reconcile_affiliations_event_path(@event), method: :post do %> - <% @included.each do |key| %><%= hidden_field_tag "included[]", key %><% end %> - <% @delete.each do |key| %><%= hidden_field_tag "delete[]", key %><% end %> + <% @outcome.each do |key, value| %><%= hidden_field_tag "outcome[#{key}]", value %><% end %> <%= submit_tag "Perform changes", class: "btn btn-primary" %> <% end %>
diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 36b230363..490ddda64 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -37,99 +37,84 @@
<% end %> - <% badges = { create: [ "Will be created", "bg-blue-50 text-blue-700" ], - reactivate: [ "Will be reactivated", "bg-green-50 text-green-700" ], - deactivate: [ "Will be deactivated", "bg-red-50 text-red-700" ], - delete: [ "Will be deleted", "bg-red-100 text-red-800" ] } %> - <% if @person_groups.any? %> <% actionable_count = @person_groups.sum { |g| g[:rows].size } %> + <% color_class = { + red: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300", + green: "has-[:checked]:bg-green-50 has-[:checked]:text-green-700 has-[:checked]:border-green-300", + blue: "has-[:checked]:bg-blue-50 has-[:checked]:text-blue-700 has-[:checked]:border-blue-300", + gray: "has-[:checked]:bg-gray-100 has-[:checked]:text-gray-800 has-[:checked]:border-gray-400" + } %> + <% outcome_options = { + create: [ [ "create", "Will be created", :blue ], [ "keep", "Skip", :gray ] ], + reactivate: [ [ "reactivate", "Will be reactivated", :green ], [ "keep", "Leave inactive", :gray ] ], + deactivate: [ [ "deactivate", "Will be deactivated", :red ], [ "delete", "Delete instead", :red ], [ "keep", "Keep active", :green ] ], + delete: [ [ "delete", "Will be deleted", :red ], [ "keep", "Keep", :gray ] ] + } %> + <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %> +

To reconcile (<%= actionable_count %>)

- Checked boxes change facilitator affiliations + These buttons change facilitator affiliations
- <%# turbo: false so the POST renders the confirmation page (Turbo ignores a 200 HTML render on POST). %> - <%= form_with url: reconcile_affiliations_event_path(@event), method: :post, data: { turbo: false } do %> -
- <% @person_groups.each do |group| %> - <% checked_class = { - create: "has-[:checked]:bg-blue-50 has-[:checked]:text-blue-700 has-[:checked]:border-blue-300", - reactivate: "has-[:checked]:bg-green-50 has-[:checked]:text-green-700 has-[:checked]:border-green-300", - deactivate: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300", - delete: "has-[:checked]:bg-red-50 has-[:checked]:text-red-700 has-[:checked]:border-red-300" - } %> - <% action_notes = { - create: [ "Uncheck to skip creating this facilitator affiliation." ], - reactivate: [ "Uncheck to leave this facilitator affiliation inactive." ], - deactivate: [ "To keep Affiliation active:", "Mark as Attended or uncheck both boxes" ], - delete: [ "Uncheck to keep this facilitator affiliation." ] - } %> - <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %> -
-
-
- <%= link_to group[:person].name, edit_event_registration_path(group[:registration]), target: "_blank", rel: "noopener", class: "font-semibold text-gray-900 hover:underline hover:text-blue-700 shrink-0" %> - <% others = group[:other_facilitators] %> - <% if others.any? %> - <% note = "Also a facilitator at #{others.map { |a| a.organization.name }.to_sentence}." %> - <% anchor = others.one? ? dom_id(others.first) : "affiliations" %> - <%= link_to note, edit_person_path(group[:person], anchor: anchor), target: "_blank", rel: "noopener", - title: note, class: "text-xs text-gray-500 truncate hover:underline hover:text-blue-700 min-w-0" %> - <% end %> -
- <%= render "event_registrations/attendance_status_badge", registration: group[:registration], return_to: "reconcile_affiliations" %> + + <%# Standalone form (turbo:false so the POST renders the confirm page). The cards + live OUTSIDE it — the attendance chip renders its own form and nesting forms is + invalid — so the radios and submit join this form via the HTML form= attribute. %> + <%= form_with url: reconcile_affiliations_event_path(@event), method: :post, id: "reconcile_form", data: { turbo: false } do %><% end %> + +
+ <% @person_groups.each do |group| %> +
+
+
+ <%= link_to group[:person].name, edit_event_registration_path(group[:registration]), target: "_blank", rel: "noopener", class: "font-semibold text-gray-900 hover:underline hover:text-blue-700 shrink-0" %> + <% others = group[:other_facilitators] %> + <% if others.any? %> + <% note = "Also a facilitator at #{others.map { |a| a.organization.name }.to_sentence}." %> + <% anchor = others.one? ? dom_id(others.first) : "affiliations" %> + <%= link_to note, edit_person_path(group[:person], anchor: anchor), target: "_blank", rel: "noopener", + title: note, class: "text-xs text-gray-500 truncate hover:underline hover:text-blue-700 min-w-0" %> + <% end %>
+ <%= render "event_registrations/attendance_status_badge", registration: group[:registration], return_to: "reconcile_affiliations" %> +
-
- <% group[:rows].each do |row| %> - <% heading, _badge_class = badges[row.action] %> - <% included_checked = @pre_included.nil? || @pre_included.include?(row.key) %> - <% delete_checked = @pre_delete.include?(row.key) %> -
- <% if row.affiliation %> - <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "flex-1 min-w-0 text-sm hover:underline" do %> - <%= row.organization.name %> - · <%= row.affiliation.decorate.date_range %> - <% end %> - <% else %> - <%= row.organization.name %> +
+ <% group[:rows].each do |row| %> + <% chosen = (@pre_outcome && @pre_outcome[row.key]) || row.action.to_s %> +
+ <% if row.affiliation %> + <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "flex-1 min-w-0 text-sm hover:underline" do %> + <%= row.organization.name %> + · <%= row.affiliation.decorate.date_range %> + <% end %> + <% else %> + <%= row.organization.name %> + <% end %> + <%# One radio per outcome (native mutual exclusion — no JS). has-[:checked] colors the chosen button. %> +
+ <% outcome_options[row.action].each do |value, label, color| %> + <% end %> - <%# Checkbox lives inside the button; has-[:checked] colors the whole button when selected. %> -
-
data-controller="exclusive-checkboxes"<% end %>> - <% if row.action == :deactivate %> - - <% end %> - -
-
- <% action_notes[row.action].each do |line| %> -

<%= line %>

- <% end %> -
-
- <% end %> -
-
- <% end %> -
+
+ <% end %> +
+
+ <% end %> +
-
- <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> - <%= submit_tag "Preview changes", class: "btn btn-primary" %> -
- <% end %> +
+ <%= link_to "Cancel", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= submit_tag "Preview changes", form: "reconcile_form", class: "btn btn-primary" %> +
<% end %> <% if @skipped_sections.any? %> diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index c8c94613a..0a0b2aa3c 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -93,7 +93,7 @@ def registrant_with_affiliation(status:) patch event_registration_path(registration, return_to: "reconcile_affiliations"), params: { event_registration: { status: "attended" } } - expect(response).to redirect_to(reconcile_affiliations_event_path(event)) + 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 @@ -102,7 +102,7 @@ def registrant_with_affiliation(status:) it "shows the selected change without writing" do _person, affiliation = registrant_with_affiliation(status: "no_show") - post reconcile_affiliations_event_path(event), params: { included: [ "aff:#{affiliation.id}" ] } + 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") @@ -111,60 +111,59 @@ def registrant_with_affiliation(status:) end it "redirects back when nothing is selected" do - registrant_with_affiliation(status: "no_show") + _person, affiliation = registrant_with_affiliation(status: "no_show") - post reconcile_affiliations_event_path(event), params: { included: [] } + 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 included non-completer and stamps the event" 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: { included: [ "aff:#{affiliation.id}" ] } + 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 "spares an opted-out row" do + it "spares a row set to keep" do _person, affiliation = registrant_with_affiliation(status: "no_show") - post perform_reconcile_affiliations_event_path(event), params: { included: [] } + 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 included" do + 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: { included: [ "create:#{person.id}:#{organization.id}" ] } + 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 instead of same-daying when the delete option is checked" do + it "deletes when the delete outcome is chosen" do _person, affiliation = registrant_with_affiliation(status: "no_show") - key = "aff:#{affiliation.id}" - post perform_reconcile_affiliations_event_path(event), params: { included: [ key ], delete: [ key ] } + 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 included" do + 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: { included: [ "aff:#{hand_entered.id}" ] } + post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{hand_entered.id}" => "deactivate" } } expect(hand_entered.reload).not_to be_active end @@ -179,7 +178,7 @@ def registrant_with_affiliation(status:) job = create(:affiliation, person: person, organization: organization, title: "Counselor", event_registration: reg) - post perform_reconcile_affiliations_event_path(non_training), params: { included: [ "aff:#{facilitator.id}" ] } + 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) From 6ae75e16009716cdd6ddc1435218994868a5fe85 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 10:09:20 -0400 Subject: [PATCH 20/22] Fix Brakeman: read outcome params as a plain hash instead of permit! Co-Authored-By: Claude Opus 4.8 (1M context) --- .../events/reconcile_affiliations_controller.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb index d434e1188..997ff3c81 100644 --- a/app/controllers/events/reconcile_affiliations_controller.rb +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -46,11 +46,13 @@ def set_event @event = Event.find(params[:id]) end - # `outcome` is a { row.key => choice } map with dynamic keys; the service only - # acts on known choices, so the actual values are validated downstream. + # `outcome` is a { row.key => choice } map with dynamic keys, read as a plain + # string hash (never mass-assigned); the service only acts on known choices. def outcome_params - outcome = params[:outcome] - outcome.respond_to?(:permit!) ? outcome.permit!.to_h : {} + 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) From 031ae847aec531c749656fa71f451acc1a65e59c Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 13:36:54 -0400 Subject: [PATCH 21/22] Reorder deactivate outcomes: Keep active, Delete, Deactivate; clearer labels Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/reconcile_affiliations/index.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 490ddda64..34af58341 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -48,7 +48,7 @@ <% outcome_options = { create: [ [ "create", "Will be created", :blue ], [ "keep", "Skip", :gray ] ], reactivate: [ [ "reactivate", "Will be reactivated", :green ], [ "keep", "Leave inactive", :gray ] ], - deactivate: [ [ "deactivate", "Will be deactivated", :red ], [ "delete", "Delete instead", :red ], [ "keep", "Keep active", :green ] ], + deactivate: [ [ "keep", "Keep active", :green ], [ "delete", "Delete affiliation", :red ], [ "deactivate", "Deactivate affiliation", :red ] ], delete: [ [ "delete", "Will be deleted", :red ], [ "keep", "Keep", :gray ] ] } %> <% button_base = "group relative inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-500 shadow-sm cursor-pointer hover:bg-gray-50" %> From eb15d0a996e61ed9c462934b8d45a22602f90761 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 14 Aug 2026 19:04:06 -0400 Subject: [PATCH 22/22] Update reconcile specs for renamed 'Deactivate affiliation' label Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/requests/events/reconcile_affiliations_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 0a0b2aa3c..2b4d53286 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -27,7 +27,7 @@ def registrant_with_affiliation(status:) expect(response).to have_http_status(:ok) expect(response.body).to include(person.name) - expect(response.body).to include("Will be deactivated") + expect(response.body).to include("Deactivate affiliation") end it "previews a missing affiliation as a creation before the event" do @@ -73,7 +73,7 @@ def registrant_with_affiliation(status:) get reconcile_affiliations_event_path(event) - expect(response.body).to include("Will be deactivated") + expect(response.body).to include("Deactivate affiliation") end it "denies a non-admin" do