Skip to content
Open
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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
| `app/models/` | ActiveRecord models | ~146 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for the stories spreadsheet import) | ~152 files |
| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for the stories spreadsheet import) | ~153 files |
| `app/jobs/` | SolidQueue background jobs | 6 files |
| `app/models/concerns/` | Shared model modules | 23 concerns |
| `app/models/concerns/` | Shared model modules | 25 concerns |

### Presentation

Expand Down Expand Up @@ -152,6 +152,7 @@ This codebase (Rails 8.1)
| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization, Affiliation, Payment, Workshop, Resource): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). `filemaker_archived_in` names the `Fm*` archive table(s) a model's codes point into (`.filemaker_archives`), and `.with_filemaker_code(code)` finds records carrying a code. Nothing should compare the whole column against a single code β€” `FilemakerHelper#filemaker_code_links` renders one archive link per code, and `fm_archives#show` links back to the portal records carrying its code |
| `Mentionable` | ActionText mention grouping, plus `connected_records(klass)` β€” records linked by a mention in either direction (the only Resource↔Workshop link: `Resource#connected_workshops`, `Workshop#connected_resources`) |
| `NameFilterable` | Name-based filtering |
| `OutsideUsaTypeable` | `typed_outside_usa :state` adds the `<attribute>_outside_usa` box `shared/_outside_usa` reveals beside a US-state dropdown; a typed value wins whichever is assigned first (ProfessionalLicense, Address, ProfileChangeRequest) |
| `PayChargeExtensions` | Included into `Pay::Charge` (via `config/initializers/pay.rb`): turns a Stripe charge into an AWBW `Payment` + `Allocation`, resolving the membership invoice it pays |
| `PaySubscriptionExtensions` | Included into `Pay::Subscription`: adds `active_uncanceled` β€” Pay's own `active` scope still counts a canceled subscription riding out its grace period, so this narrows to the ones that will keep billing |
| `Publishable` | `published`, `publicly_visible` scopes |
Expand Down Expand Up @@ -273,6 +274,7 @@ comparison β€” add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve
- `FmAddressAttributes` β€” Turns an `FmAddress` row into `Address` attributes for any FileMaker import (street from `StreetNo` + lines, ZIP or its `PostalCodeLookup`, county from the archive's ZIP table read once, everything clipped to 255), with state/country/type/locality through `AddressTranslation`; nil when the row lacks a street, city or state. An `address_type:` overrides the row's (an organization's addresses are work); `#place(city:, state:, county:)` builds a street-less address for a place the archive records by city alone (a project). `#unmatched` gathers what it couldn't translate. Used by `PersonServices::ImportFromRolodex` (listed in `fm:import_people`'s report) and the FileMaker organizations/affiliations imports (ADR-0008)
- `AddressDefaults` β€” The country, county and locality an address leaves blank, from its other parts: a US state β†’ United States (`Address::DEFAULT_COUNTRY`), a US ZIP β†’ its county (`FmPostalCode.county_for`, FileMaker's ZIP table, skipped when the ZIP is in another state), then `AddressLocality`. Used on create by `OrganizationServices::UpsertAddress` (both org-linking flows and public registration capture), `PersonServices::CaptureFromSubmission` and `ProfileChangeRequests::Apply`; `UpsertAddress` also fills a matched address's blank country/county and blank or Unknown locality, never replacing a value someone set
- `AddressLocality` β€” Works out which `Address::LOCALITIES` bucket an address falls in from its own city/state/country/county, for a writer that has an address but never asked the question (`PersonServices::ImportFromRolodex`, and through `AddressDefaults` for the rest β€” a locality a person picked is never overwritten). Reads a named non-US country as **Outside USA**, a non-CA US state as **Outside CA**, a mailing city inside the City of Los Angeles β€” Los Angeles or a neighborhood (`LA_CITY_MAILING_CITIES`) β€” as **LA City**, the rest of Los Angeles County as **LA County**, Orange County as **Orange County**, and any other California county by `CALIFORNIA_REGIONS` (the region most FileMaker projects in that county name, plus a hand-checked few) as **Northern/Central/Southern CA**. A state no US state answers to, with no country, is **Outside USA** (the forms only offer US states). Everything else stays **Unknown**: a California address with no or an unplaced county, and a US address whose state no US state answers to. Every comparison goes through `AddressNormalizer`. FileMaker records a `Locality` only on a **project**, so the organizations pass (ADR-0008) carries that across rather than deriving it; a person's own address has none
- `OutsideUsaState` β€” Resolves a US-state dropdown + "Outside USA?" box pair: `.pick(dropdown, typed)` for raw params (CE callout, admin CE registration) and `.form_field_answers(params, scope)`, which lays `form_fields_outside_usa` answers over `form_fields` for every `_state` question (public forms, registration, callout forms). The box itself is `shared/_outside_usa` β€” a no-JS `<details>`
- `AddressDeduper` β€” Merges a person's or organization's addresses that are one place into a single address. `.matching_sets` groups by `AddressNormalizer.signature`, then merges a less complete copy (blank ZIP, street, country, …) into the single fuller address whose every part it shares β€” skipped when it fits more than one; works fullest-first so a chain of partial copies lands on the fullest. Each merge goes through `ModelDeduper#merge`, which repoints the merged address's inbound FKs β€” `contact_methods.address_id`, `invoices.bill_to_address_id`, `affiliations.organization_address_id` β€” onto the survivor. Within a set the survivor is the copy still in use β€” active before inactive, then the primary, then the oldest (`.keeper_order`) β€” since `Address.active` drives bill-to addresses, an org's program location and the geographic breakdowns; blank columns on the survivor (`CARRIED_COLUMNS` β€” the street/ZIP/country/county/district/type/locality, `phone` and the three `la_*` districts; "unknown" type/locality counts as blank) are filled from the merged copies. Then settles a single primary on the set holding the signature captured in the deduper's `merge_keeper` (the survivor's own pre-merge primary address, else the deleted record's), demoting the rest. Runs in the people and organizations dedupers' `after_merge`; `AddressDeduper.merge_duplicates(addressable)` runs it standalone (keeping the owner's current primary) for the **Dedupe addresses** admin page (`AddressesController#dedupe_index`/`#dedupe_perform`)
- `AddressServices::DuplicateFinder` β€” Lists the people and organizations holding addresses that `AddressDeduper.matching_sets` would merge, for the **Dedupe addresses** page. Compares only within one owner β€” two orgs at one street are separate records, not duplicates
- `PersonServices::ReconcilePrimaryDesignations` β€” After a person merge, keeps the survivor's own primary sector + primary age range and demotes the duplicate's, which would otherwise leave the kept person with two primaries and trip Person's single-primary validations. The survivor's pre-merge primary ids are captured in the people deduper's `merge_keeper` (they're indistinguishable once merged); reconciliation runs in `after_merge`
Expand Down Expand Up @@ -437,7 +439,7 @@ end
- `asset_picker` β€” Asset selection UI
- `autosave` β€” Auto-save form state; optional `status` targets (`data-field-name` = the field's name) show "Saved" or "Not saved: <reason>" beside a field via `shared/_autosave_status` (dedupe preview, index notes, notification responded toggles)
- `carousel` β€” Swiper-based carousels (opt-in `Autoplay` via the options value)
- `ce_license_picker` β€” Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one)
- `ce_license_picker` β€” Fill the CE license type/number/state/country/expiry fields from the picked license (or clear them for a new one), opening the "Outside USA?" box for a license issued abroad
- `cocoon` β€” Nested form handling (cocoon gem)
- `collection` β€” Filter form auto-submit with debounce
- `column_toggle` β€” Toggle table column visibility
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ def license_for_create
def apply_ce_params(ce_registration)
ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number),
kind: params.dig(:continuing_education_registration, :license_kind),
issuing_state: params.dig(:continuing_education_registration, :license_issuing_state),
issuing_state: OutsideUsaState.pick(params.dig(:continuing_education_registration, :license_issuing_state),
params.dig(:continuing_education_registration, :license_issuing_state_outside_usa)),
issuing_country: params.dig(:continuing_education_registration, :license_issuing_country),
expires_on: params.dig(:continuing_education_registration, :license_expires_on),
license_id: params.dig(:continuing_education_registration, :professional_license_id))
ce_registration.hours = params.dig(:continuing_education_registration, :hours)
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ def update_ce_license
end

ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind],
issuing_state: params[:license_issuing_state], expires_on: params[:license_expires_on])
issuing_state: OutsideUsaState.pick(params[:license_issuing_state], params[:license_issuing_state_outside_usa]),
issuing_country: params[:license_issuing_country], expires_on: params[:license_expires_on])
ce_registration.save!
# assign_license already normalized the number; mirror the saved value rather
# than re-stripping the raw param.
Expand Down Expand Up @@ -456,7 +457,7 @@ def ce_form_locked?(callout)
end

