Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
489cd9f
Add affiliation↔registration link and event reconciled-at column
maebeale Aug 14, 2026
c691836
Add ReconcileFacilitatorAffiliation service
maebeale Aug 14, 2026
ef2e439
Add Reconcile affiliations bulk action with preview and opt-out
maebeale Aug 14, 2026
49a52ae
Date facilitator affiliation to the training day; heal missing affili…
maebeale Aug 14, 2026
e11ef68
Reconcile non-training facilitator affiliations and offer delete-instead
maebeale Aug 14, 2026
b02b3ef
Show full reconcile picture: reasons for no-action rows and attendanc…
maebeale Aug 14, 2026
7c33e81
Redesign reconcile page: group by person, per-affiliation, editable a…
maebeale Aug 14, 2026
74d0a1e
Editable attendance chip in skipped rows, Edit first, shorter dates
maebeale Aug 14, 2026
6601657
Reconcile all facilitator affiliations incl. hand-entered; clearer ac…
maebeale Aug 14, 2026
620eb70
Action toggles as buttons with error-red on select, hover tooltips, h…
maebeale Aug 14, 2026
700dd67
Move Collapse all into the Not reconciled header; more space between …
maebeale Aug 14, 2026
a5c75a1
Show checkbox inside action buttons; make deactivate/delete mutually …
maebeale Aug 14, 2026
a247dbb
Two-step reconcile: Preview changes then confirmation screen
maebeale Aug 14, 2026
86a085a
Keep attendance toggle on the reconcile page with a flash
maebeale Aug 14, 2026
5805acf
Register exclusive-checkboxes controller; clearer deactivate instruction
maebeale Aug 14, 2026
439ced8
Render deactivate row note as two lines
maebeale Aug 14, 2026
cc6ccc0
Reword deactivate note to 'To keep Affiliation active: Mark as Attend…
maebeale Aug 14, 2026
cd810c0
Fix Preview changes: turbo:false so the confirm page renders on POST;…
maebeale Aug 14, 2026
b1a09f1
Radio outcomes with Keep-active; fix nested-form bug; scroll to item …
maebeale Aug 14, 2026
6ae75e1
Fix Brakeman: read outcome params as a plain hash instead of permit!
maebeale Aug 14, 2026
031ae84
Reorder deactivate outcomes: Keep active, Delete, Deactivate; clearer…
maebeale Aug 14, 2026
eb15d0a
Update reconcile specs for renamed 'Deactivate affiliation' label
maebeale Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

Expand Down
1 change: 1 addition & 0 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
Expand Down
64 changes: 64 additions & 0 deletions app/controllers/events/reconcile_affiliations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module Events
# The "Reconcile affiliations" bulk action: a preview-and-confirm page that
# 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

def index
authorize! @event, to: :reconcile_affiliations?
track_view("events.reconcile_affiliations", { event_id: @event.id })

reconcile = AffiliationServices::ReconcileEvent.new(@event)
@person_groups = reconcile.actionable_person_groups
@skipped_sections = reconcile.skipped_reason_sections
@has_rows = reconcile.any_rows?
# Restore the admin's per-row radio choices when they come back from confirm.
@pre_outcome = params[:outcome]
@event = @event.decorate
end

# Step 2: show exactly what "Perform changes" will do (no writes yet).
def confirm
authorize! @event, to: :reconcile_affiliations?

@outcome = outcome_params
@changes = AffiliationServices::ReconcileEvent.new(@event).planned_changes(outcome: @outcome)
@event = @event.decorate

redirect_to reconcile_affiliations_event_path(@event), notice: "Nothing selected to change." and return if @changes.empty?
end

def create
authorize! @event, to: :reconcile_affiliations?

changed = AffiliationServices::ReconcileEvent.new(@event).apply(outcome: outcome_params)
redirect_to registrants_event_path(@event), notice: reconcile_notice(changed)
end

private

def set_event
@event = Event.find(params[:id])
end

# `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
raw = params[:outcome]
return {} unless raw.respond_to?(:each_pair)

raw.each_pair.map { |key, value| [ key.to_s, value.to_s ] }.to_h
end

def reconcile_notice(changed)
return "No affiliations needed reconciling." if changed.zero?

"Reconciled #{changed} #{'affiliation'.pluralize(changed)}."
end
end
end
8 changes: 8 additions & 0 deletions app/decorators/affiliation_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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. "Oct 13, 2026 – present".
# Reads "no start date" when unset so a blank date isn't silently omitted.
def date_range
start = start_date ? start_date.strftime("%b %-d, %Y") : "no start date"
finish = end_date ? end_date.strftime("%b %-d, %Y") : "present"
"#{start} – #{finish}"
end
end
9 changes: 9 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions app/policies/event_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ def bulk_payments?
manage?
end

def reconcile_affiliations?
manage?
end

def invoice?
manage?
end
Expand Down
207 changes: 207 additions & 0 deletions app/services/affiliation_services/reconcile_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
module AffiliationServices
# Event-level orchestration for the "Reconcile affiliations" bulk action. Walks
# 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, 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`.
#
# `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. `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
Row = Struct.new(:person, :registration, :organization, :affiliation, :action, :reason, :key, keyword_init: true) do
def actionable?
action != :noop
end
end

def initialize(event)
@event = event
end

# 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: [[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_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?
all_rows.any?
end

Change = Struct.new(:person, :organization, :affiliation, :action, keyword_init: true)

# 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|
action = ACTION_FOR_CHOICE[outcome[row.key]]
next unless action

Change.new(person: row.person, organization: row.organization, affiliation: row.affiliation, action:)
end
end

# Apply each row's chosen outcome, stamp the event, and return the number of
# rows actually changed ("keep"/unknown choices are no-ops).
def apply(outcome:)
outcome = outcome.to_h

changed = all_rows.count do |row|
row.actionable? && perform_outcome(row, outcome[row.key])
end

@event.update!(affiliations_reconciled_at: Time.current)
changed
end

private

def all_rows
@all_rows ||= registrations_by_person.flat_map do |person, registrations|
registration = registrations.first
linked_organizations(registrations).flat_map { |organization| rows_for(person, registration, organization) }
end
end

def rows_for(person, registration, 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?
rows.compact
end

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

# 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)
if attended
affiliation.active? ? [ :noop, "Active β€” attended" ] : [ :reactivate, nil ]
elsif affiliation.active? && deactivation_ready?(affiliation)
[ :deactivate, nil ]
elsif affiliation.active?
[ :noop, "Training hasn't ended yet" ]
else
[ :noop, "Already deactivated β€” didn't attend" ]
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,
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_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"
row.affiliation.destroy!
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"
row.affiliation.update!(end_date: nil)
else
return false # "keep" or unknown
end
true
end

def completed_training?(person, organization)
ReconcileFacilitatorAffiliation.new(person:, organization:).completed_training?
end

def source_ended?(affiliation)
affiliation.event_registration&.event&.ended?
end

def other_facilitators(person)
person.affiliations.active.facilitators
.where.not(organization_id: linked_org_ids(person))
.includes(:organization)
.to_a
end

def linked_org_ids(person)
linked_organizations(registrations_by_person[person]).map(&:id)
end

def linked_organizations(registrations)
registrations.flat_map(&:organizations).uniq
end

def registrations_by_person
@registrations_by_person ||= @event.event_registrations.includes(:registrant, :organizations).group_by(&:registrant)
end
end
end
Loading