diff --git a/AGENTS.md b/AGENTS.md index a2b06ae67b..09a0aee7c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,8 +236,9 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve - `EventProgramStatusReport` — Cross-event program-status report grouped by calendar year: how many organizations were New / Ongoing / Reinstate at each facilitator training, counted both as organization-trainings (summed rows) and as distinct organizations classified at their earliest training. Sibling of `EventRevenueReport`/`EventParticipationReport`/`EventScholarshipReport` (includes `ReportPeriods`); powers the `events#program_statuses` page and the reports-hub program-status card - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` - `AttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` — the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope — so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see -- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance, `as_of:` = that event's start date so program status reads the same verdict its dashboard does). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries -- `AttendeesActiveFilters` — Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, org city, age group / life experience / setting categories, country, school district, scholarship, CE, payment status, scholarship funding source, sector, state) — params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all — `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients +- `PersonLocations` — Where each person is on a date (`on:`, default today) — where they work, else where they live: every affiliation active on that date (`Affiliation.active_by_date_on`, or `.active` for today), at its picked address (`Affiliation#organization_address`), else its org's oldest active address — every active affiliation counts; with none, the one address that stands for the person themselves (`Address.ranked_for_owner` / `Address::STANDING` — active work, else latest-edited active personal, else inactive personal, else inactive work; work and personal swap below the active line because a current work address is the sharpest thing we know and a retired one the stalest. Written once in Ruby and compiled into the ORDER BY; `AddressDecorator#saved_address` sorts a loaded association by the same list. Retired addresses count, because placing someone by a stale address beats leaving them off the map). Each row carries every `LOCATION_FIELDS` value the address holds — city, county, state, country, locality, zip, school district and the three LA districts, i.e. everything bar the street and phone — so a new chart, count or filter needs only a field name: `#registrant_ids_by(field)` groups ids by any of them (`#registrant_ids_by_state` is US-only for the map, `#registrant_ids_by_city_label` keys on "City, State"), and `#matching(**fields)` tests ONE address per row against the whole filter set at once — chaining two calls is what lets someone working in two places answer a city from one and a county from the other. `.filters_from(**params)` folds a County option's own state ("CA|Los Angeles" on the roster, "CA::Los Angeles" on the attendees index) into `state:` and returns nil when a State filter contradicts it. `#candidate_values(*fields)` answers filter option lists straight from the org/registrant address tables (two grouped queries, a superset) so a whole-population dropdown never builds a row per person. Takes a person-id relation (kept as a subquery, so a whole-population lookup never inlines every id) or an explicit id array. Event pages pass the event's start date so a past event doesn't move when someone changes jobs; cross-event pages and reports use today. The single source for every person location figure and filter: the charts' cities, states, and countries (`EventDashboard` on the event date, `AttendeesBreakdowns` on `as_of`), the roster/reminder City/State/County filters (`EventRegistration.located_in(on:, ...)`, which takes them all at once, and `ReminderRecipientFilter`), the attendees index's City/State/County filters and `country`/`org_city` drill-ins (the filtered event's date, else today), and `EventParticipationReport#demographics` (today) +- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set — tags from profiles, cities/states/countries from `PersonLocations` — for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance, `as_of:` = that event's start date so program status reads the same verdict its dashboard does). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries +- `AttendeesActiveFilters` — Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, city, age group / life experience / setting categories, country, school district, scholarship, CE, payment status, scholarship funding source, sector, state) — params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all — `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards - `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the reports-hub scholarship summary card - `EventAttendanceReport` — Per-event attendance sign-in/out report from `EventAttendanceTimeEntry`, grouped by day then reported line, with per-day and grand-total minutes; `ce_only:` scopes to CE registrants and surfaces license number + awarded hours. The reported unit is a `Row` (registration + optional `ContinuingEducationRegistration`), so a registrant claiming CE against two licences gets a line each, keyed by `Row#key` — which is what `attendance_cell_id` and the editor's `row` param use. The times behind two lines are shared, so the everyone-totals dedupe by registration while hours awarded sum per line. The in-portal CE hour sign-in sheet, linked from the participation report (`?ce=true`) at `attendance_event_path` @@ -288,7 +289,7 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve - `DisplayImagePresenter` — Image display logic - `DeployedRevision` — The deployed commit from `GIT_SHA` (the DigitalOcean web service maps it to `${web.COMMIT_HASH}`); App settings shows a muted `build ` link to GitHub's compare view of everything merged to `main` since. In development it falls back to the checked-out commit; nil, and no link, when unset elsewhere - `ScholarshipsGrouping` (presenter) — Groups scholarships into the index's funder → grant → recipient hierarchy; grant-free awards collect under a trailing "Unfunded" group -- `RegistrantCityBreakdown` (presenter) — Groups an event's registrants by the city of the org linked on their registration, counting registrants + scholarship recipients per city; drives the shared "Registrants by city" card inside `events/_registrant_breakdowns` on all three people-pages — per-event roster, cross-event attendees index, and scholarship recipients (fed plucked data by `EventDashboard` or `AttendeesBreakdowns`) +- `RegistrantCityBreakdown` (presenter) — Groups an event's registrants by their location's city (`PersonLocations` — work, else home), counting registrants + scholarship recipients per city; drives the shared "Registrants by city" card inside `events/_registrant_breakdowns` on all three people-pages — per-event roster, cross-event attendees index, and scholarship recipients (fed plucked data by `EventDashboard` or `AttendeesBreakdowns`) - `AllocationLedgerLabel` (presenter) — Shared payment-method/label + check-number labelling for an allocation, used by the invoice and receipt ledgers so they can't drift - `InvoiceIssuer` (presenter) — Single source of truth for the issuing org's invoice/receipt header (name, address, email, tax id, payable-to note), shared by `EventInvoice`, `EventReceipt`, and `InvoicePresenter`. Name and tax id come from `Setting.app_organization`; the addresses and email are app-wide settings. `#address_lines` / `#remittance_address_lines` delegate to `Setting`, which reads the `Address` the row points at (`return_address` / `remittance_address`) and otherwise falls back to the app org's oldest address flagged for invoices (`Address.display_lines_for`); there are no hard-coded address literals. The email is `Setting.info_email`. The header carries the return address; `#remittance_address_lines` is the separate address cheques are mailed to, printed under the payable-to line on both invoices and on the pay-by-check callout diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index bc70710f9c..97cf0591d4 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -244,12 +244,10 @@ def registrants scope = scope.comment_text(params[:comment]) if params[:comment].present? scope = scope.funder_name(params[:funder_name]) if params[:funder_name].present? scope = scope.submission_status(params[:submission_status], @event) if params[:submission_status].present? - scope = scope.registrant_city(params[:city]) if params[:city].present? + scope = scope.located_in(on: @event.start_date, state: params[:state], county: params[:county], city: params[:city]) scope = scope.organization_linking_status(params[:org_status], @event) if params[:org_status].present? scope = scope.account_status(params[:account_status]) if params[:account_status].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_county(params[:county]) if params[:county].present? scope = scope.registrant_sector(params[:sector]) if params[:sector].present? scope = scope.registered_between(params[:registered_from], params[:registered_to]) @@ -714,18 +712,16 @@ def filtered_roster_registrants end # Person ids behind the clicked breakdown row. Most dimensions arrive as an - # explicit id list (EventDashboard already has the maps); sector and state have - # no per-value id map, so they arrive as named filters and resolve here — bounded - # to `registrant_ids` (this event's roster), since the result is only ever + # explicit id list (EventDashboard already has the maps); sector has no per-value + # id map, so it arrives as a named filter and resolves here — bounded to + # `registrant_ids` (this event's roster), since the result is only ever # intersected with it and the unbounded lookup would load every person in the app - # tagged with that sector or living in that state. + # tagged with that sector. def roster_drill_in_ids(registrant_ids) if params[:registrant_ids].present? params[:registrant_ids].to_s.split("-").map(&:to_i).to_set elsif params[:sector].present? person_sector_ids(params[:sector]).where(sectorable_id: registrant_ids).pluck(:sectorable_id).to_set - elsif params[:state].present? - person_address_ids(state: params[:state]).where(addressable_id: registrant_ids).pluck(:addressable_id).to_set end end @@ -855,11 +851,10 @@ def filtered_attendees # Breakdown drill-in filters (set when a chart row is clicked). scope = scope.where(id: person_category_ids(params[:age_group])) if params[:age_group].present? scope = scope.where(id: person_category_ids(params[:life_experience])) if params[:life_experience].present? - scope = scope.where(id: person_country_ids(params[:country])) if params[:country].present? scope = scope.where(id: person_school_district_ids(params[:school_district])) if params[:school_district].present? scope = scope.where(id: person_program_status_ids(params[:program_status])) if params[:program_status].present? scope = scope.where(id: person_linked_organization_ids(params[:organization_id])) if params[:organization_id].present? - scope = scope.where(id: person_linked_org_city_ids(params[:org_city])) if params[:org_city].present? + scope = scope.where(id: attendee_locations.registrant_ids_by_city_label.fetch(params[:org_city], [])) if params[:org_city].present? if params[:scholarship].present? # Scoped to one event, every status (including "no") is unambiguous at the # registration level, so reuse the shared scope. Cross-event, "No scholarship" @@ -871,19 +866,18 @@ def filtered_attendees scope.where(id: scholarship_status_person_ids(params[:scholarship])) end end - # Address filters read the person's own address data directly (no registration - # required) — city is a free-text match, state/county exact. City and state ask - # one subquery so they describe the same address, matching the roster's shared - # join; two subqueries would let someone with homes in two states through on a - # city from one and a state from the other. - if params[:state].present? || params[:city].present? - scope = scope.where(id: person_address_ids(state: params[:state], city: params[:city])) - end - if params[:county].present? - # County options carry their state ("STATE::County") so same-named counties - # across states don't collide. - county_state, county_name = params[:county].split("::", 2) - scope = scope.where(id: person_address_ids(state: county_state, county: county_name)) + # Where people are (PersonLocations) — on the filtered event's date, else today. The + # City box, the State and County dropdowns and the countries drill-in resolve in + # one pass, so they all describe the same address: someone working in two states + # can't match a city from one and the county from the other. A State that + # contradicts the county's own state matches nobody. + location_filters = PersonLocations.filters_from( + state: params[:state], county: params[:county], city: params[:city], country: params[:country] + ) + if location_filters.nil? + scope = scope.none + elsif location_filters.any? + scope = scope.where(id: attendee_locations.matching(**location_filters).map(&:registrant_id)) end # Shared registration-level filters, applied cross-event with "any registration @@ -950,10 +944,6 @@ def person_category_ids(category_id) CategorizableItem.where(categorizable_type: "Person", category_id: category_id).select(:categorizable_id) end - def person_country_ids(country) - Address.active.where(addressable_type: "Person", country: country).select(:addressable_id) - end - # People whose event-registration-linked affiliation points at an organization # address in the given school district (Affiliation#organization_address → # Address#district), scoped to the in-scope registrations — matching the @@ -974,26 +964,10 @@ def person_linked_organization_ids(organization_id) .select(Arel.sql("event_registrations.registrant_id")) end - # People whose linked training org sits in the given "City, State" (matched on - # each org's first active address, mirroring the cities breakdown's labeling). - def person_linked_org_city_ids(city_label) - org_ids = org_ids_by_city_label[city_label] || [] - return Person.none if org_ids.empty? - person_linked_organization_ids(org_ids) - end - - def org_ids_by_city_label - @org_ids_by_city_label ||= Address.active - .where(addressable_type: "Organization", - addressable_id: EventRegistrationOrganization.where(event_registration_id: attendee_registrations.select(:id)).select(:organization_id)) - .order(:id) - .pluck(:addressable_id, :city, :state) - .each_with_object({}) do |(org_id, city, state), first_label| - next if first_label.key?(org_id) - first_label[org_id] = [ city, state ].compact_blank.join(", ").presence - end - .group_by { |_org_id, label| label } - .transform_values { |pairs| pairs.map(&:first) } + # Where each in-scope registrant is, behind the location filters and drill-ins — + # the same source as the breakdowns they come from. + def attendee_locations + @attendee_locations ||= PersonLocations.new(attendee_registrations.select(:registrant_id), on: @filter_event&.start_date) end # Anchored the same way the index's own column is: this list spans events, so @@ -1031,21 +1005,6 @@ def person_affiliation_status_ids(status) Affiliation.with_status(status).select(:person_id) end - # Person ids behind active person addresses, narrowed by any of state, county or - # city. City is a free-text LIKE (matching the roster's registrant_city filter), - # state/county are exact. Street isn't a filter anywhere yet, but the column - # exists — add a `street:` clause here the same way if one is ever needed. - def person_address_ids(state: nil, county: nil, city: nil) - scope = Address.active.where(addressable_type: "Person") - scope = scope.where(state: state) if state.present? - scope = scope.where(county: county) if county.present? - if city.present? - like = "%#{Address.sanitize_sql_like(city.downcase.strip)}%" - scope = scope.where("LOWER(addresses.city) LIKE ?", like) - end - scope.select(:addressable_id) - end - # Option lists for the attendees index filter selects, built once per full page # load from the viewer's WHOLE reportable population — deliberately un-narrowed by # the active filters, so filtering never removes the option that would undo it. @@ -1054,9 +1013,9 @@ def set_attendee_filter_options @attendee_years = @attendee_event_options.filter_map { |event| event.start_date&.year }.uniq.sort.reverse person_ids = Person.where(id: EventRegistration.where(event_id: reportable_events.select(:id)).select(:registrant_id)) @attendee_sectors = Sector.where(id: SectorableItem.where(sectorable_type: "Person", sectorable_id: person_ids).select(:sector_id)).order(:name) - addresses = Address.active.where(addressable_type: "Person", addressable_id: person_ids) - @attendee_states = addresses.where.not(state: [ nil, "" ]).distinct.pluck(:state).sort - @attendee_counties = addresses.where.not(county: [ nil, "" ]).where.not(state: [ nil, "" ]).distinct.pluck(:state, :county).sort + locations = PersonLocations.new(EventRegistration.where(event_id: reportable_events.select(:id)).select(:registrant_id)) + @attendee_states = locations.candidate_values(:state) + @attendee_counties = locations.candidate_values(:state, :county) @attendee_topic_types = TopicSubscriptionType.active.ordered end diff --git a/app/decorators/address_decorator.rb b/app/decorators/address_decorator.rb index 98a0190238..7f3b4e2860 100644 --- a/app/decorators/address_decorator.rb +++ b/app/decorators/address_decorator.rb @@ -56,10 +56,13 @@ def default_locality # The saved address a new one copies: an active address before a retired one, and the # most recently updated of those. + # The one address that stands for this owner (Address::STANDING), sorted in + # memory off the loaded association so a form with unsaved blocks pays no query + # — the same order Address.ranked_for_owner gives a relation. def saved_address return @saved_address if defined?(@saved_address) saved = addressable&.addresses&.select(&:persisted?) || [] - @saved_address = saved.min_by { |address| [ address.inactive? ? 1 : 0, -address.updated_at.to_f ] } + @saved_address = saved.min_by { |address| [ address.standing, -address.updated_at.to_f ] } end end diff --git a/app/models/address.rb b/app/models/address.rb index f6bda0fce7..0f26e17564 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -51,6 +51,40 @@ class Address < ApplicationRecord scope :active, -> { where(inactive: false) } scope :for_invoices, -> { where(invoice_address: true) } + # Which of an owner's addresses stands for them when only one can, best first: + # [ inactive?, address_type ] — a nil type matches whatever is left at that + # active/inactive level. Work and personal swap places below the active line on + # purpose: a current work address is the sharpest thing we know about someone, + # and a retired one the stalest (people change jobs; home outlasts the job). A + # retired address still beats placing them nowhere. + # + # Written once, in Ruby, because both the ORDER BY and Address#standing read it — + # a hand-kept SQL twin of this list would drift the first time an order changed. + STANDING = [ + [ false, "work" ], + [ false, "personal" ], + [ false, nil ], + [ true, "personal" ], + [ true, "work" ] + ].freeze + + # Interpolated, not bound: every value comes from STANDING above, never a caller. + STANDING_SQL = STANDING.map.with_index { |(inactive, type), rank| + tests = [ "addresses.inactive = #{inactive ? 1 : 0}" ] + tests << "addresses.address_type = '#{type}'" if type + "WHEN #{tests.join(' AND ')} THEN #{rank}" + }.join(" ").then { |whens| "CASE #{whens} ELSE #{STANDING.size} END" }.freeze + + # Best-standing first, then most recently edited — "latest edited" is how two + # addresses at the same standing are told apart. + scope :ranked_for_owner, -> { order(Arel.sql(STANDING_SQL), updated_at: :desc, id: :asc) } + + # This address's place in STANDING, for sorting an already-loaded set the same + # way the scope sorts a query. + def standing + STANDING.index { |rank_inactive, type| rank_inactive == inactive? && (type.nil? || type == address_type) } || STANDING.size + end + # A nested-attributes block nobody filled in, which the parent drops instead of # building an address that can only fail validation. def self.unfilled_attributes?(attributes) diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index e12b2e9cf1..ef85353fa4 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -220,27 +220,15 @@ class EventRegistration < ApplicationRecord end scope } - scope :registrant_state, ->(state) { - joins(registrant: :addresses) - .where(addresses: { inactive: false, state: state }) - .distinct - } - # Accepts "STATE|County" (from the manage filter) to scope to a county within - # a specific state, or a bare county name for backward compatibility. - scope :registrant_county, ->(value) { - state, county = value.to_s.include?("|") ? value.split("|", 2) : [ nil, value.to_s ] - next none if county.blank? - conditions = { inactive: false, county: county } - conditions[:state] = state if state.present? - joins(registrant: :addresses).where(addresses: conditions).distinct - } - scope :registrant_city, ->(term) { - return all if term.blank? - like = "%#{sanitize_sql_like(term.downcase.strip)}%" - joins(registrant: :addresses) - .where("LOWER(addresses.city) LIKE ?", like) - .where(addresses: { inactive: false }) - .distinct + # Where the registrant is on `on` (the event's start date) — work, else home + # (PersonLocations). Takes any of its LOCATION_FIELDS, and they arrive together + # in one call so they test the same location row: a registrant working in two + # states would otherwise match a city from one and the state from the other. + scope :located_in, ->(on: nil, **filters) { + resolved = PersonLocations.filters_from(**filters) + next none if resolved.nil? + next all if resolved.empty? + where(registrant_id: PersonLocations.new(all.select(:registrant_id), on:).matching(**resolved).map(&:registrant_id)) } scope :registrant_sector, ->(sector_id) { joins(registrant: :sectorable_items) diff --git a/app/presenters/registrant_city_breakdown.rb b/app/presenters/registrant_city_breakdown.rb index 14514f9394..953b58913f 100644 --- a/app/presenters/registrant_city_breakdown.rb +++ b/app/presenters/registrant_city_breakdown.rb @@ -1,24 +1,21 @@ -# Groups an event's registrants by the city of the organization linked on their -# registration, counting distinct registrants and scholarship recipients per -# city. Powers the shared "Registrants by city" breakdown on the event +# Groups an event's registrants by their location's city (PersonLocations), +# counting distinct registrants and scholarship recipients per city. Powers the shared "Registrants by city" breakdown on the event # background dashboard and the scholarship-recipients page. Pure/in-memory so # EventDashboard feeds it already-plucked data (see [[event_dashboard]]). # -# A registrant linked to orgs in two cities counts under each; the "Unknown" -# bucket (a linked org with no city on file) always sorts last. +# A registrant working in two cities counts under each; the "Unknown" +# bucket (an address with no city) always sorts last. class RegistrantCityBreakdown UNKNOWN_LABEL = "Unknown".freeze Row = Struct.new(:city, :registrant_count, :scholarship_count, :registrant_ids, :scholarship_recipient_ids, keyword_init: true) - # org_registrant_pairs: [ [ organization_id, person_id ], ... ], one per - # organization linked on a registration. - # city_by_org: { organization_id => "City, State" }; a missing - # entry means that org has no city ⇒ Unknown. + # city_registrant_pairs: [ [ "City, State", person_id ], ... ], one per + # organization linked on a registration; a nil + # city ⇒ Unknown. # scholarship_recipient_ids: person ids awarded a scholarship this event. - def initialize(org_registrant_pairs:, city_by_org:, scholarship_recipient_ids:) - @org_registrant_pairs = org_registrant_pairs - @city_by_org = city_by_org + def initialize(city_registrant_pairs:, scholarship_recipient_ids:) + @city_registrant_pairs = city_registrant_pairs @scholarship_recipient_ids = scholarship_recipient_ids.to_set end @@ -49,9 +46,8 @@ def any? private def registrant_ids_by_city - @registrant_ids_by_city ||= @org_registrant_pairs.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |(org_id, person_id), map| - city = @city_by_org[org_id].presence || UNKNOWN_LABEL - map[city] << person_id + @registrant_ids_by_city ||= @city_registrant_pairs.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |(city, person_id), map| + map[city.presence || UNKNOWN_LABEL] << person_id end end end diff --git a/app/services/attendees_active_filters.rb b/app/services/attendees_active_filters.rb index ad6d86d185..df5982ef52 100644 --- a/app/services/attendees_active_filters.rb +++ b/app/services/attendees_active_filters.rb @@ -19,8 +19,8 @@ class AttendeesActiveFilters country school_district payment_status funder ].freeze # The roster has no filter form at all, so every drill-in it accepts needs a - # chip — including the two the index leaves out because it has controls for them. - ROSTER_CHIP_PARAMS = %w[ registrant_ids sector state ].freeze + # chip — including sector, which the index leaves out because it has a control for it. + ROSTER_CHIP_PARAMS = %w[ registrant_ids sector ].freeze def initialize(params, chip_params: CHIP_PARAMS) @params = params @@ -45,9 +45,8 @@ def label_for(param, value) case param when "registrant_ids" then "#{value.to_s.split("-").size} selected people" when "sector" then record_label("Sector", Sector, value) - when "state" then "State: #{value}" when "organization_id" then record_label("Organization", Organization, value) - when "org_city" then "Org city: #{value}" + when "org_city" then "City: #{value}" when "age_group" then record_label("Age group", Category, value) when "life_experience" then record_label("Life experience", Category, value) when "country" then "Country: #{value}" diff --git a/app/services/attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb index 58d5222dbe..8e2d3c6f3d 100644 --- a/app/services/attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -3,8 +3,8 @@ # whole filtered attendee population (not one page). Method names/return shapes # match EventDashboard so the shared _registrant_breakdowns partial reads either. # -# Sourced from each person's profile (sectors, age groups, categories, addresses) -# and from the orgs/scholarships/CE tied to their attended-training registrations. +# Sourced from each person's profile (sectors, age groups, categories) and from the +# orgs, locations, scholarships, and CE tied to their attended-training registrations. class AttendeesBreakdowns # events: the events whose registrations may be counted (for the attendees index, # the viewer's reportable events narrowed by the active filters), so the @@ -140,13 +140,12 @@ def ce_registrant_ids # --- Cities ---------------------------------------------------------------- - # Attendees grouped by the city of the organization linked on their attended- - # training registrations, with scholarship recipients per city — the shared + # Attendees grouped by their location's city on their attended-training + # registrations, with scholarship recipients per city — the shared # "Registrants by city" breakdown, aggregated across all trainings. def registrant_city_breakdown @registrant_city_breakdown ||= RegistrantCityBreakdown.new( - org_registrant_pairs: org_registrant_pairs, - city_by_org: city_by_organization, + city_registrant_pairs: locations.rows.map { |row| [ row.city_label, row.registrant_id ] }, scholarship_recipient_ids: scholarship_recipient_ids ) end @@ -181,11 +180,11 @@ def life_experience_registrant_ids_by_category end def state_registrant_ids_by_state - @state_registrant_ids_by_state ||= person_ids_by(address_rows.select { |_, state, _| Address::US_STATE_ABBREVIATIONS.include?(state.to_s.upcase) }) { |row| row[1] } + locations.registrant_ids_by_state end def country_registrant_ids_by_country - @country_registrant_ids_by_country ||= person_ids_by(address_rows.reject { |_, _, country| country.blank? }) { |row| row[2] } + locations.registrant_ids_by(:country) end def school_district_registrant_ids_by_district @@ -241,10 +240,12 @@ def person_ids @person_ids ||= @people.except(:includes, :eager_load, :preload, :order).ids end + def scoped_registrations + @scoped_registrations ||= @registrations.where(registrant_id: person_ids, event_id: @events.select(:id)) + end + def registration_ids - @registration_ids ||= @registrations - .where(registrant_id: person_ids, event_id: @events.select(:id)) - .pluck(:id) + @registration_ids ||= scoped_registrations.pluck(:id) end def sector_rows @@ -290,19 +291,13 @@ def category_rows(category_type_name) .pluck(:categorizable_id, :category_id) end - # [ [ person_id, state, country ], ... ] — one load behind the state and country - # counts and their id maps. - def address_rows - @address_rows ||= active_addresses.pluck(:addressable_id, :state, :country) - end - # { key => distinct person ids }, grouping [ person_id, ... ] rows by the block. def person_ids_by(rows) rows.group_by { |row| yield(row) }.transform_values { |grouped| grouped.map(&:first).uniq } end - def active_addresses - Address.active.where(addressable_type: "Person", addressable_id: person_ids) + def locations + @locations ||= PersonLocations.new(scoped_registrations.select(:registrant_id), on: @as_of) end def org_registrant_pairs @@ -347,22 +342,6 @@ def program_status_by_organization end end - # "City, State" per linked organization, from its first active address (mirrors - # EventDashboard#city_by_organization). Orgs with no active address are absent, - # so their registrants fall into the breakdown's Unknown bucket. - def city_by_organization - org_ids = org_registrant_pairs.map(&:first).uniq - Address.active - .where(addressable_type: "Organization", addressable_id: org_ids) - .order(:id) - .pluck(:addressable_id, :city, :state) - .each_with_object({}) do |(org_id, city, state), map| - next if map.key?(org_id) - label = [ city, state ].compact_blank.join(", ").presence - map[org_id] = label if label - end - end - def scholarship_recipient_ids @scholarship_recipient_ids ||= Scholarship .not_declined diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 7f78748c07..64a5606905 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -147,14 +147,12 @@ def scholarship_recipient_count recognized_scholarships.map(&:recipient_id).uniq.size end - # This event's registrants grouped by the city of the organization linked on - # their registration, with the scholarship-recipient count per city — the - # shared "Registrants by city" breakdown on the background and recipients - # pages. Fed already-plucked data so the rollup runs in memory. + # This event's registrants grouped by their location's city, with the + # scholarship-recipient count per city — the shared "Registrants by city" + # breakdown on the background and recipients pages. def registrant_city_breakdown @registrant_city_breakdown ||= RegistrantCityBreakdown.new( - org_registrant_pairs: registration_org_registrant_pairs, - city_by_org: city_by_organization, + city_registrant_pairs: locations.rows.map { |row| [ row.city_label, row.registrant_id ] }, scholarship_recipient_ids: scholarships.distinct.pluck(:recipient_id) ) end @@ -852,52 +850,34 @@ def referral_source_counts .sort_by { |label, count| [ -count, label ] } end - # US states only — international registrants' regions (e.g. provinces) are - # excluded so the States breakdown reflects domestic residents. + # The US states registrants are in on the event date — work, else home (PersonLocations). def states - @states ||= us_state_addresses.distinct.pluck(:state).sort + @states ||= state_registrant_ids_by_state.keys.sort end - # Distinct US-resident registrant count per state. def state_counts - @state_counts ||= us_state_addresses.group(:state).distinct.count(:addressable_id) + @state_counts ||= state_registrant_ids_by_state.transform_values(&:size) end - # Registrant ids that have at least one active US address with a state on file — - # i.e. the people behind the states count. def state_registrant_ids - @state_registrant_ids ||= us_state_addresses.distinct.pluck(:addressable_id) + @state_registrant_ids ||= state_registrant_ids_by_state.values.flatten.uniq end + def state_registrant_ids_by_state + locations.registrant_ids_by_state + end + + # The countries registrants are in (PersonLocations). def countries - @countries ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(country: [ nil, "" ]) - .distinct - .pluck(:country) - .sort + @countries ||= country_registrant_ids_by_country.keys.sort end - # Distinct registrant count per country. def country_counts - @country_counts ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(country: [ nil, "" ]) - .group(:country) - .distinct - .count(:addressable_id) + @country_counts ||= country_registrant_ids_by_country.transform_values(&:size) end - # Registrant ids that have at least one active address with a country on file. def country_registrant_ids - @country_registrant_ids ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(country: [ nil, "" ]) - .distinct - .pluck(:addressable_id) + @country_registrant_ids ||= country_registrant_ids_by_country.values.flatten.uniq end # School districts across active registrants' affiliation organization addresses @@ -916,17 +896,8 @@ def school_district_registrant_ids @school_district_registrant_ids ||= affiliation_district_pairs.map(&:last).uniq end - # Registrant ids per country, keyed by country name — the people behind each - # country row, for drilling into the matching registrant list. def country_registrant_ids_by_country - @country_registrant_ids_by_country ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(country: [ nil, "" ]) - .distinct - .pluck(:country, :addressable_id) - .group_by(&:first) - .transform_values { |rows| rows.map(&:last).uniq } + locations.registrant_ids_by(:country) end # Registrant ids per school district, for the breakdown's per-row drill-in. A @@ -939,19 +910,10 @@ def school_district_registrant_ids_by_district .transform_values(&:to_a) end - # Distinct [ state, county ] pairs across active registrants' active addresses, - # sorted by state then county. Pairing in the state lets the manage filter - # disambiguate same-named counties across states (e.g. "CA - Warren" vs - # "NY - Warren"). + # [ state, county ] pairs registrants are in. Pairing in the state lets the + # manage filter disambiguate same-named counties ("CA - Warren" vs "NY - Warren"). def counties - @counties ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(county: [ nil, "" ]) - .where.not(state: [ nil, "" ]) - .distinct - .pluck(:state, :county) - .sort + locations.counties end # Life experiences captured at registration: StoryPopulation categories tagged @@ -985,13 +947,8 @@ def life_experience_registrant_ids_by_category private - # Active registrant addresses whose state is a recognized US state/territory — - # the source for every States figure (count card, choropleth, and drill-in). - def us_state_addresses - Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where("UPPER(addresses.state) IN (?)", Address::US_STATE_ABBREVIATIONS) + def locations + @locations ||= PersonLocations.new(active_registrations.select(:registrant_id), on: reference_date) end # [ [ district, registrant_id ], ... ] from the affiliations linked to this event's @@ -1339,32 +1296,6 @@ def self_funded_grant_ids @self_funded_grant_ids ||= Grant.self_funded_ids end - # [ [ organization_id, registrant_id ], ... ] from the organizations linked on - # each active registration (the registration-time snapshot only, not later - # affiliations) — the basis for grouping registrants by their org's city. - def registration_org_registrant_pairs - @registration_org_registrant_pairs ||= EventRegistrationOrganization - .joins(:event_registration) - .where(event_registration_id: active_registration_ids) - .pluck(:organization_id, "event_registrations.registrant_id") - end - - # "City, State" per linked organization, from its first active address (mirrors - # Organization#program_location). Orgs with no active address are absent, so - # their registrants fall into the breakdown's Unknown bucket. - def city_by_organization - org_ids = registration_org_registrant_pairs.map(&:first).uniq - Address.active - .where(addressable_type: "Organization", addressable_id: org_ids) - .order(:id) - .pluck(:addressable_id, :city, :state) - .each_with_object({}) do |(org_id, city, state), map| - next if map.key?(org_id) - label = [ city, state ].compact_blank.join(", ").presence - map[org_id] = label if label - end - end - def paid_registration_ids @paid_registration_ids ||= billable_registration_ids.select do |id| allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i diff --git a/app/services/event_participation_report.rb b/app/services/event_participation_report.rb index 5739ebef5a..53cf853dd3 100644 --- a/app/services/event_participation_report.rb +++ b/app/services/event_participation_report.rb @@ -183,8 +183,8 @@ def prior_year # The reach of the scoped registrants: how many distinct organizations, # sectors, states, countries and cities they span, for a given calendar year # (nil = every year in scope). Read "via registrants", like the event - # dashboard's breakdown cards. States/countries come from registrant addresses; - # cities come from the linked organizations' addresses. + # dashboard's breakdown cards. States, countries, and cities are where each + # registrant is today (PersonLocations — work, else home). Demographics = Struct.new(:organizations, :sectors, :states, :countries, :cities, keyword_init: true) def demographics(year: nil) @@ -194,19 +194,13 @@ def demographics(year: nil) .where(event_registration_id: registrations.select(:id)) .distinct .pluck(:organization_id) - addresses = Address.active - .where(addressable_type: "Person", addressable_id: person_ids) - .pluck(:state, :country) + locations = PersonLocations.new(person_ids) Demographics.new( organizations: organization_ids.size, sectors: SectorableItem.where(sectorable_type: "Person", sectorable_id: person_ids).distinct.count(:sector_id), - states: addresses.map(&:first).reject(&:blank?).uniq.size, - countries: addresses.map(&:last).reject(&:blank?).uniq.size, - cities: Address.active - .where(addressable_type: "Organization", addressable_id: organization_ids) - .where.not(city: [ nil, "" ]) - .distinct - .count(:city) + states: locations.registrant_ids_by(:state).size, + countries: locations.registrant_ids_by(:country).size, + cities: locations.registrant_ids_by(:city).size ) end diff --git a/app/services/person_locations.rb b/app/services/person_locations.rb new file mode 100644 index 0000000000..57379ccf24 --- /dev/null +++ b/app/services/person_locations.rb @@ -0,0 +1,175 @@ +# Where each person is on a date — where they work, else where they live: every +# affiliation active on that date (today by default), at its picked address +# (Affiliation#organization_address), else its org's oldest active address; with +# none of those, the one address that stands for the person themselves +# (Address#ranked_for_owner — active work, else latest-edited active personal, +# else inactive personal, else inactive work). Event pages pass the event's start +# date so a past event doesn't move when someone changes jobs. The source for +# every person location chart, count, and filter. +class PersonLocations + # Everything an address says about *where* it is — the whole record bar the + # street and phone, which identify a building rather than place it. A row + # carries all of them so a new chart, count, or filter needs only a field name. + LOCATION_FIELDS = %i[ + city county state country locality zip_code district + la_city_council_district la_supervisorial_district la_service_planning_area + ].freeze + # Fields a filter matches on a case-insensitive substring, because they arrive + # from a free-text box. The rest are picked from a list this class built, so + # they match whole (case-insensitively, as the database's own collation does). + # Add a field here when it gets a text box of its own. + SEARCHED_FIELDS = %i[ city ].freeze + # County filter options carry their state so same-named counties across states + # don't collide — "CA|Los Angeles" on the registrants roster, "CA::Los Angeles" + # on the attendees index. + COUNTY_STATE_SEPARATOR = /::|\|/ + + Row = Struct.new(:organization_id, :registrant_id, *LOCATION_FIELDS, keyword_init: true) do + def city_label + [ city, state ].compact_blank.join(", ").presence + end + end + + # A page's raw location params as fields #matching understands: the county's + # own state folded into `state`, blanks dropped. Returns nil when nothing can + # satisfy the combination — the State dropdown naming one state and the County + # dropdown's own state another. + def self.filters_from(**filters) + county_state, county = split_county(filters[:county]) + return nil if filters[:county].present? && county.blank? + states = [ filters[:state], county_state ].compact_blank.uniq + return nil if states.many? + filters.merge(state: states.first, county: county).compact_blank + end + + def self.split_county(value) + state, county = value.to_s.split(COUNTY_STATE_SEPARATOR, 2) + county.nil? ? [ nil, state ] : [ state, county ] + end + + # Takes the people to locate, as a relation (kept as a subquery, so a + # whole-population lookup never inlines every id) or as an explicit id array. + def initialize(people, on: nil) + @person_ids = if people.is_a?(ActiveRecord::Relation) + people.except(:includes, :eager_load, :preload, :order, :limit, :offset) + else + people + end + @on = on + end + + def rows + @rows ||= begin + work = affiliation_triples.filter_map do |person_id, organization_id, address_id| + address = chosen_addresses[address_id] || oldest_address_by_organization[organization_id] + row(organization_id, person_id, address) if address + end + located = work.map(&:registrant_id).to_set + home = own_address_by_person.except(*located).map { |person_id, address| row(nil, person_id, address) } + (work + home).uniq + end + end + + # Rows whose one address answers to every field given — so a registrant working + # in two states can't match a city from one and the county from the other. Pass + # the whole filter set in one call; chaining two calls is what loses that. + def matching(**filters) + filters = filters.compact_blank + filters.assert_valid_keys(*LOCATION_FIELDS) + rows.select { |row| filters.all? { |field, value| matches?(row, field, value) } } + end + + # Distinct [ state, county ] pairs, sorted, for the County filters. + def counties + @counties ||= rows.filter_map { |row| [ row.state, row.county ] if row.state.present? && row.county.present? }.uniq.sort + end + + # { value => distinct registrant ids } for a location field — the shape every + # breakdown chart, count, and drill-in reads. Rows with no value sit it out. + def registrant_ids_by(field) + registrant_id_maps[field] ||= group_registrant_ids(rows.select { |row| row.public_send(field).present? }, &field) + end + + # US states only, so regions abroad stay off the States map. + def registrant_ids_by_state + @registrant_ids_by_state ||= group_registrant_ids(rows.select { |row| Address::US_STATE_ABBREVIATIONS.include?(row.state.to_s.upcase) }, &:state) + end + + def registrant_ids_by_city_label + @registrant_ids_by_city_label ||= group_registrant_ids(rows.select(&:city_label), &:city_label) + end + + # Distinct values of one or more location fields across every address these + # people could resolve to — their affiliated organizations' and their own — for + # a filter's option list. Two grouped queries rather than a row per person, so + # the attendees index can offer options drawn from its whole reportable + # population. It skips the work-else-home precedence #rows applies, so it can + # offer a value no one settles on; an option list is already deliberately wider + # than the active filters. + def candidate_values(*fields) + [ organization_addresses, person_addresses ].flat_map { |scope| + fields.reduce(scope) { |relation, field| relation.where.not(field => [ nil, "" ]) }.distinct.pluck(*fields) + }.uniq.sort + end + + private + + def matches?(row, field, value) + actual = row.public_send(field).to_s + return actual.downcase.include?(value.to_s.downcase.strip) if SEARCHED_FIELDS.include?(field) + actual.casecmp?(value.to_s) + end + + def registrant_id_maps + @registrant_id_maps ||= {} + end + + def group_registrant_ids(rows, &key) + rows.group_by(&key).transform_values { |grouped| grouped.map(&:registrant_id).uniq } + end + + def row(organization_id, registrant_id, address) + Row.new(organization_id:, registrant_id:, **LOCATION_FIELDS.index_with { |field| address&.public_send(field) }) + end + + def affiliations + scope = @on ? Affiliation.active_by_date_on(@on) : Affiliation.active + scope.where(person_id: @person_ids) + end + + def affiliation_triples + @affiliation_triples ||= affiliations.distinct.pluck(:person_id, :organization_id, :organization_address_id) + end + + def chosen_addresses + @chosen_addresses ||= Address.active + .where(id: affiliations.select(:organization_address_id)) + .select(:id, *LOCATION_FIELDS) + .index_by(&:id) + end + + def organization_addresses + Address.active.where(addressable_type: "Organization", addressable_id: affiliations.select(:organization_id)) + end + + # Not narrowed to active: a person with nothing but a retired address is better + # placed there than left out of the map entirely (Address#ranked_for_owner ranks + # retired addresses last). + def person_addresses + Address.where(addressable_type: "Person", addressable_id: @person_ids) + end + + def oldest_address_by_organization + @oldest_address_by_organization ||= organization_addresses + .order(:id) + .select(:id, :addressable_id, *LOCATION_FIELDS) + .each_with_object({}) { |address, map| map[address.addressable_id] ||= address } + end + + def own_address_by_person + @own_address_by_person ||= person_addresses + .ranked_for_owner + .select(:id, :addressable_id, :inactive, :address_type, :updated_at, *LOCATION_FIELDS) + .each_with_object({}) { |address, map| map[address.addressable_id] ||= address } + end +end diff --git a/app/services/reminder_recipient_filter.rb b/app/services/reminder_recipient_filter.rb index 495964d81a..68fced6467 100644 --- a/app/services/reminder_recipient_filter.rb +++ b/app/services/reminder_recipient_filter.rb @@ -10,7 +10,7 @@ class ReminderRecipientFilter # `funder_name` is a free-text search on the scholarship funder's name — kept # distinct from the awbw/external funding-source `funder` scope so the shared # roster/picker filter bar can't collide the two on the roster. - TEXT_KEYS = %i[ name reg_org funder_name comment email city ].freeze + TEXT_KEYS = %i[ name reg_org funder_name comment email ].freeze # Dropdown filters shared with the registrants roster. They reuse the # EventRegistration scopes (run once as a query) so both pages stay in sync — # same param names, options, and semantics. @@ -22,11 +22,14 @@ class ReminderRecipientFilter # keeps registrants whose person holds an active subscription to any of the chosen # topic subscription lists; matched in memory against the registrant's topic_subscriptions. PICKER_KEYS = %i[ topic_subscription ].freeze + # The City search plus the State and County dropdowns. Resolved as one group so + # they describe the same address, which is why city isn't a TEXT_KEY. + LOCATION_KEYS = %i[ city state county ].freeze # Registration date-range filters shared with the roster. Backed by the # EventRegistration.registered_between scope (run once as a query), so the same # range narrows both pages identically. DATE_KEYS = %i[ registered_from registered_to ].freeze - FILTER_KEYS = (TEXT_KEYS + DROPDOWN_KEYS + PICKER_KEYS + DATE_KEYS).freeze + FILTER_KEYS = (TEXT_KEYS + DROPDOWN_KEYS + PICKER_KEYS + LOCATION_KEYS + DATE_KEYS).uniq.freeze # Every key above that is also a registrants-roster param, so the roster's # "Send bulk emails" link can carry the active filters straight into the picker # (see EventHelper#reminder_recipient_filters). The remaining text keys (name, @@ -41,7 +44,7 @@ def initialize(event_registrations, params, event: nil) def matched_ids text_matched = @event_registrations.select { |reg| matches_text?(reg) }.map(&:id).to_set - text_matched & dropdown_matched_ids & topic_matched_ids + text_matched & dropdown_matched_ids & location_matched_ids & topic_matched_ids end # True when at least one filter is narrowing the list. The view uses this to @@ -58,15 +61,14 @@ def matches_text?(reg) matches_reg_org?(reg) && matches_funder?(reg) && matches_comment?(reg) && - matches_email?(reg) && - matches_city?(reg) + matches_email?(reg) end # Apply the registrants-roster scopes for whichever dropdowns / date-range # filters are set, then return the matching ids. With none set this is every # registration, so the text-match set passes through unchanged. def dropdown_matched_ids - return @event_registrations.map(&:id).to_set if @event.nil? + return all_registration_ids if @event.nil? scope = @event.event_registrations scope = scope.registered_between(@params[:registered_from], @params[:registered_to]) @@ -79,17 +81,35 @@ def dropdown_matched_ids scope = scope.organization_linking_status(@params[:org_status], @event) if @params[:org_status].present? scope = scope.account_status(@params[:account_status]) if @params[:account_status].present? scope = scope.submission_status(@params[:submission_status], @event) if @params[:submission_status].present? - scope = scope.registrant_state(@params[:state]) if @params[:state].present? - scope = scope.registrant_county(@params[:county]) if @params[:county].present? scope.pluck(:id).to_set end + # The City search and the State / County dropdowns, resolved together through + # EventRegistration.located_in so the same values narrow the roster and the picker + # identically — and so a registrant working in two states can't match a city from + # one and the state from the other. City is multi-term, so each term asks for the + # rows that match it *and* the dropdowns, then the terms are unioned. + def location_matched_ids + return all_registration_ids if LOCATION_KEYS.none? { |key| @params[key].present? } + + terms = terms_for(:city) + return located_in_ids(nil) if terms.empty? + terms.reduce(Set.new) { |ids, term| ids | located_in_ids(term) } + end + + def located_in_ids(city) + EventRegistration.where(id: all_registration_ids.to_a) + .located_in(on: @event&.start_date, state: @params[:state], county: @params[:county], city: city) + .pluck(:id) + .to_set + end + # Keeps registrants whose person holds an active subscription to any of the chosen # topic subscription lists. With no topic set this is every registration, so it # passes the other matches through unchanged. def topic_matched_ids type_ids = Array(@params[:topic_subscription]).reject(&:blank?).map(&:to_i).to_set - return @event_registrations.map(&:id).to_set if type_ids.empty? + return all_registration_ids if type_ids.empty? @event_registrations.select do |reg| reg.registrant.topic_subscriptions.any? do |sub| @@ -127,16 +147,6 @@ def matches_comment?(reg) end end - # Skips inactive addresses, matching EventRegistration.registrant_city so the - # same City value narrows the roster and the picker identically. - def matches_city?(reg) - any_term?(:city) do |term| - reg.registrant.addresses.any? do |address| - !address.inactive? && address.city.to_s.downcase.include?(term) - end - end - end - # Matches against every email we hold for the registrant (their account email, # contact email and secondary email), so a search hits whichever address the # admin remembers. @@ -156,8 +166,16 @@ def registrant_emails(reg) # when ANY of them hits (e.g. "amy--aisha" keeps both Amy and Aisha). Single # hyphens inside a value are preserved. Returns true when no term was typed. def any_term?(key) - tokens = @params[key].to_s.downcase.split("--").map(&:strip).reject(&:blank?) + tokens = terms_for(key) return true if tokens.empty? tokens.any? { |token| yield token } end + + def terms_for(key) + @params[key].to_s.downcase.split("--").map(&:strip).reject(&:blank?) + end + + def all_registration_ids + @all_registration_ids ||= @event_registrations.map(&:id).to_set + end end diff --git a/app/views/events/_registrant_breakdowns.html.erb b/app/views/events/_registrant_breakdowns.html.erb index 934d8d1a21..186206752a 100644 --- a/app/views/events/_registrant_breakdowns.html.erb +++ b/app/views/events/_registrant_breakdowns.html.erb @@ -77,7 +77,7 @@ <% sector_paths["Other"] = by_ids.(data.other_sector_response_registrant_ids) if data.other_sector_response_count.positive? %> <% age_group_paths = data.age_groups.to_h { |c| [ c.name, by_ids.(data.age_group_registrant_ids_by_category.fetch(c.id, [])) ] } %> <% all_age_group_paths = data.all_age_groups.to_h { |c| [ c.name, by_ids.(data.all_age_group_registrant_ids_by_category.fetch(c.id, [])) ] } %> - <% state_paths = data.states.index_with { |state| roster_event_path(event, state: state, charts: 1) } %> + <% state_paths = data.state_registrant_ids_by_state.transform_values { |ids| by_ids.(ids) } %> <% country_paths = data.country_registrant_ids_by_country.transform_values { |ids| by_ids.(ids) } %> <% school_district_paths = data.school_district_registrant_ids_by_district.transform_values { |ids| by_ids.(ids) } %> <% life_experience_paths = data.life_experiences.to_h { |c| [ c.name, by_ids.(data.life_experience_registrant_ids_by_category.fetch(c.id, [])) ] } %> diff --git a/db/seeds/features/2026-10-25-registrant-charts-use-work-locations.yml b/db/seeds/features/2026-10-25-registrant-charts-use-work-locations.yml new file mode 100644 index 0000000000..db287e6f78 --- /dev/null +++ b/db/seeds/features/2026-10-25-registrant-charts-use-work-locations.yml @@ -0,0 +1,15 @@ +name: "Registrant charts and location filters use where people work" +area: reporting +display_status: admin_facing +released_on: 2026-10-25 +pr_number: 2844 +summary: >- + The cities, states, and countries charts, the City, State, and County + filters on the roster, the attendees page, and the bulk email picker, and + the participation report's reach figures now use where each person works: + the addresses of the organizations they're affiliated with. +pro_tips: + - "On an event's pages, it's the affiliations active on the event's start date, so a past event doesn't change when someone changes jobs. Across events and on the reports, it's today's active affiliations." + - "Every active affiliation counts. If one has a work address picked, that one is used; otherwise the organization's first address on file." + - "Someone with no affiliation address counts where their own address puts them: their active work address, else their most recently edited active personal one, else a retired personal, else a retired work." + - "City, State, County and Country always describe the same address, so someone working in two states won't show up for a city in one and a state in the other." diff --git a/spec/models/address_spec.rb b/spec/models/address_spec.rb index 197c70e00c..54d110d224 100644 --- a/spec/models/address_spec.rb +++ b/spec/models/address_spec.rb @@ -167,6 +167,46 @@ end end + describe ".ranked_for_owner" do + let(:person) { create(:person) } + + def address_typed(type, inactive: false, city: type.to_s) + create(:address, addressable: person, address_type: type, inactive: inactive, city: city) + end + + it "prefers the active work address" do + address_typed("personal") + work = address_typed("work") + + expect(person.addresses.ranked_for_owner.first).to eq(work) + end + + it "falls back to the most recently edited active personal address" do + older = address_typed("personal", city: "Boise") + newer = address_typed("personal", city: "Nampa") + older.update!(zip_code: "83701") + newer.update!(zip_code: "83651") + + expect(person.addresses.ranked_for_owner.first).to eq(newer) + end + + it "takes an untyped active address over any retired one" do + address_typed("work", inactive: true) + unknown = address_typed(Address::UNKNOWN_CONTACT_TYPE) + + expect(person.addresses.ranked_for_owner.first).to eq(unknown) + end + + # A retired work address is the stalest thing we hold — people change jobs, + # and home outlasts the job — so it sorts behind a retired personal one. + it "prefers a retired personal address to a retired work address" do + address_typed("work", inactive: true) + personal = address_typed("personal", inactive: true) + + expect(person.addresses.ranked_for_owner.first).to eq(personal) + end + end + describe ".unfilled_attributes?" do it "is true for a block carrying only the pre-filled country, state, and locality" do attrs = { "country" => "Canada", "state" => "AB", "locality" => "Outside USA", diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index c1133cfdc0..8d4991bf5d 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -600,18 +600,61 @@ def registration_with_scholarship end end - describe ".registrant_city" do - it "matches registrants with an address in the city (case-insensitive, partial)" do - in_city = create(:person) - create(:address, addressable: in_city, city: "Santa Monica") - out_city = create(:person) - create(:address, addressable: out_city, city: "Portland") - reg_in = create(:event_registration, registrant: in_city) - reg_out = create(:event_registration, registrant: out_city) - - results = EventRegistration.registrant_city("santa") - expect(results).to include(reg_in) - expect(results).not_to include(reg_out) + describe "location scopes" do + def registration_working_in(city:, state:, county: nil) + registration = create(:event_registration) + organization = create(:organization) + create(:address, addressable: organization, city:, state:, county:) + create(:affiliation, person: registration.registrant, organization:) + registration + end + + let!(:santa_monica) { registration_working_in(city: "Santa Monica", state: "CA", county: "Los Angeles") } + let!(:portland) { registration_working_in(city: "Portland", state: "OR", county: "Multnomah") } + + it "matches the work city (case-insensitive, partial) over the registrant's own address" do + create(:address, addressable: portland.registrant, city: "Santa Monica") + + expect(EventRegistration.located_in(city: "santa")).to contain_exactly(santa_monica) + end + + it "falls back to the registrant's own address with no active affiliation" do + unlinked = create(:event_registration) + create(:address, addressable: unlinked.registrant, city: "Santa Barbara", state: "CA") + + expect(EventRegistration.located_in(city: "santa")).to contain_exactly(santa_monica, unlinked) + end + + it "matches the work state" do + expect(EventRegistration.located_in(state: "OR")).to contain_exactly(portland) + end + + it "matches the work county within a state" do + expect(EventRegistration.located_in(county: "CA|Los Angeles")).to contain_exactly(santa_monica) + expect(EventRegistration.located_in(county: "OR|Los Angeles")).to be_empty + end + + it "passes everything through when no location filter is given" do + expect(EventRegistration.located_in).to include(santa_monica, portland) + end + + # One call, not three chained scopes: otherwise someone working in two states + # matches a city from one and the state from the other. + it "tests city, state and county against the same work address" do + two_states = create(:event_registration) + [ [ "Portland", "OR", "Multnomah" ], [ "Seattle", "WA", "King" ] ].each do |city, state, county| + organization = create(:organization) + create(:address, addressable: organization, city:, state:, county:) + create(:affiliation, person: two_states.registrant, organization:) + end + + expect(EventRegistration.located_in(city: "Seattle", state: "WA")).to contain_exactly(two_states) + expect(EventRegistration.located_in(city: "Seattle", state: "OR")).to be_empty + expect(EventRegistration.located_in(city: "Seattle", county: "OR|Multnomah")).to be_empty + end + + it "matches nothing when the State and County filters name different states" do + expect(EventRegistration.located_in(state: "CA", county: "OR|Multnomah")).to be_empty end end diff --git a/spec/presenters/registrant_city_breakdown_spec.rb b/spec/presenters/registrant_city_breakdown_spec.rb index 235b926373..4df4cc523e 100644 --- a/spec/presenters/registrant_city_breakdown_spec.rb +++ b/spec/presenters/registrant_city_breakdown_spec.rb @@ -4,8 +4,7 @@ describe "#rows" do it "counts distinct registrants and scholarship recipients per city, most first" do breakdown = described_class.new( - org_registrant_pairs: [ [ 1, 10 ], [ 1, 11 ], [ 2, 12 ] ], - city_by_org: { 1 => "Los Angeles, CA", 2 => "Richmond, CA" }, + city_registrant_pairs: [ [ "Los Angeles, CA", 10 ], [ "Los Angeles, CA", 11 ], [ "Richmond, CA", 12 ] ], scholarship_recipient_ids: [ 10 ] ) @@ -16,8 +15,7 @@ it "exposes per-city registrant and scholarship-recipient ids for drill-in links" do breakdown = described_class.new( - org_registrant_pairs: [ [ 1, 10 ], [ 1, 11 ], [ 2, 12 ] ], - city_by_org: { 1 => "Los Angeles, CA", 2 => "Richmond, CA" }, + city_registrant_pairs: [ [ "Los Angeles, CA", 10 ], [ "Los Angeles, CA", 11 ], [ "Richmond, CA", 12 ] ], scholarship_recipient_ids: [ 10, 12 ] ) @@ -26,20 +24,18 @@ expect(la.scholarship_recipient_ids).to contain_exactly(10) end - it "counts a registrant once per city even when linked to that org twice" do + it "counts a registrant once per city even when listed there twice" do breakdown = described_class.new( - org_registrant_pairs: [ [ 1, 10 ], [ 1, 10 ] ], - city_by_org: { 1 => "Barstow, CA" }, + city_registrant_pairs: [ [ "Barstow, CA", 10 ], [ "Barstow, CA", 10 ] ], scholarship_recipient_ids: [] ) expect(breakdown.rows.map(&:registrant_count)).to eq([ 1 ]) end - it "buckets linked orgs with no city under Unknown, sorted last" do + it "buckets a nil city under Unknown, sorted last" do breakdown = described_class.new( - org_registrant_pairs: [ [ 1, 10 ], [ 1, 11 ], [ 2, 12 ] ], - city_by_org: { 1 => "Perris, CA" }, + city_registrant_pairs: [ [ "Perris, CA", 10 ], [ "Perris, CA", 11 ], [ nil, 12 ] ], scholarship_recipient_ids: [] ) @@ -51,13 +47,12 @@ it "reports city_count and any?" do breakdown = described_class.new( - org_registrant_pairs: [ [ 1, 10 ], [ 2, 11 ] ], - city_by_org: { 1 => "Pomona, CA", 2 => "Tustin, CA" }, + city_registrant_pairs: [ [ "Pomona, CA", 10 ], [ "Tustin, CA", 11 ] ], scholarship_recipient_ids: [] ) expect(breakdown.city_count).to eq(2) expect(breakdown).to be_any - expect(described_class.new(org_registrant_pairs: [], city_by_org: {}, scholarship_recipient_ids: [])).not_to be_any + expect(described_class.new(city_registrant_pairs: [], scholarship_recipient_ids: [])).not_to be_any end end diff --git a/spec/requests/events/attendees_spec.rb b/spec/requests/events/attendees_spec.rb index 828aa4f56c..d28f91c93f 100644 --- a/spec/requests/events/attendees_spec.rb +++ b/spec/requests/events/attendees_spec.rb @@ -341,14 +341,22 @@ expect(response.body).to include("Online Search") end - it "filters by a breakdown drill-in (country)" do - create(:address, addressable: attendee, country: "Canada", inactive: false) + it "filters by work country and state (via their active affiliations)" do + org = create(:organization, name: "Toronto Org") + create(:address, addressable: org, state: "ON", country: "Canada", inactive: false) + create(:affiliation, person: attendee_registration.registrant, organization: org) other = create(:person, first_name: "Zed", last_name: "Zulu") + create(:address, addressable: other, state: "OR", country: "Mexico", inactive: false) create(:event_registration, event: recent_training, registrant: other, status: "attended") get attendees_events_url(country: "Canada"), headers: frame_headers expect(response.body).to include("Ada Lovelace") expect(response.body).not_to include("Zed Zulu") + + org.addresses.first.update!(state: "WA", country: "United States") + get attendees_events_url(state: "WA"), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + expect(response.body).not_to include("Zed Zulu") end it "filters by a school-district drill-in (via the org they registered under)" do @@ -373,13 +381,13 @@ it "renders the cities breakdown and filters by an org-city drill-in" do org = create(:organization, name: "Wellness Org") create(:address, addressable: org, city: "Austin", state: "TX", inactive: false) - attendee_registration.event_registration_organizations.create!(organization: org) + create(:affiliation, person: attendee_registration.registrant, organization: org) other = create(:person, first_name: "Zed", last_name: "Zulu") other_registration = create(:event_registration, event: recent_training, registrant: other, status: "attended") other_org = create(:organization, name: "Other Org") create(:address, addressable: other_org, city: "Reno", state: "NV", inactive: false) - other_registration.event_registration_organizations.create!(organization: other_org) + create(:affiliation, person: other_registration.registrant, organization: other_org) # An org with no address falls into the non-clickable "Unknown" bucket — # this used to raise a UrlGenerationError (nil event) on the index. @@ -403,7 +411,7 @@ # that raised a nil-event UrlGenerationError). org = create(:organization, name: "Wellness Org") create(:address, addressable: org, city: "Austin", state: "TX", inactive: false) - attendee_registration.event_registration_organizations.create!(organization: org) + create(:affiliation, person: attendee_registration.registrant, organization: org) award = create(:scholarship, recipient: attendee, amount_cents: 1_000, grant: create(:grant)) create(:allocation, source: award, allocatable: attendee_registration, amount: 1_000) @@ -540,28 +548,52 @@ expect(response.body).to include("Ada Lovelace") end - it "filters by address data (city) without requiring a registration to carry it" do - create(:address, addressable: attendee, city: "Portland", state: "OR", inactive: false) + it "filters by work city, not the person's own address" do + org = create(:organization) + create(:address, addressable: org, city: "Portland", state: "OR", inactive: false) + create(:affiliation, person: attendee_registration.registrant, organization: org) other = create(:person, first_name: "Cara", last_name: "Coast") - create(:event_registration, event: recent_training, registrant: other, status: "attended") - create(:address, addressable: other, city: "Seattle", state: "WA", inactive: false) + other_registration = create(:event_registration, event: recent_training, registrant: other, status: "attended") + create(:address, addressable: other, city: "Portland", state: "OR", inactive: false) + other_org = create(:organization) + create(:address, addressable: other_org, city: "Seattle", state: "WA", inactive: false) + create(:affiliation, person: other_registration.registrant, organization: other_org) get attendees_events_url(city: "portland"), headers: frame_headers expect(response.body).to include("Ada Lovelace") expect(response.body).not_to include("Cara Coast") end - # City and state have to describe one address, the way the roster's shared - # join does — otherwise someone with homes in two states matches a city from - # one and a state from the other. - it "matches city and state against the same address" do - create(:address, addressable: attendee, city: "Portland", state: "OR", inactive: false) - create(:address, addressable: attendee, city: "Seattle", state: "WA", inactive: false) + # Every location filter has to describe one address — otherwise someone + # working in two places matches a city from one and the state, county or + # country from the other. + it "matches city, state, county and country against the same work address" do + [ [ "Portland", "OR", "Multnomah", "United States" ], [ "Vancouver", "BC", "Greater Vancouver", "Canada" ] ].each do |city, state, county, country| + org = create(:organization) + create(:address, addressable: org, city:, state:, county:, country:, inactive: false) + create(:affiliation, person: attendee_registration.registrant, organization: org) + end - get attendees_events_url(city: "Seattle", state: "WA"), headers: frame_headers + get attendees_events_url(city: "Vancouver", state: "BC"), headers: frame_headers expect(response.body).to include("Ada Lovelace") - get attendees_events_url(city: "Seattle", state: "OR"), headers: frame_headers + get attendees_events_url(city: "Vancouver", state: "OR"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + + get attendees_events_url(city: "Vancouver", county: "OR::Multnomah"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + + get attendees_events_url(city: "Vancouver", country: "United States"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + end + + it "matches nobody when the State dropdown contradicts the county's own state" do + org = create(:organization) + create(:address, addressable: org, city: "Portland", state: "OR", county: "Multnomah", inactive: false) + attendee_registration.event_registration_organizations.create!(organization: org) + + get attendees_events_url(state: "OR", county: "WA::King"), headers: frame_headers + expect(response).to have_http_status(:ok) expect(response.body).not_to include("Ada Lovelace") end diff --git a/spec/requests/events/bulk_reminders_spec.rb b/spec/requests/events/bulk_reminders_spec.rb index 91219c8e92..3122ec443a 100644 --- a/spec/requests/events/bulk_reminders_spec.rb +++ b/spec/requests/events/bulk_reminders_spec.rb @@ -47,8 +47,14 @@ def checked?(body, registration) expect(checked?(response.body, sam)).to be(false) end - it "filters recipients by the shared city search" do - create(:address, addressable: jane.registrant, city: "Santa Monica") + it "filters recipients by the shared city search, on where they work" do + organization = create(:organization) + create(:address, addressable: organization, city: "Santa Monica") + create(:affiliation, person: jane.registrant, organization:) + elsewhere = create(:organization) + create(:address, addressable: elsewhere, city: "Portland") + create(:affiliation, person: sam.registrant, organization: elsewhere) + create(:address, addressable: sam.registrant, city: "Santa Monica") get preview_reminder_event_path(event, city: "santa"), headers: { "Turbo-Frame" => "reminder_recipients" } diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index a769b15bde..b23aff9a9b 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1779,13 +1779,18 @@ def pay_for_others_classes(template) expect(response.body).to include("Any status") end - it "narrows the roster by city" do + it "narrows the roster by work city" do here = create(:person, first_name: "Cityful", last_name: "Local") - create(:address, addressable: here, city: "Santa Monica") there = create(:person, first_name: "Faraway", last_name: "Person") - create(:address, addressable: there, city: "Portland") + create(:address, addressable: there, city: "Santa Monica") + here_org = create(:organization) + create(:address, addressable: here_org, city: "Santa Monica") create(:event_registration, event: event, registrant: here) + create(:affiliation, person: here, organization: here_org) + there_org = create(:organization) + create(:address, addressable: there_org, city: "Portland") create(:event_registration, event: event, registrant: there) + create(:affiliation, person: there, organization: there_org) get registrants_event_path(event, city: "santa") @@ -1793,6 +1798,25 @@ def pay_for_others_classes(template) expect(response.body).not_to include("Faraway") end + # The City and State controls sit in the same filter form, so they have to + # describe one address — otherwise a registrant working in two states matches + # a city from one and the state from the other. + it "matches the roster's city and state filters against the same work address" do + person = create(:person, first_name: "Bistate", last_name: "Worker") + registration = create(:event_registration, event: event, registrant: person) + [ %w[Portland OR], %w[Seattle WA] ].each do |city, state| + organization = create(:organization) + create(:address, addressable: organization, city: city, state: state) + create(:affiliation, person: registration.registrant, organization: organization) + end + + get registrants_event_path(event, city: "Seattle", state: "WA") + expect(response.body).to include("Bistate") + + get registrants_event_path(event, city: "Seattle", state: "OR") + expect(response.body).not_to include("Bistate") + end + it "does not crash on the new funder / comment-text / submission filters" do get registrants_event_path(event, funder_name: "someone") expect(response).to have_http_status(:ok) @@ -2577,9 +2601,11 @@ def ce_chip_text let!(:ca_kings_reg) { create(:event_registration, event: event, registrant: ca_kings_person) } before do - create(:address, addressable: ca_person, state: "CA", county: "Los Angeles") - create(:address, addressable: ny_person, state: "NY", county: "Kings") - create(:address, addressable: ca_kings_person, state: "CA", county: "Kings") + { ca_reg => [ "CA", "Los Angeles" ], ny_reg => [ "NY", "Kings" ], ca_kings_reg => [ "CA", "Kings" ] }.each do |registration, (state, county)| + organization = create(:organization) + create(:address, addressable: organization, state:, county:) + create(:affiliation, person: registration.registrant, organization:) + end sign_in admin end @@ -3371,8 +3397,9 @@ def ce_chip_text expect(response.body).to include("Hospice care") end - it "shows a countries breakdown from registrant addresses" do - create(:address, addressable: person, state: "CA", country: "Canada", inactive: false) + it "shows a countries breakdown from the org a registrant registered under" do + create(:address, addressable: organization, state: "ON", country: "Canada", inactive: false) + registration.event_registration_organizations.create!(organization: organization) get roster_event_path(owned_event), headers: charts_headers @@ -3391,7 +3418,8 @@ def ce_chip_text end it "renders the states breakdown as a US choropleth map fed by per-state counts" do - create(:address, addressable: person, state: "CA", inactive: false) + create(:address, addressable: organization, state: "CA", inactive: false) + registration.event_registration_organizations.create!(organization: organization) get roster_event_path(owned_event), headers: charts_headers @@ -3429,10 +3457,10 @@ def ce_chip_text expect(response.body).not_to include("Only scholarship orgs") end - it "groups registrants by the city of the org linked on their registration" do + it "groups registrants by the city they work in" do city_org = create(:organization, name: "City Org") create(:address, addressable: city_org, city: "Los Angeles", state: "CA", inactive: false) - create(:event_registration_organization, event_registration: registration, organization: city_org) + create(:affiliation, person: person, organization: city_org) scholarship = create(:scholarship, recipient: person, amount_cents: 500) create(:allocation, source: scholarship, allocatable: registration, amount: 500) @@ -3650,11 +3678,10 @@ def ce_chip_text expect(response.body).to include("Billed to original event") end - it "shows a recipient city breakdown, grouped by the registration-linked org, in the lazy charts frame" do + it "shows a recipient city breakdown, grouped by where they work, in the lazy charts frame" do org = create(:organization, name: "Reach Org") create(:address, addressable: org, city: "Richmond", state: "CA", inactive: false) - registration = EventRegistration.find_by!(registrant: applicant, event: event) - create(:event_registration_organization, event_registration: registration, organization: org) + create(:affiliation, person: applicant, organization: org) get recipients_event_path(event), headers: { "Turbo-Frame" => "recipients_charts" } diff --git a/spec/services/attendees_active_filters_spec.rb b/spec/services/attendees_active_filters_spec.rb index 1b267d5e2e..150572d1b4 100644 --- a/spec/services/attendees_active_filters_spec.rb +++ b/spec/services/attendees_active_filters_spec.rb @@ -31,7 +31,7 @@ def chips_for(params) it "labels the literal-value drill-ins" do expect(chips_for(country: "Canada").first[:label]).to eq("Country: Canada") expect(chips_for(school_district: "PPS").first[:label]).to eq("School district: PPS") - expect(chips_for(org_city: "Austin, TX").first[:label]).to eq("Org city: Austin, TX") + expect(chips_for(org_city: "Austin, TX").first[:label]).to eq("City: Austin, TX") end # Scholarship and CE status each have their own visible select on the index, so diff --git a/spec/services/attendees_breakdowns_spec.rb b/spec/services/attendees_breakdowns_spec.rb index 7888c260f9..e62821f7a8 100644 --- a/spec/services/attendees_breakdowns_spec.rb +++ b/spec/services/attendees_breakdowns_spec.rb @@ -19,9 +19,14 @@ expect(breakdowns.sector_counts).to eq(sector.id => 1) end - it "counts US states from active addresses" do - create(:address, addressable: person, state: "CA", inactive: false) + it "counts US states and countries from where they work (active affiliations)" do + create(:address, addressable: person, state: "NY", country: "Canada") + organization = create(:organization) + create(:address, addressable: organization, state: "CA", country: "United States") + create(:affiliation, person: registration.registrant, organization: organization) + expect(breakdowns.state_counts).to eq("CA" => 1) + expect(breakdowns.country_counts).to eq("United States" => 1) end it "aggregates organizations linked on the person's training registrations" do @@ -31,10 +36,10 @@ expect(breakdowns.organization_counts).to eq(organization.id => 1) end - it "groups attendees by the city of the org linked on their training registration" do + it "groups attendees by the city they work in" do organization = create(:organization, name: "Wellness Org") create(:address, addressable: organization, city: "Austin", state: "TX", inactive: false) - registration.event_registration_organizations.create!(organization: organization) + create(:affiliation, person: registration.registrant, organization: organization) rows = breakdowns.registrant_city_breakdown.rows expect(rows.map(&:city)).to eq([ "Austin, TX" ]) @@ -137,9 +142,10 @@ def answer(who, event, value) it "returns the people behind each dimension" do sector = create(:sector, name: "Healthcare") create(:sectorable_item, sectorable: person, sector: sector, is_primary: true) - create(:address, addressable: person, state: "CA", inactive: false) organization = create(:organization, name: "Wellness Org") + create(:address, addressable: organization, state: "CA", inactive: false) registration.event_registration_organizations.create!(organization: organization) + create(:affiliation, person: registration.registrant, organization: organization) scholarship = create(:scholarship, recipient: person, amount_cents: 1_000) create(:allocation, source: scholarship, allocatable: registration, amount: 1_000) diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 9d98f445f2..0e3564a8e0 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -438,37 +438,36 @@ end end - describe "states" do - it "returns unique states from active registrants' active addresses" do - expect(dashboard.states).to eq(%w[CA NY]) + describe "work locations" do + before do + reg1.event_registration_organizations.create!(organization: org_a) + reg2.event_registration_organizations.create!(organization: org_c) + cancelled_reg = event.event_registrations.find_by(registrant: cancelled_person) + cancelled_reg.event_registration_organizations.create!(organization: org_excluded) + create(:address, addressable: org_a, city: "Oakland", state: "OR", country: "United States") + chosen = create(:address, addressable: org_a, city: "Fresno", state: "CA", county: "Fresno", country: "United States") + person1.affiliations.find_by(organization: org_a).update!(organization_address: chosen, event_registration: reg1) + create(:address, addressable: org_c, city: "Albany", state: "NY", county: "Albany", country: "Mexico") + create(:address, addressable: org_excluded, city: "Miami", state: "FL", country: "Canada") end - it "counts distinct registrants per state" do + it "reads states from the affiliation's chosen org address, else the org's oldest" do + expect(dashboard.states).to eq(%w[CA NY]) expect(dashboard.state_counts).to eq("CA" => 1, "NY" => 1) + expect(dashboard.state_registrant_ids_by_state).to eq("CA" => [ person1.id ], "NY" => [ person2.id ]) end - it "returns the registrant ids that have a state on file" do - expect(dashboard.state_registrant_ids).to contain_exactly(person1.id, person2.id) - end - end - - describe "counties" do - it "returns unique [ state, county ] pairs from active registrants' active addresses" do - expect(dashboard.counties).to eq([ [ "CA", "Los Angeles" ], [ "NY", "Kings" ] ]) - end - end - - describe "countries" do - it "returns the registrant ids that have a country on file, excluding inactive and cancelled" do + it "reads countries from the same work addresses, excluding cancelled registrations" do + expect(dashboard.country_counts).to eq("United States" => 1, "Mexico" => 1) expect(dashboard.country_registrant_ids).to contain_exactly(person1.id, person2.id) end - it "counts only active registrants' active addresses" do - expect(dashboard.country_counts.values.sum).to eq(2) + it "groups registrants by work city" do + expect(dashboard.registrant_city_breakdown.rows.map(&:city)).to contain_exactly("Fresno, CA", "Albany, NY") end - it "maps each country to its registrant ids" do - expect(dashboard.country_registrant_ids_by_country.values.flatten).to contain_exactly(person1.id, person2.id) + it "lists the [ state, county ] pairs registrants work in" do + expect(dashboard.counties).to eq([ [ "CA", "Fresno" ], [ "NY", "Albany" ] ]) end end diff --git a/spec/services/event_participation_report_spec.rb b/spec/services/event_participation_report_spec.rb index f4626ba736..8a405d41b7 100644 --- a/spec/services/event_participation_report_spec.rb +++ b/spec/services/event_participation_report_spec.rb @@ -143,7 +143,7 @@ registration = create(:event_registration, event: event, registrant: person, status: "attended") create(:event_registration_organization, event_registration: registration, organization: organization) create(:sectorable_item, sectorable: person, sector: create(:sector)) - create(:address, addressable: person, state: "CA", country: "US") + create(:address, addressable: person, city: "Fresno", state: "CA", country: "US") create(:address, addressable: organization, city: "Los Angeles") end @@ -155,8 +155,16 @@ expect(reach.countries).to eq(1) end - it "counts distinct cities from the linked organizations' addresses" do - expect(report.demographics.cities).to eq(1) + it "counts states, countries, and cities where registrants work, else live" do + worker = create(:person) + create(:event_registration, event: event, registrant: worker, status: "attended") + create(:affiliation, person: worker, organization: organization) + organization.addresses.first.update!(state: "NV", country: "Canada") + + reach = report.demographics + expect(reach.states).to eq(2) + expect(reach.countries).to eq(2) + expect(reach.cities).to eq(2) end end diff --git a/spec/services/person_locations_spec.rb b/spec/services/person_locations_spec.rb new file mode 100644 index 0000000000..84bdd2edd3 --- /dev/null +++ b/spec/services/person_locations_spec.rb @@ -0,0 +1,186 @@ +require "rails_helper" + +RSpec.describe PersonLocations do + let(:person) { create(:person) } + let(:organization) { create(:organization) } + let!(:affiliation) { create(:affiliation, person: person, organization: organization) } + + subject(:locations) { described_class.new([ person.id ]) } + + it "uses the address picked on the affiliation" do + create(:address, addressable: organization, city: "Portland", state: "OR", country: "United States") + chosen = create(:address, addressable: organization, city: "Fresno", state: "CA", country: "United States") + affiliation.update!(organization_address: chosen) + + expect(locations.rows.map(&:city_label)).to eq([ "Fresno, CA" ]) + end + + it "falls back to the org's oldest active address" do + create(:address, addressable: organization, city: "Old", state: "NV", inactive: true) + create(:address, addressable: organization, city: "Reno", state: "NV") + create(:address, addressable: organization, city: "Elko", state: "NV") + + expect(locations.rows.map(&:city_label)).to eq([ "Reno, NV" ]) + end + + it "counts every active affiliation" do + create(:address, addressable: organization, city: "Reno", state: "NV") + other = create(:organization) + create(:address, addressable: other, city: "Elko", state: "NV") + create(:affiliation, person: person, organization: other) + + expect(locations.rows.map(&:city_label)).to contain_exactly("Reno, NV", "Elko, NV") + end + + it "prefers the work address over the person's own" do + create(:address, addressable: organization, city: "Reno", state: "NV") + create(:address, addressable: person, city: "Boise", state: "ID") + + expect(locations.rows.map(&:city_label)).to eq([ "Reno, NV" ]) + end + + it "skips an ended affiliation today" do + create(:address, addressable: organization, city: "Reno", state: "NV") + affiliation.update!(end_date: 1.month.ago.to_date) + create(:address, addressable: person, city: "Boise", state: "ID") + + expect(locations.rows.map(&:city_label)).to eq([ "Boise, ID" ]) + end + + describe "on a date" do + before do + create(:address, addressable: organization, city: "Reno", state: "NV") + create(:address, addressable: person, city: "Boise", state: "ID") + end + + it "counts an affiliation that was active then, even if it has ended since" do + affiliation.update!(start_date: Date.new(2024, 1, 1), end_date: Date.new(2025, 1, 1)) + + expect(described_class.new([ person.id ], on: Date.new(2024, 6, 1)).rows.map(&:city_label)).to eq([ "Reno, NV" ]) + end + + it "skips an affiliation that started after it" do + affiliation.update!(start_date: Date.new(2025, 1, 1)) + + expect(described_class.new([ person.id ], on: Date.new(2024, 6, 1)).rows.map(&:city_label)).to eq([ "Boise, ID" ]) + end + end + + # Address#ranked_for_owner: active work, else latest-edited active personal, + # else inactive personal, else inactive work. + it "falls back to the address that stands for the person" do + affiliation.destroy! + create(:address, addressable: person, city: "Boise", state: "ID", address_type: "personal") + create(:address, addressable: person, city: "Nampa", state: "ID", address_type: "work") + + expect(locations.rows.map { |row| [ row.city_label, row.organization_id ] }).to eq([ [ "Nampa, ID", nil ] ]) + end + + it "places a person by a retired address rather than nowhere" do + create(:address, addressable: person, city: "Boise", state: "ID", address_type: "personal", inactive: true) + + expect(locations.rows.map(&:city_label)).to eq([ "Boise, ID" ]) + end + + it "has no row for someone with no work or own address" do + expect(locations.rows).to be_empty + end + + # A relation stays a subquery, so a whole-population lookup never inlines every + # id (the attendees index builds its filter options that way). + it "keeps a relation as a subquery instead of plucking its ids" do + create(:address, addressable: organization, city: "Austin", state: "TX") + + queries = [] + subscriber = ->(*, payload) { queries << payload[:sql] } + rows = ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do + described_class.new(Person.where(id: person.id)).rows + end + + expect(rows.map(&:city_label)).to eq([ "Austin, TX" ]) + expect(queries.grep(/FROM `affiliations`/).first).to include("SELECT `people`.`id` FROM `people`") + end + + it "groups registrant ids by US state, country, and city" do + create(:address, addressable: organization, city: "Austin", state: "TX", country: "United States") + abroad = create(:organization) + create(:address, addressable: abroad, city: "Toronto", state: "ON", country: "Canada") + create(:affiliation, person: person, organization: abroad) + + expect(locations.registrant_ids_by_state).to eq("TX" => [ person.id ]) + expect(locations.registrant_ids_by(:country)).to eq("United States" => [ person.id ], "Canada" => [ person.id ]) + expect(locations.registrant_ids_by_city_label.keys).to contain_exactly("Austin, TX", "Toronto, ON") + end + + it "groups by any other location field the address carries" do + create(:address, addressable: organization, city: "Austin", state: "TX", + zip_code: "78701", county: "Travis", locality: Address::OUTSIDE_CA, district: "Austin ISD") + + expect(locations.registrant_ids_by(:zip_code)).to eq("78701" => [ person.id ]) + expect(locations.registrant_ids_by(:locality)).to eq(Address::OUTSIDE_CA => [ person.id ]) + expect(locations.registrant_ids_by(:district)).to eq("Austin ISD" => [ person.id ]) + end + + describe "#matching" do + before do + create(:address, addressable: organization, city: "Austin", state: "TX", county: "Travis", country: "United States") + abroad = create(:organization) + create(:address, addressable: abroad, city: "Toronto", state: "ON", county: "York", country: "Canada") + create(:affiliation, person: person, organization: abroad) + end + + # One call, not two: chaining them is what lets someone working in two + # places answer a city from one and a county from the other. + it "tests every field against the same address" do + expect(locations.matching(city: "toronto", country: "Canada")).to be_present + expect(locations.matching(city: "toronto", country: "United States")).to be_empty + expect(locations.matching(city: "toronto", county: "Travis")).to be_empty + end + + it "matches a searched field on a substring and the rest whole, both case-insensitively" do + expect(locations.matching(city: "AUST")).to be_present + expect(locations.matching(state: "tx")).to be_present + expect(locations.matching(county: "Trav")).to be_empty + end + + it "rejects a field that isn't a location" do + expect { locations.matching(street_address: "908 N 6th St") }.to raise_error(ArgumentError) + end + end + + describe ".filters_from" do + it "folds the county option's own state into the state filter" do + expect(described_class.filters_from(county: "CA|Los Angeles")).to eq(state: "CA", county: "Los Angeles") + expect(described_class.filters_from(county: "CA::Los Angeles", city: "pas")).to eq(state: "CA", county: "Los Angeles", city: "pas") + end + + it "keeps a bare county name, and drops blanks" do + expect(described_class.filters_from(county: "Los Angeles", state: "")).to eq(county: "Los Angeles") + expect(described_class.filters_from(state: nil, county: nil, city: nil)).to eq({}) + end + + it "reports no possible match when the state and the county's state disagree" do + expect(described_class.filters_from(state: "CA", county: "OR|Multnomah")).to be_nil + expect(described_class.filters_from(county: "CA|")).to be_nil + end + end + + describe "#candidate_values" do + # Option lists are built from the whole reportable population, so they read the + # address tables directly rather than resolving a row per person. + it "lists the values the affiliated orgs' and people's own addresses carry" do + create(:address, addressable: organization, state: "TX", county: "Travis") + create(:address, addressable: person, state: "ID", county: "Ada") + + expect(locations.candidate_values(:state)).to eq(%w[ID TX]) + expect(locations.candidate_values(:state, :county)).to eq([ [ "ID", "Ada" ], [ "TX", "Travis" ] ]) + end + + it "skips addresses missing any of the fields asked for" do + create(:address, addressable: organization, state: "TX", county: "") + + expect(locations.candidate_values(:state)).to eq([ "TX" ]) + expect(locations.candidate_values(:state, :county)).to be_empty + end + end +end diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index 612b03fecc..0efd38f3e8 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -95,13 +95,36 @@ def matched(params, registrations) expect(matched({ email: "amy@--aisha@" }, [ amy, aisha, sam ])).to eq([ amy.id, aisha.id ].to_set) end - it "filters by registrant city, skipping inactive addresses like the roster does" do - here = registration - create(:address, addressable: here.registrant, city: "Santa Monica") - moved_away = registration(first_name: "Sam") - create(:address, addressable: moved_away.registrant, city: "Santa Monica", inactive: true) - expect(matched({ city: "santa" }, [ here, moved_away ])).to eq([ here.id ].to_set) - expect(EventRegistration.registrant_city("santa")).not_to include(moved_away) + it "filters by work city, else the registrant's own, like the roster does" do + works_there = registration + organization = create(:organization) + create(:address, addressable: organization, city: "Santa Monica") + create(:affiliation, person: works_there.registrant, organization:) + works_elsewhere = registration(first_name: "Sam") + elsewhere = create(:organization) + create(:address, addressable: elsewhere, city: "Portland") + create(:affiliation, person: works_elsewhere.registrant, organization: elsewhere) + create(:address, addressable: works_elsewhere.registrant, city: "Santa Monica") + lives_there = registration(first_name: "Lia") + create(:address, addressable: lives_there.registrant, city: "Santa Monica") + + expect(matched({ city: "santa" }, [ works_there, works_elsewhere, lives_there ])).to eq([ works_there.id, lives_there.id ].to_set) + expect(EventRegistration.located_in(city: "santa")).to contain_exactly(works_there, lives_there) + end + + # The City search and the State dropdown have to describe the same address, like + # the roster — otherwise someone working in two states matches a city from one + # and the state from the other. + it "tests the city search and the state dropdown against the same address" do + two_states = registration + [ [ "Portland", "OR" ], [ "Seattle", "WA" ] ].each do |city, state| + organization = create(:organization) + create(:address, addressable: organization, city:, state:) + create(:affiliation, person: two_states.registrant, organization:) + end + + expect(matched({ city: "seattle", state: "WA" }, [ two_states ])).to eq([ two_states.id ].to_set) + expect(matched({ city: "seattle", state: "OR" }, [ two_states ])).to be_empty end it "filters by registration comment text" do