From 968f2aad0e42e18f3fa6659a3768c8eba8b5dccc Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 10:51:15 -0400 Subject: [PATCH 1/5] Start: let reconcile handle cancelled training registrants Co-Authored-By: Claude Opus 5.5 (1M context) From a5ab7b9b24bc868255962e54366d950ea52faa0d Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 10:55:32 -0400 Subject: [PATCH 2/5] Settle cancelled training registrants' affiliations before the event A registrant who cancels will never attend, so waiting for the training to end before removing the affiliation it minted left a stale facilitator row in place and, worse, a pre-event run offered to re-create one. Rows minted off an upcoming training also read as 'Upcoming' rather than active, which filed them as already ended instead of pending. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reconcile_affiliations_controller.rb | 3 +- .../affiliation_services/reconcile_person.rb | 25 ++++- .../_person_group.html.erb | 64 +++++++++++++ .../reconcile_affiliations/index.html.erb | 93 +++++-------------- ...ncile-cancelled-registrants-right-away.yml | 13 +++ .../events/reconcile_affiliations_spec.rb | 22 +++++ .../reconcile_person_spec.rb | 50 ++++++++++ 7 files changed, 196 insertions(+), 74 deletions(-) create mode 100644 app/views/events/reconcile_affiliations/_person_group.html.erb create mode 100644 db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb index 80d9769915..0cd6e29b2e 100644 --- a/app/controllers/events/reconcile_affiliations_controller.rb +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -10,7 +10,8 @@ def index track_view("events.reconcile_affiliations", { event_id: @event.id }) reconcile = AffiliationServices::ReconcileEvent.new(@event) - @person_groups = reconcile.actionable_person_groups + @cancelled_groups, @person_groups = reconcile.actionable_person_groups + .partition { |group| group[:registration]&.status == "cancelled" } @skipped_sections = reconcile.skipped_reason_sections @has_rows = reconcile.any_rows? # Restore the admin's per-row radio choices when they come back from confirm. diff --git a/app/services/affiliation_services/reconcile_person.rb b/app/services/affiliation_services/reconcile_person.rb index 2e0cdaeafb..15099c07ad 100644 --- a/app/services/affiliation_services/reconcile_person.rb +++ b/app/services/affiliation_services/reconcile_person.rb @@ -26,6 +26,7 @@ def actionable? # ended without a dedicated column (ADR-0003 D6b). COMMENT_TOPIC = "Reconciliation".freeze NOT_ATTENDED = "Didn't attend — no affiliation created".freeze + CANCELLED = "Cancelled — no affiliation created".freeze # `registered` after the event is a gap in the record, not an outcome: # #attendance_recorded? deliberately excludes it. Acting on it would be acting # on missing data, so the row is surfaced for an admin to resolve instead. @@ -204,6 +205,10 @@ def transferred_out? registration_here&.transferred_out? end + def cancelled_here? + registration_here&.status == "cancelled" + end + # A transfer isn't a failure — they're training somewhere else. The affiliation # follows them: it re-dates to the destination event and re-points at that # registration, rather than being ended or deleted (ADR-0003 D6d). @@ -251,11 +256,11 @@ def needs_affiliation? def classify(affiliation) if completed_training? - return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if affiliation.active? + return Decision.new(affiliation:, action: :noop, reason: ACTIVE_ATTENDED) if open?(affiliation) return Decision.new(affiliation:, action: :reactivate) if ended_for_this_training?(affiliation) Decision.new(affiliation:, action: :noop, reason: LAPSED) - elsif !affiliation.active? + elsif !open?(affiliation) reason = ended_by_reconciliation?(affiliation) ? ALREADY_DEACTIVATED : ALREADY_ENDED Decision.new(affiliation:, action: :noop, reason:) elsif attendance_unrecorded? @@ -271,6 +276,12 @@ def classify(affiliation) end end + # A row minted off an upcoming training starts on its date, so it reads as + # upcoming until then — still open, not ended. + def open?(affiliation) + affiliation.active? || affiliation.upcoming? + end + # Only the row this training minted, and only when the person didn't turn up at # all. A partial attendance is kept as an auditable record that they were here — # it confers no facilitation either way (ADR-0001 D8a), but the row and its @@ -284,12 +295,18 @@ def partially_attended? end # Waits for the governing training to end, so a pre-event run never deactivates. - # A hand-entered row has no source training, so it waits on this event. + # A hand-entered row has no source training, so it waits on this event. A + # cancellation settles this event's outcome early, so its rows are ready at once. def deactivation_ready?(affiliation) - affiliation.event_registration_id ? affiliation.event_registration&.event&.ended? : @event.ended? + if affiliation.event_registration_id && !minted_here?(affiliation) + return affiliation.event_registration&.event&.ended? + end + + @event.ended? || cancelled_here? end def creation_decision + return Decision.new(action: :noop, reason: CANCELLED) if cancelled_here? && !completed_training? return Decision.new(action: :create) if !@event.ended? || completed_training? Decision.new(action: :noop, reason: NOT_ATTENDED) diff --git a/app/views/events/reconcile_affiliations/_person_group.html.erb b/app/views/events/reconcile_affiliations/_person_group.html.erb new file mode 100644 index 0000000000..ba2e69f138 --- /dev/null +++ b/app/views/events/reconcile_affiliations/_person_group.html.erb @@ -0,0 +1,64 @@ +<% 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" + } %> +<% fa_hint = "facilitator affiliation (FA)" %> +<% outcome_options = { + create: [ [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Skip", :gray ] ], + deactivate: [ [ "keep", "Keep active", :green ], [ "delete", "Delete affiliation", :red ], [ "deactivate", "Deactivate affiliation", :red ] ], + delete: [ [ "delete", "Delete", :red ], [ "keep", "Keep", :gray ] ], + reactivate: [ [ "reactivate", "Reactivate", :green ], [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Leave inactive", :gray ] ], + retarget: [ [ "retarget", "Move to the new event", :blue ], [ "keep", "Leave as is", :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" %> + +
+
+
+ <%= link_to group[:person].decorate.full_name_with_display_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" %> +
+ + <% if group[:registration]&.status == "incomplete_attendance" && ticked.add?(group[:registration].id) %> + <%= render "attendance_days", registration: group[:registration], event: @event %> + <% end %> + +
+ <% group[:rows].each do |row| %> + <%# A row this training created has nothing to preserve, so Delete leads and + Deactivate is shown but not selectable — the tooltip says why. %> + <% default_choice = row.action == :deactivate && row.minted_here ? "delete" : row.action.to_s %> + <% chosen = (pre_outcome&.dig(row.key)) || default_choice %> +
+ <% 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.period_label %> + <% end %> + <% else %> + <%= row.organization.name %> + <% end %> +
+ <% outcome_options[row.action].each do |value, label, color, hint| %> + <% unavailable = value == "deactivate" && row.minted_here %> + + <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index db43aa5550..658ddf9e92 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -18,7 +18,8 @@ later marked attended, an ending this step applied is undone rather than leaving them with a second affiliation for an engagement that never broke. A return after a real lapse is still a new affiliation, so the lapse stays visible. Anyone still marked Registered is left alone and listed - below — that's an unfilled roster, not an outcome. Job affiliations are never touched. + below — that's an unfilled roster, not an outcome. A cancellation doesn't wait for the training to end: + it's listed in its own section so the affiliation can be settled right away. Job affiliations are never touched.

