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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ action, or `authorize! :workshop, to: :summary?`).
- `NewsSubscriptionCapture` β€” Successor to the retired person-level mailing-list consent flag: given a `person` + `source`, idempotently creates an active **News** `TopicSubscription` (skips when one is already active, no-op when the News topic isn't seeded). Called from `PublicRegistration` (source = event) and `PublicFormSubmission` (source = form) when the `communication_consent` question is answered
- `OrganizationServices::CaptureFromSubmission` β€” The shared org-capture for **both** the event registration (`PublicRegistration`) and standalone (`PublicFormSubmission`) flows: resolves the submitted organization by name (`FormField::ORGANIZATION_NAME_FIELD_IDENTIFIER`, case-insensitive per the column collation; never creates one β€” an unmatched name is left for an admin), syncs its website/type (`SyncProfile`, latest-wins) and work address (`UpsertAddress`, which adds another work address rather than rewriting a different one). `AffiliationServices::CreateFromRegistration` then mints the person's job affiliation from the typed position, plus the standing Facilitator one when `facilitator_training:` β€” which only an event supplies (`@event.facilitator_training`), so a standalone form gets the job affiliation alone, the same as registering for a non-training event. **Agreement-role submissions never come through here** β€” they run `LinkSubmittedOrganization`, which owns scenario end-dating and the new-job start date. Returns the org + the autofill changes, which the event flow records on its registration↔org link
- `PersonServices::CaptureFromSubmission` β€” The shared person-capture for **both** the event registration (`PublicRegistration`) and standalone (`PublicFormSubmission`) flows, run for a newly-created **and** an existing person, so the person smart fields on the Smart form settings page behave identically across both forms. Fills identity extras (pronouns, name `pronunciation`, `nickname` β†’ first name / legal first name, primary email type, secondary email + type), the profile (`racial_ethnic_identity` + `SyncSharingPreferences`), the mailing address, the phone contact method, and sector/age tags (mirrored onto any organizations passed in). A non-blank answer overwrites what's on file (newest submission wins); a blank never clobbers. Sector and age tags both run through `SubmissionTagging` and follow one rule on the **person**: submitting both a primary and additional set **overwrites** the person's tags with the latest (`replace: true`), submitting only a primary **reassigns** the primary and leaves the rest (additive); the org mirror always stays additive (orgs aggregate their members' tags). Self-gates on each field identifier, so a form that doesn't ask a question no-ops for it
- `PersonServices::ImportFromRolodex` β€” Creates people from FileMaker Rolodex archive rows (`FmRolodex`), carrying the row's ID as `filemaker_code` plus every field `FmRolodex::PORTAL_FIELDS` maps onto a person (profile fields, email type, facilitator start β†’ `member_since`, service areas β†’ sectors and `PrimaryAgeGroup` β†’ age group via `SubmissionTagging`, and the archive's own entry/modification dates as `created_at`/`updated_at`), plus every address and phone the row answers to (`FmRolodex#addresses` / `#phone_numbers`, primary first). Links an existing person through `PersonMatcher`; flags email-only, same-name-no-email, and already-linked collisions as ambiguous for hand review; skips agencies, soft-deleted, and nameless rows. Counts service areas no sector answers to in `#unmatched_tags`. Idempotent on `filemaker_code`. Driven by `fm:import_people`
- `PersonServices::SyncSharingPreferences` β€” Writes the two content-sharing answers (`anonymous_contributions`, `display_name_preference`) onto the `Person`, mapping each submitted option label back to its stored value; an unanswered or unrecognized answer leaves the profile alone, so a form that doesn't ask the questions is a no-op and every flow can call it unconditionally. The person-side counterpart to `OrganizationServices::SyncProfile`. Shared by `PersonServices::CaptureFromSubmission` (so a submission's answers land in the "what this submission changed" audit via the Ahoy trail) and `CalloutFormSubmission` (whose `#profile_changes` re-reports the same edits for its own Ahoy tracking)
- `EventRegistrationServices::TransferContinuingEducation` β€” Splits/relocates a registrant's CE when they transfer events (issue #1944): a simple forward transfer leaves a paid, zero-hours **stub** on the source (its payments count at the original event) and creates a **live** record on the destination carrying the hours and the outstanding balance; when the reg being transferred out is itself a transfer-in (a collapsing double transfer, or a transfer back to the origin) its live record is relocated forward β€” merging back into the origin's stub β€” instead of split again, so no third record appears. Runs inside the transfer transaction, after the destination is saved and before a collapsing middle reg is destroyed
- `EventRegistrationServices::ReconcileMergedContinuingEducation` β€” Run from the event-registration deduper's `after_merge`. A CE registration moved from the deleted registration still points at that (deleted) registrant's license β€” invalid, since the registration now belongs to the kept registrant β€” so this repoints each to the kept registrant's equivalent license (same kind + number, found or created), then folds the resulting duplicates into one with `ContinuingEducationDeduper` so payments are preserved
Expand Down Expand Up @@ -561,7 +562,7 @@ RuboCop linting on PRs and pushes to main.

## Rake Tasks

