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 %> -
- <%= 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/_bulk_actions_menu.html.erb b/app/views/events/_bulk_actions_menu.html.erb index 72c7dac7f..8a598d2d1 100644 --- a/app/views/events/_bulk_actions_menu.html.erb +++ b/app/views/events/_bulk_actions_menu.html.erb @@ -24,6 +24,7 @@ <% else %> <%= link_to "Sign-ins", attendance_event_path(@event, return_to: "registrants"), 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/_tooltip.html.erb b/app/views/events/reconcile_affiliations/_tooltip.html.erb new file mode 100644 index 000000000..a41b75283 --- /dev/null +++ b/app/views/events/reconcile_affiliations/_tooltip.html.erb @@ -0,0 +1,19 @@ +<%# Hover explanation for a reconcile action. `kind` is the action symbol. %> + 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..885b8287d --- /dev/null +++ b/app/views/events/reconcile_affiliations/confirm.html.erb @@ -0,0 +1,51 @@ +<% 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, outcome: @outcome), 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, outcome: @outcome), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= form_with url: perform_reconcile_affiliations_event_path(@event), method: :post do %> + <% @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 new file mode 100644 index 000000000..34af58341 --- /dev/null +++ b/app/views/events/reconcile_affiliations/index.html.erb @@ -0,0 +1,156 @@ +<% 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