Only the row this training created is deleted — it recorded an assumption that never came true. An @@ -35,7 +36,7 @@ <% if @event.affiliations_reconciled_at %>

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

<% end %> - <% if @person_groups.any? && @event.affiliations_reconciliation_stale? %> + <% if (@cancelled_groups.any? || @person_groups.any?) && @event.affiliations_reconciliation_stale? %>

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

<% end %> @@ -53,24 +54,8 @@ <%# One set of day ticks per registration, wherever it appears: the turbo replace targets them by id, and a person with two linked orgs has rows in both sections. %> <% ticked = Set.new %> - <% 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" - } %> - <% fa_hint = "facilitator affiliation (FA)" %> - <% outcome_options = { - create: [ [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Skip", :gray ] ], - deactivate: [ [ "keep", "Keep active", :green ], [ "delete", "Delete affiliation", :red ], [ "deactivate", "Deactivate affiliation", :red ] ], - delete: [ [ "delete", "Delete", :red ], [ "keep", "Keep", :gray ] ], - reactivate: [ [ "reactivate", "Reactivate", :green ], [ "create", "Create new FA", :blue, fa_hint ], [ "keep", "Leave inactive", :gray ] ], - retarget: [ [ "retarget", "Move to the new event", :blue ], [ "keep", "Leave as is", :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" %> - + <% if @cancelled_groups.any? || @person_groups.any? %> + <% actionable_count = (@cancelled_groups + @person_groups).sum { |g| g[:rows].size } %>

