diff --git a/AGENTS.md b/AGENTS.md index cf151739d..ac1afcf47 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. 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 d6c70cfd9..c153f8b50 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, anchor: helpers.dom_id(@event_registration, :attendance_status)), notice: notice, status: :see_other # Two ways back to the recipients page: the shout-outs section (the # feature-a-shout-out flow) or the recipient's own card (their name). when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other diff --git a/app/controllers/events/reconcile_affiliations_controller.rb b/app/controllers/events/reconcile_affiliations_controller.rb new file mode 100644 index 000000000..997ff3c81 --- /dev/null +++ b/app/controllers/events/reconcile_affiliations_controller.rb @@ -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 diff --git a/app/decorators/affiliation_decorator.rb b/app/decorators/affiliation_decorator.rb index 86f3f24dc..3fac6cece 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. "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 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..dffa15930 --- /dev/null +++ b/app/services/affiliation_services/reconcile_event.rb @@ -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 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..b4c837651 --- /dev/null +++ b/app/services/affiliation_services/reconcile_facilitator_affiliation.rb @@ -0,0 +1,97 @@ +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 + elsif deactivatable_affiliations.any? + :deactivate + else + :noop + 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 + + # 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) + targets = deactivatable_affiliations + return :noop if targets.empty? + + targets.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? + + 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 + end +end diff --git a/app/views/event_registrations/_attendance_status_badge.html.erb b/app/views/event_registrations/_attendance_status_badge.html.erb index 768ce79e3..4fd6882c2 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 %> -
+ 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." ] + } %> + +<%= description %>
++ 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, and reactivates anyone later marked + attended. Job affiliations are never touched. +
+ <% 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 %> +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 %> + <% 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 %> +