diff --git a/AGENTS.md b/AGENTS.md index df764e7b9f..35dc2e113b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~78 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~28 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~29 files | | `app/jobs/` | SolidQueue background jobs | 3 files | | `app/models/concerns/` | Shared model modules | 15 concerns | @@ -181,6 +181,7 @@ end ### Business Logic - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total) +- `EventOrganizationLinkStatus` — Classifies an event's registrations as linked/pending/none by organization resolution, for the registrants filter - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration - `WorkshopVariationFromIdeaService` — Variation creation from ideas diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index cb4e688ecf..452dd34be4 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -89,9 +89,16 @@ def registrants scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present? scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present? scope = scope.registrant_state(params[:state]) if params[:state].present? + scope = scope.registrant_locality(params[:locality]) if params[:locality].present? scope = scope.registrant_county(params[:county]) if params[:county].present? + scope = scope.registrant_country(params[:country]) if params[:country].present? scope = scope.registrant_sector(params[:sector]) if params[:sector].present? + if params[:organization_status].present? + registration_ids = EventOrganizationLinkStatus.new(@event).registration_ids_for(params[:organization_status]) + scope = scope.where(id: registration_ids) + end + @active_count = scope.active.count @inactive_count = scope.inactive.count diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 63e0ca6ebb..cddf7a25b7 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -76,6 +76,16 @@ class EventRegistration < ApplicationRecord conditions[:state] = state if state.present? joins(registrant: :addresses).where(addresses: conditions).distinct } + scope :registrant_locality, ->(locality) { + joins(registrant: :addresses) + .where(addresses: { inactive: false, locality: locality }) + .distinct + } + scope :registrant_country, ->(country) { + joins(registrant: :addresses) + .where(addresses: { inactive: false, country: country }) + .distinct + } scope :registrant_sector, ->(sector_id) { joins(registrant: :sectorable_items) .where(sectorable_items: { sector_id: sector_id }) diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index f27aac74bc..422197e3bb 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -471,6 +471,18 @@ def countries .sort end + # Localities (e.g. urban / rural / suburban) on file across active registrants' + # active addresses, sorted by name — the option list for the registrants filter. + def localities + @localities ||= Address + .active + .where(addressable_type: "Person", addressable_id: registrant_ids) + .where.not(locality: [ nil, "" ]) + .distinct + .pluck(:locality) + .sort + end + # Distinct registrant count per country. def country_counts @country_counts ||= Address diff --git a/app/services/event_organization_link_status.rb b/app/services/event_organization_link_status.rb new file mode 100644 index 0000000000..847e4da744 --- /dev/null +++ b/app/services/event_organization_link_status.rb @@ -0,0 +1,93 @@ +# Classifies an event's registrations by how their organization is resolved, +# mirroring the badge logic in `events/_registrants_results` so the registrants +# filter and the displayed pills agree: +# +# - linked: the registration has at least one linked organization +# - pending: the registrant submitted an agency name that doesn't match any +# linked organization (needs admin attention) +# - none: no linked organization and no submitted agency name +# +# The states are not mutually exclusive — a registration can be both linked and +# pending — so each option returns everyone matching that predicate. +class EventOrganizationLinkStatus + STATUSES = %w[linked pending none].freeze + + def initialize(event) + @event = event + end + + # Registration ids matching the given status ("linked"/"pending"/"none"). + # Returns [] for an unrecognized status so callers can scope to nothing. + def registration_ids_for(status) + case status.to_s + when "linked" then linked_registration_ids + when "pending" then pending_registration_ids + when "none" then none_registration_ids + else [] + end + end + + private + + attr_reader :event + + def linked_registration_ids + registrations.select { |registration| linked_names(registration).present? }.map(&:id) + end + + def pending_registration_ids + registrations.select { |registration| needs_linking?(registration) }.map(&:id) + end + + def none_registration_ids + registrations.select do |registration| + linked_names(registration).blank? && submitted_name(registration).blank? + end.map(&:id) + end + + def needs_linking?(registration) + name = submitted_name(registration) + name.present? && linked_names(registration).exclude?(name.downcase) + end + + def linked_names(registration) + linked_names_by_registration.fetch(registration.id, []) + end + + def submitted_name(registration) + submitted_names_by_registrant[registration.registrant_id].to_s.strip + end + + def registrations + @registrations ||= event.event_registrations.pluck(:id, :registrant_id) + .map { |id, registrant_id| Registration.new(id, registrant_id) } + end + + # Linked organization names (lowercased) keyed by registration id. + def linked_names_by_registration + @linked_names_by_registration ||= EventRegistrationOrganization + .joins(:organization) + .where(event_registration_id: registrations.map(&:id)) + .pluck(:event_registration_id, Arel.sql("organizations.name")) + .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(registration_id, name), memo| + memo[registration_id] << name.to_s.strip.downcase + end + end + + # Agency name submitted on the registration form, keyed by registrant (person) id. + def submitted_names_by_registrant + return @submitted_names_by_registrant if defined?(@submitted_names_by_registrant) + form = event.registration_form + field = form&.form_fields&.find_by(field_identifier: "agency_name") + @submitted_names_by_registrant = if field + FormAnswer.joins(:form_submission) + .where(form_submissions: { person_id: registrations.map(&:registrant_id), form_id: form.id }, form_field_id: field.id) + .pluck(Arel.sql("form_submissions.person_id"), :submitted_answer) + .to_h + else + {} + end + end + + Registration = Struct.new(:id, :registrant_id) +end diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index 1db8ea304f..00e1355f68 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -1,90 +1,132 @@ -<%= form_with url: registrants_event_path(@event), method: :get, - data: { controller: "collection", turbo_frame: "registrants_results" }, - autocomplete: "off", - class: "flex flex-col md:flex-row md:flex-wrap md:items-end gap-4 mb-6" do %> +
+ <%= form_with url: registrants_event_path(@event), method: :get, + data: { controller: "collection", turbo_frame: "registrants_results" }, + autocomplete: "off", + class: "flex flex-col gap-4" do %> - <%= hidden_field_tag :status_filter, params[:status_filter].presence || "active" %> + <%= hidden_field_tag :status_filter, params[:status_filter].presence || "active" %> -
- <%= label_tag :keyword, "Keyword", class: "block text-sm font-medium text-gray-700 mb-1" %> -
- <%= text_field_tag :keyword, params[:keyword], + <%# Row 1: keyword, organization status, scholarship, payment, attendance %> +
+
+ <%= label_tag :keyword, "Keyword", class: "block text-sm font-medium text-gray-700 mb-1" %> +
+ <%= text_field_tag :keyword, params[:keyword], + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", + placeholder: "Name, email, phone, city, or organization" %> + +
+
+ +
+ <%= label_tag :organization_status, "Organization status", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :organization_status, + options_for_select( + [ [ "Linked", "linked" ], [ "Pending", "pending" ], [ "None", "none" ] ], + params[:organization_status] + ), + include_blank: "All", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ + <% if @event.cost_cents.to_i > 0 %> +
+ <%= label_tag :scholarship, "Scholarship", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :scholarship, + options_for_select( + [ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ], + params[:scholarship] + ), + include_blank: "All registrants", class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", - placeholder: "Name, email, phone, city, or organization" %> - -
-
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
-
- <%= label_tag :attendance_status, "Attendance status", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :attendance_status, - options_for_select( - EventRegistration::ATTENDANCE_STATUSES.map { |s| [EventRegistration.new(status: s).attendance_status_label, s] }, - params[:attendance_status] - ), - include_blank: "All statuses", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
+
+ <%= label_tag :payment_status, "Payment", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :payment_status, + options_for_select( + [ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ], + params[:payment_status] + ), + include_blank: "Any payment status", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ <% end %> - <% if @event.cost_cents.to_i > 0 %> -
- <%= label_tag :payment_status, "Payment", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :payment_status, - options_for_select( - [ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ], - params[:payment_status] - ), - include_blank: "Any payment status", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm +
+ <%= label_tag :attendance_status, "Attendance status", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :attendance_status, + options_for_select( + EventRegistration::ATTENDANCE_STATUSES.map { |s| [EventRegistration.new(status: s).attendance_status_label, s] }, + params[:attendance_status] + ), + include_blank: "All statuses", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
-
- <%= label_tag :scholarship, "Scholarship", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :scholarship, - options_for_select( - [ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ], - params[:scholarship] - ), - include_blank: "All registrants", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
- <% end %> + <%# Row 2: state, locality, county, country %> +
+ <% if @dashboard.states.any? %> +
+ <%= label_tag :state, "State", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :state, + options_for_select(@dashboard.states, params[:state]), + include_blank: "All states", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ <% end %> - <% if @dashboard.states.any? %> -
- <%= label_tag :state, "State", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :state, - options_for_select(@dashboard.states, params[:state]), - include_blank: "All states", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
- <% end %> + <% if @dashboard.localities.any? %> +
+ <%= label_tag :locality, "Locality", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :locality, + options_for_select(@dashboard.localities, params[:locality]), + include_blank: "All localities", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ <% end %> - <% if @dashboard.counties.any? %> -
- <%= label_tag :county, "County", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :county, - options_for_select( - @dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] }, - params[:county] - ), - include_blank: "All counties", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> + <% if @dashboard.counties.any? %> +
+ <%= label_tag :county, "County", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :county, + options_for_select( + @dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] }, + params[:county] + ), + include_blank: "All counties", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ <% end %> + + <% if @dashboard.countries.any? %> +
+ <%= label_tag :country, "Country", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :country, + options_for_select(@dashboard.countries, params[:country]), + include_blank: "All countries", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +
+ <% end %> + +
+ <%= link_to "Clear filters", registrants_event_path(@event), + class: "btn btn-utility-outline", + data: { action: "collection#clearAndSubmit" } %> +
<% end %> - -
- <%= link_to "Clear filters", registrants_event_path(@event), - class: "btn btn-utility-outline", - data: { action: "collection#clearAndSubmit" } %> -
-<% end %> +
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 9fdc1ef1b8..988ff43984 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -348,6 +348,35 @@ end end + describe ".registrant_locality scope" do + it "filters to registrations whose registrant has an active address in the locality" do + match = create(:event_registration) + create(:address, addressable: match.registrant, locality: "LA City", inactive: false) + other = create(:event_registration) + create(:address, addressable: other.registrant, locality: "Northern CA", inactive: false) + + expect(EventRegistration.registrant_locality("LA City")).to contain_exactly(match) + end + + it "ignores inactive addresses" do + reg = create(:event_registration) + create(:address, addressable: reg.registrant, locality: "LA City", inactive: true) + + expect(EventRegistration.registrant_locality("LA City")).to be_empty + end + end + + describe ".registrant_country scope" do + it "filters to registrations whose registrant has an active address in the country" do + match = create(:event_registration) + create(:address, addressable: match.registrant, country: "Canada", inactive: false) + other = create(:event_registration) + create(:address, addressable: other.registrant, country: "Mexico", inactive: false) + + expect(EventRegistration.registrant_country("Canada")).to contain_exactly(match) + end + end + describe "#paid_in_full?" do let(:event) { create(:event, cost_cents: 1000) } let(:user) { create(:user, :with_person) } diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 6366abd982..6bd0528771 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -138,6 +138,50 @@ end end + describe "GET /registrants with organization_status filter" do + let(:form) { create(:form) } + let!(:event_form) { create(:event_form, :registration, event: event, form: form) } + let!(:agency_field) { create(:form_field, form: form, field_identifier: "agency_name") } + + let(:linked_person) { create(:person, first_name: "Linny", last_name: "Linked") } + let(:pending_person) { create(:person, first_name: "Penny", last_name: "Pending") } + let(:none_person) { create(:person, first_name: "Nora", last_name: "None") } + + before do + linked = create(:event_registration, event: event, registrant: linked_person) + create(:event_registration_organization, event_registration: linked, organization: create(:organization, name: "Acme Org")) + + create(:event_registration, event: event, registrant: pending_person) + submission = create(:form_submission, person: pending_person, form: form) + create(:form_answer, form_submission: submission, form_field: agency_field, submitted_answer: "Unlinked Agency") + + create(:event_registration, event: event, registrant: none_person) + + sign_in admin + end + + it "shows only linked registrants when filtering by linked" do + get registrants_event_path(event, organization_status: "linked") + expect(response.body).to include("Linny") + expect(response.body).not_to include("Penny") + expect(response.body).not_to include("Nora None") + end + + it "shows only pending registrants when filtering by pending" do + get registrants_event_path(event, organization_status: "pending") + expect(response.body).to include("Penny") + expect(response.body).not_to include("Linny") + expect(response.body).not_to include("Nora None") + end + + it "shows only unresolved registrants when filtering by none" do + get registrants_event_path(event, organization_status: "none") + expect(response.body).to include("Nora None") + expect(response.body).not_to include("Linny") + expect(response.body).not_to include("Penny") + end + end + describe "GET /details" do let(:event) { create(:event, :published, :publicly_visible) } @@ -581,15 +625,15 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include(">Pending<") - expect(response.body).not_to include(">None<") + expect(response.body).to include("Pending") + expect(response.body).not_to include("None") end it "shows a 'None' chip when a registrant has no linked org and submitted nothing" do get registrants_event_path(event) - expect(response.body).to include(">None<") - expect(response.body).not_to include(">Pending<") + expect(response.body).to include("None") + expect(response.body).not_to include("Pending") end it "shows the linked org AND a 'Pending' chip when the submitted name is not among the linked orgs" do @@ -599,7 +643,7 @@ def submit_agency_name(name) get registrants_event_path(event) expect(response.body).to include(organization.name) - expect(response.body).to include(">Pending<") + expect(response.body).to include("Pending") end it "does not show 'Pending' when the submitted name matches a linked org" do @@ -609,7 +653,7 @@ def submit_agency_name(name) get registrants_event_path(event) expect(response.body).to include(organization.name) - expect(response.body).not_to include(">Pending<") + expect(response.body).not_to include("Pending") end end diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 1c3d2a03e6..81e2a1b031 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -350,6 +350,16 @@ end end + describe "localities" do + it "lists distinct localities from active registrants' active addresses, sorted and excluding inactive" do + person1.addresses.first.update!(locality: "Northern CA") + person2.addresses.where(inactive: false).first.update!(locality: "LA City") + person2.addresses.where(inactive: true).first.update!(locality: "Southern CA") + + expect(dashboard.localities).to eq([ "LA City", "Northern CA" ]) + end + end + describe "countries" do it "returns the registrant ids that have a country on file, excluding inactive and cancelled" do expect(dashboard.country_registrant_ids).to contain_exactly(person1.id, person2.id) diff --git a/spec/services/event_organization_link_status_spec.rb b/spec/services/event_organization_link_status_spec.rb new file mode 100644 index 0000000000..d01a861456 --- /dev/null +++ b/spec/services/event_organization_link_status_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe EventOrganizationLinkStatus do + let(:event) { create(:event) } + let(:form) { create(:form) } + let!(:event_form) { create(:event_form, :registration, event: event, form: form) } + let!(:agency_field) { create(:form_field, form: form, field_identifier: "agency_name") } + + def submit_agency_name(person, name) + submission = create(:form_submission, person: person, form: form) + create(:form_answer, form_submission: submission, form_field: agency_field, submitted_answer: name) + end + + describe "#registration_ids_for" do + it "classifies linked, pending, and none registrations" do + linked = create(:event_registration, event: event) + organization = create(:organization, name: "Acme Org") + create(:event_registration_organization, event_registration: linked, organization: organization) + + pending = create(:event_registration, event: event) + submit_agency_name(pending.registrant, "Unlinked Agency") + + none = create(:event_registration, event: event) + + service = described_class.new(event) + + expect(service.registration_ids_for("linked")).to contain_exactly(linked.id) + expect(service.registration_ids_for("pending")).to contain_exactly(pending.id) + expect(service.registration_ids_for("none")).to contain_exactly(none.id) + end + + it "treats a submitted name matching a linked organization as resolved (not pending)" do + registration = create(:event_registration, event: event) + organization = create(:organization, name: "Acme Org") + create(:event_registration_organization, event_registration: registration, organization: organization) + submit_agency_name(registration.registrant, "acme org") + + service = described_class.new(event) + + expect(service.registration_ids_for("linked")).to contain_exactly(registration.id) + expect(service.registration_ids_for("pending")).to be_empty + end + + it "lists a registration that is both linked and pending under each status" do + registration = create(:event_registration, event: event) + organization = create(:organization, name: "Acme Org") + create(:event_registration_organization, event_registration: registration, organization: organization) + submit_agency_name(registration.registrant, "A Different Agency") + + service = described_class.new(event) + + expect(service.registration_ids_for("linked")).to contain_exactly(registration.id) + expect(service.registration_ids_for("pending")).to contain_exactly(registration.id) + expect(service.registration_ids_for("none")).to be_empty + end + + it "returns an empty array for an unrecognized status" do + create(:event_registration, event: event) + + expect(described_class.new(event).registration_ids_for("bogus")).to eq([]) + end + end +end