To reconcile (<%= actionable_count %>)

@@ -82,58 +67,28 @@ can't nest — so the radios join it 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].decorate.full_name_with_display_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" %> -
- - <% if group[:registration]&.status == "incomplete_attendance" && ticked.add?(group[:registration].id) %> - <%= render "attendance_days", registration: group[:registration], event: @event %> + <% if @cancelled_groups.any? %> +
+

Cancelled (<%= @cancelled_groups.size %>)

+

They won't attend, so these can be settled now — no need to wait for the training to end.

+
+ <% @cancelled_groups.each do |group| %> + <%= render "person_group", group: group, ticked: ticked, pre_outcome: @pre_outcome %> <% end %> +
+
+ <% end %> -
- <% group[:rows].each do |row| %> - <%# A row this training created has nothing to preserve, so Delete leads and - Deactivate is shown but not selectable — the tooltip says why. %> - <% default_choice = row.action == :deactivate && row.minted_here ? "delete" : row.action.to_s %> - <% chosen = (@pre_outcome && @pre_outcome[row.key]) || default_choice %> -
- <% 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.period_label %> - <% end %> - <% else %> - <%= row.organization.name %> - <% end %> -
- <% outcome_options[row.action].each do |value, label, color, hint| %> - <% unavailable = value == "deactivate" && row.minted_here %> - - <% end %> -
-
- <% end %> -
-
+ <% if @person_groups.any? %> + <% if @cancelled_groups.any? %> +

Other registrants (<%= @person_groups.size %>)

