Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ This codebase (Rails 8.1)
| `AgeGroupTaggable` | Splits AgeRange category taggings into primary/additional via `categorizable_items.is_primary` (Person). `replace: true` treats the submitted set as the whole truth; demotions save row by row so they reach the change log |
| `AhoyTrackable` | Event tracking integration. Included by the tag join models (`SectorableItem`, `CategorizableItem`) too, so a submission's tag adds/removals/primary flips reach `FormSubmissionChanges` |
| `CategoriesTaggable` | Enforces a single primary category via `categorizable_items.is_primary` (Story, StoryIdea); any type validates, but the story forms and `TagAssignable` star only `AUDIENCE_CATEGORY_TYPES` (AgeRange + StoryPopulation). Also `audience_categories` |
| `ContactSearchable` | The prefixed "find them by where they are" search, generalised out of the people index's Contact info box (`PeopleController`, Justin's #2825) and shared by the attendees page and the organizations index. **It does not replace that box** β€” `Person` keeps `search_contact` exactly as the people index has always had it and takes this one alongside as `search_location` (`searches_contact :contact_methods, :active_addresses, as: :search_location`); `as:` exists for that. `CONTACT_SEARCH_FIELDS` maps the prefix someone types to its column(s) β€” phone, street, city, state, zip, county, country, locality, district, the three LA districts, and `type:` for work/personal/mailing. SearchCop ANDs the prefixes onto the **same joined address**, so `type: personal city: austin` is how you search a personal city alone. `SCOPING_PREFIXES` stay out of the bare-term catch-all (`type:` is a word streets are called; the LA districts are integer columns). Searches **live addresses only** (`active_addresses`) β€” a retired address is still the right place to show someone (`Address::STANDING`) but a wrong place to find them. Grammar for the boxes that use it: several words AND (in any word order), `--` means either (`ALTERNATIVE_SEPARATOR`), quotes make a phrase, commas become spaces unless the query is quoted. The two combine β€” `amy jones--mary davis` is `(amy AND jones) OR (mary AND davis)`, which is why `expand_alternatives` parenthesises each alternative before SearchCop sees it: unparenthesised, `or` binds to the last word alone and the query matches nobody. (For the same reason Oregon has to be asked for as `state: OR`; a bare `or` is the operator.) The non-SearchCop boxes reach the same grammar through `alternatives` (`EventRegistration.keyword` ANDs each word's whole OR block; `ReminderRecipientFilter#any_term?` ANDs the words of each alternative). `SEARCH_SYNTAX_HINT` / `CONTACT_SEARCH_HINT` caption those boxes from these constants so help text can't drift β€” **the people index keeps its own hardcoded hint**, because its box keeps its own grammar |
| `Communicable` | Gives a record the `notifications` association, nested attributes, a default `communications_scope`, and a `before_validation` addressing a hand-logged communication to the record's `communications_email`. Included by every model whose form renders `shared/_comments_and_communications` |
| `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page. Anonymity is a one-way latch: the profile or the record can set it, neither can strip it from the other. `credits_to_org` flips the unattributed label to "AWBW Staff"; `credits_creator` (idea models, workshop logs) credits the submitting account's person when no author is named |
| `Featureable` | `featured`, `publicly_featured` scopes |
Expand Down Expand Up @@ -237,7 +238,7 @@ 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
- `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)
- `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 (plus `any:` terms, which may match any field) β€” chaining two calls is what lets someone working in two places answer a city from one and a county from the other. `.parse_query` reads the prefixed "Where they work" box (`FIELD_PREFIXES` β€” city, county, state, country, locality, zip, district, the LA districts; an unknown prefix or bare word becomes an any-field term rather than an error), and `.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
Expand Down
11 changes: 9 additions & 2 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,10 @@ def filtered_attendees
scope = Person.where(id: attendee_registrations.select(:registrant_id))
# Dash-joined person ids, e.g. from the reports-hub participation totals.
scope = scope.where(id: params[:registrant_ids].to_s.split("-")) if params[:registrant_ids].present?
scope = scope.search_by_params({ contact_info: params[:contact_info] }) if params[:contact_info].present?
scope = scope.search_name_email(params[:name_email]) if params[:name_email].present?
# ContactSearchable's prefixed search over the person's own live addresses and
# phones β€” the people index's box generalised, not the box itself.
scope = scope.search_location(params[:contact_info]) if params[:contact_info].present?
scope = scope.where(id: person_sector_ids(params[:sector])) if params[:sector].present?
scope = scope.where(id: person_affiliation_status_ids(params[:affiliation_status])) if params[:affiliation_status].present?
# Breakdown drill-in filters (set when a chart row is clicked).
Expand All @@ -871,8 +874,12 @@ def filtered_attendees
# 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.
# The typed box first, the dropdowns over the top of it: a control someone can
# see wins over the same field typed into the box.
controls = { state: params[:state], county: params[:county],
country: params[:country], city: params[:city] }.compact_blank
location_filters = PersonLocations.filters_from(
state: params[:state], county: params[:county], city: params[:city], country: params[:country]
**PersonLocations.parse_query(params[:work_location]).merge(controls)
)
if location_filters.nil?
scope = scope.none
Expand Down
10 changes: 10 additions & 0 deletions app/controllers/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ def index
filters = authorized_scope(params, as: :index).to_unsafe_h
name_email = filters.delete(:name_email)
contact_info = filters.delete(:contact_info)
work_location = filters.delete(:work_location)
filtered = base_scope.search_by_params(filters)
filtered = filtered.search_name_email(name_email)
filtered = filtered.search_contact(contact_info)
filtered = filtered.distinct if contact_info.present?
filtered = located_by_work_location(filtered, work_location) if work_location.present?
filtered = filtered.order(:first_name, :last_name)

total = filtered.count
Expand All @@ -51,10 +53,12 @@ def email_addresses
filters = params.to_unsafe_h
name_email = filters.delete(:name_email)
contact_info = filters.delete(:contact_info)
work_location = filters.delete(:work_location)
people = Person.includes(:user).search_by_params(filters)
people = people.search_name_email(name_email)
people = people.search_contact(contact_info)
people = people.distinct if contact_info.present?
people = located_by_work_location(people, work_location) if work_location.present?
@email_addresses = people.filter_map { |person| person.preferred_email.presence }.uniq.sort
end

Expand Down Expand Up @@ -379,6 +383,12 @@ def check_duplicates

private

def located_by_work_location(people, query)
filters = PersonLocations.filters_from(**PersonLocations.parse_query(query))
return people.none if filters.nil?
people.where(id: PersonLocations.new(people.select(:id)).matching(**filters).map(&:registrant_id))
end

def dedupe_config
{
model_class: Person,
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/events_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def time_period_to_event_year(time_period)
REPORT_SUBNAV_PARAMS = %i[
event_id event_type search funder_sgid time_period view registrant_ids
attendance_status payment_status funder scholarship
sector contact_info affiliation_status organization_id org_city state county
sector name_email contact_info work_location affiliation_status organization_id org_city state county
age_group life_experience country school_district program_status
].freeze

Expand Down
Loading
Loading