Located in `lib/tasks/` (13 files):
Located in `lib/tasks/` (16 files):
- `dev.rake` β€” Development database seeding from XML/CSV
- `rhino_migrator.rake` β€” Rich text editor migration
- `attachment_report.rake` β€” Attachment reporting
Expand All @@ -573,6 +574,8 @@ Located in `lib/tasks/` (13 files):
- `migrate_workshop_logs.rake` β€” Workshop log migration
- `backfill_user_stamps.rake` β€” Backfill `created_by_id`/`updated_by_id` on legacy rows from the Ahoy trail (`data:backfill_user_stamps`)
- `backfill_notification_stamps.rake` β€” Backfill notifications' `created_by_id`/`updated_by_id` from `sender_id`, nil-only (`data:backfill_notification_stamps`)
- `fm_import.rake` β€” Load the FileMaker CSV exports into the `fm_*` archive tables (`fm:import`, reads each CSV from the `FM Archive` resources, batch `insert_all`, skips rows whose key is already stored)
- `fm_import_people.rake` β€” Create people from the FileMaker Rolodex archive (`fm:import_people`, via `PersonServices::ImportFromRolodex`, links existing by name + email, idempotent, DRY_RUN; VERBOSE=true lists every row)
- `resolve_resource_organizations.rake` β€” Link resources to organizations from the legacy `agency` free-text name (`data:resolve_resource_organizations`, match-or-create, idempotent, DRY_RUN)
- `seed_survey_forms.rake` β€” Create the Day 1 / Day 2 / recipients survey template forms without the full db:seed (`data:seed_survey_forms`, via `SurveyFormSeeder`, idempotent on form name; run before configuring the survey callouts)
- `seed_close_program_form.rake` β€” Create the Close Program agreement form without the full db:seed (`data:seed_close_program_form`, via `CloseProgramFormSeeder`, idempotent on form name; give it a slug and publish it in the form builder to use it)
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 @@ -382,7 +382,7 @@ def person_known_identifiers(person)
if person.addresses.exists?
address = person.addresses.find_by(primary: true) || person.addresses.first
keys << "mailing_street" if address.street_address.present?
keys << "mailing_address_type" if address.address_type.present?
keys << "mailing_address_type" if address.known_type?
keys << "mailing_city" if address.city.present?
keys << "mailing_state" if address.state.present?
keys << "mailing_zip" if address.zip_code.present?
Expand Down
19 changes: 18 additions & 1 deletion app/models/address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ class Address < ApplicationRecord

LOCALITIES = [ "LA City", "LA County", "Southern CA", "Northern CA",
"Central CA", "Orange County", "Outside CA", "Outside USA", "Unknown" ]
CONTACT_TYPES = [ nil, "work", "personal", "mailing", "unknown" ].freeze
UNKNOWN_CONTACT_TYPE = "unknown"
CONTACT_TYPES = [ nil, "work", "personal", "mailing", UNKNOWN_CONTACT_TYPE ].freeze
# USPS abbreviations for the 50 states, DC, and the US territories the atlas
# draws β€” the whitelist behind every "States" breakdown, so international
# regions (e.g. "ON", "England") are excluded (they belong to the Countries map).
Expand All @@ -19,6 +20,8 @@ class Address < ApplicationRecord
# 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

before_validation :type_as_unknown

validates :locality, presence: true
validates :city, presence: true, length: { maximum: 255 }
validates :state, presence: true
Expand All @@ -35,4 +38,18 @@ class Address < ApplicationRecord
def name
"#{street_address}, #{city}, #{state} #{zip_code}"
end

# Unknown is a recorded answer, not a known one, so a flow that asks what kind of
# address this is still has to ask.
def known_type?
address_type.present? && address_type != UNKNOWN_CONTACT_TYPE
end

private

# Every address carries a type, so one saved without an answer says so. Rows written
# before this keep their blank until something saves them.
def type_as_unknown
self.address_type = UNKNOWN_CONTACT_TYPE if address_type.blank?
end
end
13 changes: 12 additions & 1 deletion app/models/contact_method.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class ContactMethod < ApplicationRecord
CONTACT_TYPES = [ nil, "work", "personal" ].freeze
UNKNOWN_CONTACT_TYPE = "unknown"
CONTACT_TYPES = [ nil, "work", "personal", UNKNOWN_CONTACT_TYPE ].freeze

belongs_to :contactable, polymorphic: true
belongs_to :created_by, class_name: "User", optional: true
Expand All @@ -12,6 +13,16 @@ class ContactMethod < ApplicationRecord
whatsapp: "whatsapp"
}

before_validation :type_as_unknown

validates :value, presence: true, length: { maximum: 255 }
validates :kind, presence: true

private

# Every contact method carries a type, so one saved without an answer says so. Rows
# written before this keep their blank until something saves them.
def type_as_unknown
self.contact_type = UNKNOWN_CONTACT_TYPE if contact_type.blank?
end
end
7 changes: 6 additions & 1 deletion app/services/address_deduper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ class AddressDeduper
def self.signature(address)
return unless address

IDENTITY_COLUMNS.map { |column| address.public_send(column).to_s.strip.downcase }
IDENTITY_COLUMNS.map do |column|
value = address.public_send(column).to_s.strip.downcase
# A row written before an untyped address said so holds a blank, so unknown and
# blank have to read alike or an otherwise identical pair won't fold.
value == Address::UNKNOWN_CONTACT_TYPE && column == "address_type" ? "" : value
end
end

# The signature of the primary address to preserve, read before the merge while
Expand Down
12 changes: 9 additions & 3 deletions app/services/person_matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ def initialize(email:, last_name:, first_names: nil)
end

def call
return if @email.blank? || @last_name.blank?
return if @first_names&.empty?
matches.first
end

# Everyone the name + email reaches, for a caller that has to tell one match from
# several rather than take the first.
def matches
return Person.none if @email.blank? || @last_name.blank?
return Person.none if @first_names&.empty?

scope = Person.left_joins(:user)
.where("LOWER(people.last_name) = ?", @last_name)
Expand All @@ -24,6 +30,6 @@ def call
"LOWER(people.first_name) IN (:names) OR LOWER(COALESCE(people.legal_first_name, '')) IN (:names)",
names: @first_names
) if @first_names
scope.first
scope
end
end
Loading
Loading