<% end %> -
+
+ <% @person_groups.each do |group| %> + <%= render "person_group", group: group, ticked: ticked, pre_outcome: @pre_outcome %> + <% end %> +
+ <% end %>
<%= link_to "Cancel", registrants_event_path(@event), class: "text-sm #{eyebrow_link_class}" %> diff --git a/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml new file mode 100644 index 0000000000..fed22c9d14 --- /dev/null +++ b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml @@ -0,0 +1,13 @@ +name: "Reconcile cancelled registrants before the training" +area: events +display_status: admin_facing +released_on: 2026-10-23 +action_path: "/events/1/reconcile_affiliations" +pr_number: 2803 +summary: >- + Reconcile affiliations now lists cancelled registrants in their own section, so + the facilitator affiliation their registration created can be deleted right away + instead of after the training ends. +pro_tips: + - "Someone who cancelled and never trained should have the affiliation deleted, not deactivated — there's no facilitation history to keep." + - "Registrants still marked Registered are listed under Not reconciled for reference until the training ends." diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 6afa653173..fe02ed745d 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -53,6 +53,28 @@ def registrant_with_affiliation(status:) expect(response.body).to include("Create new") end + it "gives cancelled registrants their own section before the event, with the deletion preselected" do + upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now) + cancelled = create(:event_registration, event: upcoming, status: "cancelled") + registered = create(:event_registration, event: upcoming, status: "registered") + [ cancelled, registered ].each do |reg| + create(:event_registration_organization, event_registration: reg, organization: organization) + end + minted = create(:affiliation, person: cancelled.registrant, organization: organization, title: "Facilitator", + start_date: upcoming.starts_on, event_registration: cancelled) + create(:affiliation, person: registered.registrant, organization: organization, title: "Facilitator", + start_date: upcoming.starts_on, event_registration: registered) + + get reconcile_affiliations_event_path(upcoming) + + page = Nokogiri::HTML(response.body) + section = page.at_css("#reconcile_cancelled") + expect(section.text).to include(cancelled.registrant.name) + expect(section.text).not_to include(registered.registrant.name) + expect(section.at_css("#outcome_aff\\:#{minted.id}_delete")["checked"]).to be_present + expect(page.text).to include("Training hasn't ended yet") + end + it "previews a facilitator affiliation on a non-training event as a deletion" do non_training = create(:event, :ended, facilitator_training: false) person = create(:person) diff --git a/spec/services/affiliation_services/reconcile_person_spec.rb b/spec/services/affiliation_services/reconcile_person_spec.rb index 6de512cb8a..431b12bb03 100644 --- a/spec/services/affiliation_services/reconcile_person_spec.rb +++ b/spec/services/affiliation_services/reconcile_person_spec.rb @@ -47,6 +47,27 @@ def reconcile(registration, **options) expect(Affiliation.exists?(affiliation.id)).to be(false) end + it "deletes the minted row as soon as the person cancels, without waiting for the training to end" do + reg = training_registration(status: "cancelled", ended: false) + affiliation = owned_facilitator(registration: reg, start_date: Date.current) + + reconcile(reg) + + expect(Affiliation.exists?(affiliation.id)).to be(false) + end + + it "waits on another upcoming training's row even when this registration is cancelled" do + reg = training_registration(status: "cancelled", ended: false) + other = training_registration(status: "registered", ended: false) + affiliation = owned_facilitator(registration: other, start_date: Date.current) + + plan = described_class.new(person: person, organization: organization, event: reg.event, + registration: reg, include_unowned: true).plan + + expect(plan.map(&:reason)).to eq([ described_class::TRAINING_PENDING ]) + expect(affiliation.reload).to be_active + end + # They were here for part of it. Deleting the row would erase that; ending it # records a facilitator term that was brief rather than one that never began. it "ends the minted row rather than deleting it for a partial attendance" do @@ -86,6 +107,25 @@ def reconcile(registration, **options) expect(plan.first.reason).to eq(described_class::TRAINING_PENDING) end + it "files a row starting on an upcoming training's date as pending, not as already ended" do + reg = training_registration(status: "registered", ended: false) + owned_facilitator(registration: reg) + + plan = described_class.new(person: person, organization: organization, + event: reg.event, registration: reg).plan + + expect(plan.map(&:reason)).to eq([ described_class::TRAINING_PENDING ]) + end + + it "deletes an upcoming training's row once the person cancels" do + reg = training_registration(status: "cancelled", ended: false) + affiliation = owned_facilitator(registration: reg) + + reconcile(reg) + + expect(Affiliation.exists?(affiliation.id)).to be(false) + end + it "deletes it on the day a one-day training ends, with no reliance on the inactive flag" do event = create(:event, facilitator_training: true, start_date: 3.hours.ago, end_date: 1.hour.ago, registration_close_date: 4.hours.ago) @@ -459,6 +499,16 @@ def transferred_out(destination_event:, link_org: organization) .to change { person.affiliations.facilitators.where(organization: organization).count }.by(1) end + it "proposes no affiliation for a registrant who cancelled before the training" do + reg = training_registration(status: "cancelled", ended: false) + + plan = described_class.new(person: person, organization: organization, event: reg.event, + registration: reg).plan + + expect(plan.map(&:action)).to eq([ :noop ]) + expect(plan.first.reason).to eq(described_class::CANCELLED) + end + it "proposes nothing when the caller passes no registration to own the new row" do reg = training_registration(status: "attended") From 231eb6b1648a844bc56b82445537a62501fa37a2 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 11:00:00 -0400 Subject: [PATCH 3/5] Note job affiliations wherever reconcile offers a delete Deleting a facilitator row can leave the person with no link to the organization at all; showing whether a job affiliation remains lets the admin see that before choosing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../affiliation_services/reconcile_event.rb | 13 ++++- .../_person_group.html.erb | 24 ++++++--- ...ncile-cancelled-registrants-right-away.yml | 1 + .../events/reconcile_affiliations_spec.rb | 49 +++++++++++++++++++ 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 513ac38de5..7fed6ba6ee 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -16,10 +16,12 @@ def initialize(event) end # `other_facilitators` are the person's active facilitator affiliations with orgs - # they did NOT link on this event. + # they did NOT link on this event; `jobs_by_org` their open job affiliations with + # the orgs they did. 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) } + { person:, registration: rows.first.registration, rows:, other_facilitators: other_facilitators(person), + jobs_by_org: jobs_by_org(person) } end end @@ -123,6 +125,13 @@ def other_facilitators(person) .to_a end + def jobs_by_org(person) + person.affiliations.active_or_pending + .where(organization_id: linked_org_ids(person)) + .reject(&:facilitator?) + .group_by(&:organization_id) + end + def linked_org_ids(person) linked_organizations(registrations_by_person[person]).map(&:id) end diff --git a/app/views/events/reconcile_affiliations/_person_group.html.erb b/app/views/events/reconcile_affiliations/_person_group.html.erb index ba2e69f138..bc912ec3fe 100644 --- a/app/views/events/reconcile_affiliations/_person_group.html.erb +++ b/app/views/events/reconcile_affiliations/_person_group.html.erb @@ -38,16 +38,24 @@ <%# A row this training created has nothing to preserve, so Delete leads and Deactivate is shown but not selectable — the tooltip says why. %> <% default_choice = row.action == :deactivate && row.minted_here ? "delete" : row.action.to_s %> - <% chosen = (pre_outcome&.dig(row.key)) || default_choice %> + <% chosen = pre_outcome&.dig(row.key) || default_choice %>
- <% 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.period_label %> +
+ <% if row.affiliation %> + <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "hover:underline" do %> + <%= row.organization.name %> + · <%= row.affiliation.decorate.period_label %> + <% end %> + <% else %> + <%= row.organization.name %> <% end %> - <% else %> - <%= row.organization.name %> - <% end %> + <% if outcome_options[row.action].any? { |value, *| value == "delete" } %> + <% jobs = group[:jobs_by_org].fetch(row.organization.id, []) %> +