def callout_form_params
params.dig(:callout_form, :form_fields)&.to_unsafe_h || {}
OutsideUsaState.form_field_answers(params, :callout_form)
end

# Attendance sign-in/out follows the CE payment β€” it's the CE sign-in sheet. Any-of
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/events/public_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def create
@scholarship_form = @event.scholarship_form if @scholarship
@continuing_education_form = @event.continuing_education_form

all_params = merge_retained_uploads(params.dig(:public_registration, :form_fields)&.to_unsafe_h || {})
all_params = merge_retained_uploads(OutsideUsaState.form_field_answers(params, :public_registration))
registration_params, scholarship_params, continuing_education_params = split_form_params(all_params)

@field_errors = validate_required_fields(registration_params)
Expand Down
1 change: 1 addition & 0 deletions app/controllers/organizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def organization_params
:street_address,
:city,
:state,
:state_outside_usa,
:zip_code,
:county,
:country,
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/professional_licenses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@ def update
private

def professional_license_params
params.require(:professional_license).permit(:person_id, :number, :kind, :issuing_state, :expires_on)
params.require(:professional_license).permit(:person_id, :number, :kind, :issuing_state, :issuing_state_outside_usa, :issuing_country, :expires_on)
end

# The registrant is fixed once a license exists β€” edits change the license
# fields only, never who it belongs to.
def license_edit_params
params.require(:professional_license).permit(:number, :kind, :issuing_state, :expires_on)
params.require(:professional_license).permit(:number, :kind, :issuing_state, :issuing_state_outside_usa, :issuing_country, :expires_on)
end
end
2 changes: 1 addition & 1 deletion app/controllers/profile_change_requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def profile_change_request_params
params.require(:profile_change_request).permit(
:field, :requested_value, :details, :affiliation_id, :organization_id, :professional_license_id, :proposed_email_type,
:proposed_title, :proposed_start_date, :proposed_end_date, :proposed_organization_name, :proposed_organization_address_id,
:proposed_street_address, :proposed_city, :proposed_state, :proposed_zip_code, :leaving_job, left_organization_ids: []
:proposed_street_address, :proposed_city, :proposed_state, :proposed_state_outside_usa, :proposed_zip_code, :leaving_job, left_organization_ids: []
)
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/public_forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def create