+ +
+ <% 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, 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 %> +
+ + <% unless @has_rows %> +
+ No registrants have linked an organization, so there's nothing to reconcile. +
+ <% end %> + + <% 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: [ [ "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" %> + +
+

To reconcile (<%= actionable_count %>)

+ + These buttons change facilitator 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| %> + <% 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 %> +
+
+ <% end %> +
+
+ <% 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? %> + <% 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| %> +
+ + <%= link_to row.person.name, edit_event_registration_path(row.registration), target: "_blank", rel: "noopener", class: "font-medium text-gray-800 hover:underline hover:text-blue-700" %> — + <% if row.affiliation %> + <%= link_to edit_person_path(row.person, anchor: dom_id(row.affiliation)), target: "_blank", rel: "noopener", title: "Edit affiliation", class: "hover:underline hover:text-blue-700" do %> + <%= row.organization.name %> · <%= row.affiliation.decorate.date_range %> + <% end %> + <% else %> + <%= row.organization.name %> + <% end %> + + <%= render "event_registrations/attendance_status_badge", registration: row.registration, return_to: "reconcile_affiliations" %> +
+ <% end %> +
+
+ <% end %> +
+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 76743101a..5426f8e5f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -190,6 +190,9 @@ get :recipients post :feature_recipient_shoutout get :bulk_payments, to: "events/bulk_payments#index" + get :reconcile_affiliations, to: "events/reconcile_affiliations#index" + post :reconcile_affiliations, to: "events/reconcile_affiliations#confirm" + post :perform_reconcile_affiliations, to: "events/reconcile_affiliations#create" get :preview_reminder patch :preview post :copy_registration_form diff --git a/db/migrate/20260814044207_add_affiliations_reconciled_at_to_events.rb b/db/migrate/20260814044207_add_affiliations_reconciled_at_to_events.rb new file mode 100644 index 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 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 diff --git a/spec/requests/events/reconcile_affiliations_spec.rb b/spec/requests/events/reconcile_affiliations_spec.rb new file mode 100644 index 000000000..2b4d53286 --- /dev/null +++ b/spec/requests/events/reconcile_affiliations_spec.rb @@ -0,0 +1,187 @@ +require "rails_helper" + +RSpec.describe "Events::ReconcileAffiliations", type: :request do + let(:admin) { create(:user, :admin) } + let(:organization) { create(:organization) } + let(:event) { create(:event, :ended, facilitator_training: true) } + + # A registrant of `event` who linked `organization`, with an owned facilitator + # affiliation created (as the registration flow would). + def registrant_with_affiliation(status:) + person = create(:person) + reg = create(:event_registration, event: event, registrant: person, status: status) + create(:event_registration_organization, event_registration: reg, organization: organization) + affiliation = create(:affiliation, person: person, organization: organization, + title: "Facilitator", start_date: 1.month.ago.to_date, + event_registration: reg) + [ person, affiliation ] + end + + before { sign_in admin } + + describe "GET index" do + it "previews the no-show as a deactivation, checked by default" do + person, _affiliation = registrant_with_affiliation(status: "no_show") + + get reconcile_affiliations_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(person.name) + expect(response.body).to include("Deactivate affiliation") + end + + it "previews a missing affiliation as a creation before the event" do + upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now) + person = create(:person) + reg = create(:event_registration, event: upcoming, registrant: person, status: "registered") + create(:event_registration_organization, event_registration: reg, organization: organization) + + get reconcile_affiliations_event_path(upcoming) + + expect(response.body).to include("Will be created") + end + + it "previews a facilitator affiliation on a non-training event as a deletion" do + non_training = create(:event, :ended, facilitator_training: false) + person = create(:person) + reg = create(:event_registration, event: non_training, registrant: person, status: "attended") + create(:event_registration_organization, event_registration: reg, organization: organization) + create(:affiliation, person: person, organization: organization, title: "Facilitator", + start_date: 1.month.ago.to_date, event_registration: reg) + + get reconcile_affiliations_event_path(non_training) + + expect(response.body).to include("Will be deleted") + end + + it "lists a no-action registrant under Not reconciled with the reason and attendance status" do + person, _affiliation = registrant_with_affiliation(status: "attended") + + get reconcile_affiliations_event_path(event) + + expect(response.body).to include("Not reconciled") + expect(response.body).to include("Active — attended") + expect(response.body).to include("Attended") + expect(response.body).to include(person.name) + end + + it "reconciles a hand-entered (unowned) facilitator affiliation too" do + person = create(:person) + reg = create(:event_registration, event: event, registrant: person, status: "no_show") + create(:event_registration_organization, event_registration: reg, organization: organization) + create(:affiliation, person: person, organization: organization, title: "Facilitator", start_date: 1.year.ago.to_date) + + get reconcile_affiliations_event_path(event) + + expect(response.body).to include("Deactivate affiliation") + end + + it "denies a non-admin" do + sign_in create(:user) + + get reconcile_affiliations_event_path(event) + + expect(response).not_to have_http_status(:ok) + end + end + + describe "toggling attendance from the reconcile page" do + it "stays on the reconcile page with a flash instead of leaving for the roster" do + person, _affiliation = registrant_with_affiliation(status: "no_show") + registration = person.event_registrations.first + + patch event_registration_path(registration, return_to: "reconcile_affiliations"), + params: { event_registration: { status: "attended" } } + + expect(response).to redirect_to(reconcile_affiliations_event_path(event, anchor: "attendance_status_event_registration_#{registration.id}")) + expect(flash[:notice]).to be_present + end + end + + describe "POST confirm (preview changes)" do + it "shows the selected change without writing" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "deactivate" } } + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Confirm affiliation changes") + expect(response.body).to include("Perform changes") + expect(affiliation.reload).to be_active + end + + it "redirects back when nothing is selected" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "keep" } } + + expect(response).to redirect_to(reconcile_affiliations_event_path(event)) + end + end + + describe "POST perform" do + it "deactivates the chosen non-completer and stamps the event" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "deactivate" } } + + expect(response).to redirect_to(registrants_event_path(event)) + expect(affiliation.reload).not_to be_active + expect(event.reload.affiliations_reconciled_at).to be_present + end + + it "spares a row set to keep" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "keep" } } + + expect(affiliation.reload).to be_active + end + + it "creates a missing affiliation before the event when chosen" do + upcoming = create(:event, facilitator_training: true, start_date: 3.days.from_now, end_date: 5.days.from_now) + person = create(:person) + reg = create(:event_registration, event: upcoming, registrant: person, status: "registered") + create(:event_registration_organization, event_registration: reg, organization: organization) + + expect { + post perform_reconcile_affiliations_event_path(upcoming), params: { outcome: { "create:#{person.id}:#{organization.id}" => "create" } } + }.to change { person.affiliations.facilitators.where(organization: organization).count }.by(1) + end + + it "deletes when the delete outcome is chosen" do + _person, affiliation = registrant_with_affiliation(status: "no_show") + + post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{affiliation.id}" => "delete" } } + + expect(Affiliation.exists?(affiliation.id)).to be(false) + end + + it "deactivates a hand-entered facilitator affiliation when chosen" do + person = create(:person) + reg = create(:event_registration, event: event, registrant: person, status: "no_show") + create(:event_registration_organization, event_registration: reg, organization: organization) + hand_entered = create(:affiliation, person: person, organization: organization, title: "Facilitator", start_date: 1.year.ago.to_date) + + post perform_reconcile_affiliations_event_path(event), params: { outcome: { "aff:#{hand_entered.id}" => "deactivate" } } + + expect(hand_entered.reload).not_to be_active + end + + it "deletes a facilitator affiliation auto-created off a non-training event, keeping the job affiliation" do + non_training = create(:event, :ended, facilitator_training: false) + person = create(:person) + reg = create(:event_registration, event: non_training, registrant: person, status: "attended") + create(:event_registration_organization, event_registration: reg, organization: organization) + facilitator = create(:affiliation, person: person, organization: organization, title: "Facilitator", + start_date: 1.month.ago.to_date, event_registration: reg) + job = create(:affiliation, person: person, organization: organization, title: "Counselor", + event_registration: reg) + + post perform_reconcile_affiliations_event_path(non_training), params: { outcome: { "aff:#{facilitator.id}" => "delete" } } + + expect(Affiliation.exists?(facilitator.id)).to be(false) + expect(Affiliation.exists?(job.id)).to be(true) + end + end +end diff --git a/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb b/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb new file mode 100644 index 000000000..d910e4bbb --- /dev/null +++ b/spec/services/affiliation_services/reconcile_facilitator_affiliation_spec.rb @@ -0,0 +1,137 @@ +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 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, + 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 diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index bd401c4af..ef69342c1 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -118,6 +118,8 @@ "app/views/events/signins.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/sample_ticket.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/bulk_payments/index.html.erb" => "admin-or-owner bg-blue-100", + "app/views/events/reconcile_affiliations/index.html.erb" => "admin-or-owner bg-blue-100", + "app/views/events/reconcile_affiliations/confirm.html.erb" => "admin-or-owner bg-blue-100", "app/views/events/edit_staff.html.erb" => "admin-or-owner bg-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",