+ <%= jobs.any? ? "Job affiliation: #{jobs.map { |job| job.title.presence || "untitled" }.to_sentence}" : "No job affiliation" %> +

+ <% end %> +
<% outcome_options[row.action].each do |value, label, color, hint| %> <% unavailable = value == "deactivate" && row.minted_here %> diff --git a/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml index fed22c9d14..29da51f57d 100644 --- a/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml +++ b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml @@ -11,3 +11,4 @@ summary: >- pro_tips: - "Someone who cancelled and never trained should have the affiliation deleted, not deactivated — there's no facilitation history to keep." - "Registrants still marked Registered are listed under Not reconciled for reference until the training ends." + - "Any row offering Delete notes whether the person still has a job affiliation with that organization, so you know if deleting leaves them unlinked." diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index fe02ed745d..27a61db153 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -75,6 +75,55 @@ def registrant_with_affiliation(status:) expect(page.text).to include("Training hasn't ended yet") end + it "notes on each cancelled row whether the person keeps a job affiliation with that organization" do + upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now) + with_job, without_job = Array.new(2) do + reg = create(:event_registration, event: upcoming, status: "cancelled") + create(:event_registration_organization, event_registration: reg, organization: organization) + create(:affiliation, person: reg.registrant, organization: organization, title: "Facilitator", + start_date: upcoming.starts_on, event_registration: reg) + reg + end + create(:affiliation, person: with_job.registrant, organization: organization, title: "Counselor", + start_date: 1.year.ago.to_date) + + get reconcile_affiliations_event_path(upcoming) + + cards = Nokogiri::HTML(response.body).css("#reconcile_cancelled section") + card_for = ->(reg) { cards.find { |card| card.text.include?(reg.registrant.name) }.text.squish } + expect(card_for.call(with_job)).to include("Job affiliation: Counselor") + expect(card_for.call(without_job)).to include("No job affiliation") + end + + it "keeps the cancelled section and its job note after the event" do + reg = create(:event_registration, event: event, status: "cancelled") + create(:event_registration_organization, event_registration: reg, organization: organization) + create(:affiliation, person: reg.registrant, organization: organization, title: "Facilitator", + start_date: event.starts_on, event_registration: reg) + + get reconcile_affiliations_event_path(event) + + section = Nokogiri::HTML(response.body).at_css("#reconcile_cancelled") + expect(section.text).to include(reg.registrant.name) + expect(section.text).to include("No job affiliation") + end + + it "notes job affiliations on any row offering a delete, and only there" do + no_show, _affiliation = registrant_with_affiliation(status: "no_show") + create(:affiliation, person: no_show, organization: organization, title: "Counselor", + start_date: 1.year.ago.to_date) + upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now) + reg = create(:event_registration, event: upcoming, status: "registered") + create(:event_registration_organization, event_registration: reg, organization: organization) + + get reconcile_affiliations_event_path(event) + expect(response.body).to include("Job affiliation: Counselor") + + get reconcile_affiliations_event_path(upcoming) + expect(response.body).to include("Create new") + expect(response.body).not_to include("job affiliation") + end + it "previews a facilitator affiliation on a non-training event as a deletion" do non_training = create(:event, :ended, facilitator_training: false) person = create(:person) From bf07812acc2a1bdd414e7a9ae43aa954012b7888 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 11:09:07 -0400 Subject: [PATCH 4/5] Note job affiliations for every cancelled, no-show, and transferred registrant These are the people whose facilitator link to the org is going away or moving, so whether a job affiliation remains matters even on rows reconcile won't change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../affiliation_services/reconcile_event.rb | 25 ++++++++++------- .../reconcile_affiliations/_job_note.html.erb | 3 ++ .../_person_group.html.erb | 7 ++--- .../reconcile_affiliations/index.html.erb | 9 ++++-- ...ncile-cancelled-registrants-right-away.yml | 2 +- .../events/reconcile_affiliations_spec.rb | 28 +++++++++++++++++++ 6 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 app/views/events/reconcile_affiliations/_job_note.html.erb diff --git a/app/services/affiliation_services/reconcile_event.rb b/app/services/affiliation_services/reconcile_event.rb index 7fed6ba6ee..eafaebf221 100644 --- a/app/services/affiliation_services/reconcile_event.rb +++ b/app/services/affiliation_services/reconcile_event.rb @@ -5,10 +5,17 @@ module AffiliationServices # the timestamp live here. Job affiliations are never touched. class ReconcileEvent Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key, - :minted_here, keyword_init: true) do + :minted_here, :jobs, keyword_init: true) do def actionable? action != :noop end + + # Whether removing or moving this facilitator row could leave the person + # unlinked from the org, so the page notes their remaining job affiliations. + def shows_jobs? + %i[ delete deactivate ].include?(action) || + EventRegistration::INACTIVE_STATUSES.include?(registration&.status) + end end def initialize(event) @@ -16,12 +23,10 @@ def initialize(event) end # `other_facilitators` are the person's active facilitator affiliations with orgs - # they did NOT link on this event; `jobs_by_org` their open job affiliations with - # the orgs they did. + # 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), - jobs_by_org: jobs_by_org(person) } + { person:, registration: rows.first.registration, rows:, other_facilitators: other_facilitators(person) } end end @@ -91,7 +96,7 @@ def all_rows def row_for(person, registration, organization, decision) Row.new(person:, registration:, organization:, affiliation: decision.affiliation, action: decision.action, reason: decision.reason, minted_here: decision.minted_here, - key: row_key(person, organization, decision)) + key: row_key(person, organization, decision), jobs: jobs_by_person_org.fetch([ person.id, organization.id ], [])) end # Stable per-row identity for the outcome map: the affiliation itself when there @@ -125,11 +130,11 @@ def other_facilitators(person) .to_a end - def jobs_by_org(person) - person.affiliations.active_or_pending - .where(organization_id: linked_org_ids(person)) + def jobs_by_person_org + @jobs_by_person_org ||= Affiliation.active_or_pending + .where(person_id: registrations_by_person.keys.compact.map(&:id)) .reject(&:facilitator?) - .group_by(&:organization_id) + .group_by { |affiliation| [ affiliation.person_id, affiliation.organization_id ] } end def linked_org_ids(person) diff --git a/app/views/events/reconcile_affiliations/_job_note.html.erb b/app/views/events/reconcile_affiliations/_job_note.html.erb new file mode 100644 index 0000000000..220f53f6df --- /dev/null +++ b/app/views/events/reconcile_affiliations/_job_note.html.erb @@ -0,0 +1,3 @@ +