@form_fields = public_form_fields.visible
@prefill_values = {}
form_params = merge_retained_uploads(params.dig(:public_registration, :form_fields)&.to_unsafe_h || {})
form_params = merge_retained_uploads(OutsideUsaState.form_field_answers(params, :public_registration))

@field_errors = validate_required_fields(form_params)
if @field_errors.any?
Expand Down
15 changes: 14 additions & 1 deletion app/decorators/professional_license_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ class ProfessionalLicenseDecorator < ApplicationDecorator
# One line identifying the license, e.g. "LMFT Β· 12345 Β· CA Β· expires June 30, 2027".
def summary
expires = "expires #{expires_on.to_fs(:long)}" if expires_on
[ kind, number, issuing_state, expires ].compact_blank.join(" Β· ")
[ kind, number, issuing_place, expires ].compact_blank.join(" Β· ")
end

# "CA", or "Ontario, Canada" for a license issued outside the USA.
def issuing_place
[ issuing_state, (issuing_country if foreign_country?) ].compact_blank.join(", ")
end

def outside_usa?
h.outside_usa_state?(issuing_state) || foreign_country?
end

# Status pill combining validity + expiry date: expired on / expiring soon /
Expand Down Expand Up @@ -44,6 +53,10 @@ def ce_hours_issued_all_years

private

def foreign_country?
issuing_country.present? && issuing_country != Address::DEFAULT_COUNTRY
end

# Sum CE hours from registrations whose certificate has been sent, optionally
# narrowed by the given block. Whole totals drop the decimal.
def issued_ce_hours
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { Controller } from "@hotwired/stimulus"

// CE license picker. When the admin chooses a license from the dropdown, fill the
// type / number / state / expiry fields from that license (carried on each option's
// data attributes) so the fields describe the selected license. Choosing "Create
// new license" clears them for a fresh entry.
// type / number / state / country / expiry fields from that license (carried on each
// option's data attributes) so the fields describe the selected license. Choosing
// "Create new license" clears them for a fresh entry. A state the US list can't show
// goes in the "Outside USA?" box, opened along with any country on file.
export default class extends Controller {
static targets = ["select", "kind", "number", "state", "expires"]
static targets = ["select", "kind", "number", "state", "typedState", "country", "outsideUsa", "expires"]

sync() {
const option = this.selectTarget.selectedOptions[0]
if (!option) return

const isNew = this.selectTarget.value === "new"
const state = isNew ? "" : (option.dataset.state || "")
const listed = [...this.stateTarget.options].some((stateOption) => stateOption.value === state)
this.kindTarget.value = isNew ? "" : (option.dataset.kind || "")
this.numberTarget.value = isNew ? "" : (option.dataset.number || "")
this.stateTarget.value = isNew ? "" : (option.dataset.state || "")
this.stateTarget.value = listed ? state : ""
this.typedStateTarget.value = listed ? "" : state
this.countryTarget.value = isNew ? "" : (option.dataset.country || "")
this.outsideUsaTarget.open = option.dataset.outsideUsa === "true"
this.expiresTarget.value = isNew ? "" : (option.dataset.expires || "")
}
}
11 changes: 10 additions & 1 deletion app/helpers/geography_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,22 @@ def us_states
]
end

def us_state?(abbreviation)
us_states.any? { |_name, abbr| abbr == abbreviation }
end

# A saved state the US-state dropdown can't show, which belongs in the "Outside USA?" box.
def outside_usa_state?(state)
state.present? && !us_state?(state)
end

# State dropdown options that always include the address's current value, even
# when it isn't a US state (e.g. an international province like "ON" or
# "England"). Without this the US-only list can't match such a value, so the
# select renders blank and saving wipes the state β€” tripping the presence
# validation on an otherwise-untouched address.
def state_select_options(current = nil)
return us_states if current.blank? || us_states.any? { |_name, abbr| abbr == current }
return us_states if current.blank? || us_state?(current)

us_states + [ [ current, current ] ]
end
Expand Down
5 changes: 5 additions & 0 deletions app/models/address.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Address < ApplicationRecord
include OutsideUsaTypeable

belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true

Expand Down Expand Up @@ -31,6 +33,9 @@ class Address < ApplicationRecord
].freeze

belongs_to :addressable, polymorphic: true, touch: true

typed_outside_usa :state

# Affiliations that point to this address as their organization address. Nullify
# the link rather than block deletion when an org address is removed.
has_many :affiliations, foreign_key: :organization_address_id, dependent: :nullify, inverse_of: :organization_address
Expand Down
22 changes: 22 additions & 0 deletions app/models/concerns/outside_usa_typeable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Pairs a state attribute with the `<attribute>_outside_usa` box from
# shared/_outside_usa. A typed value wins over the dropdown whichever one params
# assign first, and writes straight to the column so nested autosave sees the change.
module OutsideUsaTypeable
extend ActiveSupport::Concern

class_methods do
def typed_outside_usa(attribute)
typed = :"#{attribute}#{OutsideUsaState::SUFFIX}"
attr_reader typed

define_method(:"#{typed}=") do |value|
instance_variable_set(:"@#{typed}", value.to_s.strip.presence)
public_send(:"#{attribute}=", public_send(attribute))
end

define_method(:"#{attribute}=") do |value|
super(OutsideUsaState.pick(value, public_send(typed)))
end
end
end
end
Loading
Loading