+ <%= jobs.any? ? "Job affiliation: #{jobs.map { |job| job.title.presence || "untitled" }.to_sentence}" : "No job affiliation" %> +

diff --git a/app/views/events/reconcile_affiliations/_person_group.html.erb b/app/views/events/reconcile_affiliations/_person_group.html.erb index bc912ec3fe..c7dcba976f 100644 --- a/app/views/events/reconcile_affiliations/_person_group.html.erb +++ b/app/views/events/reconcile_affiliations/_person_group.html.erb @@ -49,11 +49,8 @@ <% else %> <%= row.organization.name %> <% end %> - <% if outcome_options[row.action].any? { |value, *| value == "delete" } %> - <% jobs = group[:jobs_by_org].fetch(row.organization.id, []) %> -

- <%= jobs.any? ? "Job affiliation: #{jobs.map { |job| job.title.presence || "untitled" }.to_sentence}" : "No job affiliation" %> -

+ <% if row.shows_jobs? %> + <%= render "job_note", jobs: row.jobs %> <% end %>
diff --git a/app/views/events/reconcile_affiliations/index.html.erb b/app/views/events/reconcile_affiliations/index.html.erb index 658ddf9e92..3cc9b268a5 100644 --- a/app/views/events/reconcile_affiliations/index.html.erb +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -70,7 +70,7 @@ <% if @cancelled_groups.any? %>

Cancelled (<%= @cancelled_groups.size %>)

-

They won't attend, so these can be settled now — no need to wait for the training to end.

+

They cancelled, so these can be settled any time — before or after the training.

<% @cancelled_groups.each do |group| %> <%= render "person_group", group: group, ticked: ticked, pre_outcome: @pre_outcome %> @@ -113,7 +113,7 @@
<% rows.each do |row| %>
- +
<%= link_to row.person.decorate.full_name_with_display_name, edit_event_registration_path(row.registration), target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> — <% if row.affiliation %> <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "hover:underline hover:text-blue-700" do %> @@ -122,7 +122,10 @@ <% else %> <%= row.organization.name %> <% end %> - + <% if row.shows_jobs? %> + <%= render "job_note", jobs: row.jobs %> + <% end %> +
<% if reason == AffiliationServices::ReconcilePerson::ATTENDANCE_NOT_RECORDED && ticked.add?(row.registration&.id) %> <%= render "attendance_day_ticks", registration: row.registration, event: @event %> <% end %> diff --git a/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml index 29da51f57d..3f75e1896e 100644 --- a/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml +++ b/db/seeds/features/2026-10-23-reconcile-cancelled-registrants-right-away.yml @@ -11,4 +11,4 @@ summary: >- pro_tips: - "Someone who cancelled and never trained should have the affiliation deleted, not deactivated — there's no facilitation history to keep." - "Registrants still marked Registered are listed under Not reconciled for reference until the training ends." - - "Any row offering Delete notes whether the person still has a job affiliation with that organization, so you know if deleting leaves them unlinked." + - "Rows for cancelled, no-show, and transferred registrants — and any row offering Delete — note whether the person still has a job affiliation with that organization." diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 27a61db153..3311626005 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -124,6 +124,34 @@ def registrant_with_affiliation(status:) expect(response.body).not_to include("job affiliation") end + it "notes job affiliations for a transferred-out registrant" do + person, _affiliation = registrant_with_affiliation(status: "registered") + source = person.event_registrations.find_by(event: event) + destination_event = create(:event, facilitator_training: true, start_date: 3.months.from_now, + end_date: 3.months.from_now + 1.day, registration_close_date: 2.months.from_now) + destination = create(:event_registration, registrant: person, event: destination_event, status: "registered") + create(:event_registration_organization, event_registration: destination, organization: organization) + destination.update!(transferred_from_registration: source) + source.update!(status: "transferred_out") + create(:affiliation, person: person, organization: organization, title: "Counselor", start_date: 1.year.ago.to_date) + + get reconcile_affiliations_event_path(event) + + expect(response.body).to include("Move to the new event") + expect(response.body).to include("Job affiliation: Counselor") + end + + it "notes job affiliations on a no-show listed under Not reconciled" do + person = create(:person) + reg = create(:event_registration, event: event, registrant: person, status: "no_show") + create(:event_registration_organization, event_registration: reg, organization: organization) + + get reconcile_affiliations_event_path(event) + + expect(response.body).to include("Didn't attend — no affiliation created") + expect(response.body).to include("No job affiliation") + end + it "previews a facilitator affiliation on a non-training event as a deletion" do non_training = create(:event, :ended, facilitator_training: false) person = create(:person) From cb099fe89e0baf49daf6113f8d0273b118ecb3c2 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Fri, 23 Oct 2026 11:12:58 -0400 Subject: [PATCH 5/5] Show each job affiliation's dates in the reconcile note Whether a job affiliation is current, upcoming, or about to end changes what deleting the facilitator row leaves behind. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../reconcile_affiliations/_job_note.html.erb | 3 ++- spec/requests/events/reconcile_affiliations_spec.rb | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/views/events/reconcile_affiliations/_job_note.html.erb b/app/views/events/reconcile_affiliations/_job_note.html.erb index 220f53f6df..c62c72a664 100644 --- a/app/views/events/reconcile_affiliations/_job_note.html.erb +++ b/app/views/events/reconcile_affiliations/_job_note.html.erb @@ -1,3 +1,4 @@ +<% labels = jobs.map { |job| "#{job.title.presence || "Untitled"} · #{job.decorate.period_label}" } %>

- <%= jobs.any? ? "Job affiliation: #{jobs.map { |job| job.title.presence || "untitled" }.to_sentence}" : "No job affiliation" %> + <%= labels.any? ? "Job affiliation: #{labels.to_sentence}" : "No job affiliation" %>

diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb index 3311626005..30d20a4253 100644 --- a/spec/requests/events/reconcile_affiliations_spec.rb +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -91,10 +91,21 @@ def registrant_with_affiliation(status:) cards = Nokogiri::HTML(response.body).css("#reconcile_cancelled section") card_for = ->(reg) { cards.find { |card| card.text.include?(reg.registrant.name) }.text.squish } - expect(card_for.call(with_job)).to include("Job affiliation: Counselor") + expect(card_for.call(with_job)).to include("Job affiliation: Counselor · Since #{1.year.ago.strftime('%b %Y')}") expect(card_for.call(without_job)).to include("No job affiliation") end + it "shows each job affiliation's date range in the note" do + reg = create(:event_registration, event: event, status: "cancelled") + create(:event_registration_organization, event_registration: reg, organization: organization) + create(:affiliation, person: reg.registrant, organization: organization, title: "Counselor", + start_date: Date.new(2024, 1, 15), end_date: 2.years.from_now.to_date) + + get reconcile_affiliations_event_path(event) + + expect(response.body).to include("Counselor · Jan 2024 – #{2.years.from_now.strftime('%b %Y')}") + end + it "keeps the cancelled section and its job note after the event" do reg = create(:event_registration, event: event, status: "cancelled") create(:event_registration_organization, event_registration: reg, organization: organization)