diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 984538617..bd062ce62 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -755,6 +755,9 @@ def profile_diff_for(organization, linked_count: nil) def link_and_report(organization, verb:, record_fills:) link = @event_registration.event_registration_organizations.find_or_create_by!(organization: organization) entry = submission_entry_for(@event_registration, organization) + # Attribute the org writes below to the submission that named it, so linking an + # org that wasn't a clean match shows up in that submission's changes audit. + Current.form_submission_id = entry[:submission].id if entry profile_changes = sync_org_profile(organization) address_result = link_affiliations_for(@event_registration, organization) diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 91daf0dea..a675f1d03 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -70,6 +70,9 @@ def create if result.success? registration = result.event_registration + # Stamp the just-created submission onto the request's buffered lifecycle + # events (flushed after this action) so every record it wrote is traceable. + Current.form_submission_id = result.form_submission&.id if !registration.scholarship_requested? && @event.cost_cents.to_i > 0 && credit_card_payment?(registration_params) checkout_session = create_stripe_checkout_session(registration, result.form_submission) diff --git a/app/models/current.rb b/app/models/current.rb index 3bc4c88fb..0dfaa245b 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,5 @@ class Current < ActiveSupport::CurrentAttributes attribute :user attribute :source + attribute :form_submission_id end diff --git a/app/models/sectorable_item.rb b/app/models/sectorable_item.rb index e55d02c2f..b7f29fc8a 100644 --- a/app/models/sectorable_item.rb +++ b/app/models/sectorable_item.rb @@ -10,9 +10,15 @@ class SectorableItem < ApplicationRecord before_create :skip_if_duplicate # Methods + # windows_type is a WorkshopLog-only association, so only a WorkshopLog sectorable + # gets the " - <windows type>" form. Any other sectorable (a person or + # organization tagged with this sector) falls back to its own title/name — without + # this guard, calling windows_type on them raised and silently dropped the tag's + # lifecycle (Ahoy) event. def title - return id unless sectorable && sectorable.class != WorkshopLog - "#{sectorable.title} - #{sectorable.windows_type.name if sectorable.windows_type}" + return "#{sectorable.title} - #{sectorable.windows_type&.name}" if sectorable.is_a?(WorkshopLog) + + sectorable.try(:title).presence || sectorable.try(:name).presence || id.to_s end private diff --git a/app/services/analytics/lifecycle_buffer.rb b/app/services/analytics/lifecycle_buffer.rb index d40461c4f..b67439026 100644 --- a/app/services/analytics/lifecycle_buffer.rb +++ b/app/services/analytics/lifecycle_buffer.rb @@ -9,12 +9,25 @@ def self.flush(controller) return if store.empty? store.each do |payload| + stamp_form_submission(payload) controller.ahoy.track(payload[:name], payload[:properties]) end ensure store.clear end + # The submission a public form/registration writes is created partway through + # the request, after many of its lifecycle events have already been buffered, + # so the id isn't knowable at push time. Stamp it here at flush — by which + # point the controller has set Current.form_submission_id — so every record + # the submission touched can be traced back to it. + def self.stamp_form_submission(payload) + return unless Current.form_submission_id + + payload[:properties] ||= {} + payload[:properties][:form_submission_id] ||= Current.form_submission_id + end + def self.store Thread.current[:_ahoy_lifecycle_events] ||= [] end diff --git a/spec/models/sectorable_item_spec.rb b/spec/models/sectorable_item_spec.rb index 344ecf516..f5d51bcfb 100644 --- a/spec/models/sectorable_item_spec.rb +++ b/spec/models/sectorable_item_spec.rb @@ -14,6 +14,32 @@ it { should validate_uniqueness_of(:sector_id).scoped_to([ :sectorable_type, :sectorable_id ]).with_message("has already been added") } end + describe "#title" do + it "does not raise for a non-WorkshopLog sectorable (e.g. a person)" do + item = create(:person).sectorable_items.create!(sector: create(:sector, :published)) + + expect { item.title }.not_to raise_error + end + end + + describe "lifecycle tracking" do + # Regression: SectorableItem#title referenced windows_type (a WorkshopLog-only + # association), so building the Ahoy payload raised for a person/org sector tag + # and the event was silently swallowed — sector tag changes went untracked. + it "buffers a create.sectorable_item event when a person is tagged with a sector" do + person = create(:person) + Current.source = "public_registration" + allow(Analytics::LifecycleBuffer).to receive(:push).and_call_original + + person.sectorable_items.create!(sector: create(:sector, :published)) + + expect(Analytics::LifecycleBuffer).to have_received(:push) + .with(hash_including(name: "create.sectorable_item")) + ensure + Current.source = nil + end + end + # it 'is valid with valid attributes' do # # Note: Factory needs associations uncommented for create # # expect(build(:sectorable_item)).to be_valid diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 2c36706e8..9939a5d34 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -1592,6 +1592,28 @@ def details_open?(body, heading) expect(response).to redirect_to(link_organization_event_registration_path(existing_registration)) end + it "attributes the org fill to the submission that named it, so its changes audit updates" do + reg_form = create(:form, name: "Reg form") + create(:event_form, :registration, event: event, form: reg_form) + submission = create(:form_submission, person: regular_user.person, form: reg_form) + { "agency_name" => organization.name, "agency_website" => "helpinghands.org" }.each do |identifier, value| + field = create(:form_field, form: reg_form, field_identifier: identifier) + create(:form_answer, form_submission: submission, form_field: field, submitted_answer: value) + end + + tracked = [] + allow_any_instance_of(Ahoy::Tracker).to receive(:track) do |_instance, name, props| + tracked << [ name, props ] + end + + post select_organization_event_registration_path(existing_registration), + params: { organization_id: organization.id } + + org_events = tracked.select { |name, _| name == "update.organization" } + expect(org_events).not_to be_empty + org_events.each { |_name, props| expect(props[:form_submission_id]).to eq(submission.id) } + end + it "creates a job affiliation and a facilitator affiliation from the submitted position" do reg_form = create(:form, name: "Reg form") field = create(:form_field, form: reg_form, field_identifier: EventRegistrationServices::PublicRegistration::ORGANIZATION_POSITION_IDENTIFIER) diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb index 19e09c3aa..358606ab2 100644 --- a/spec/requests/events/public_registrations_spec.rb +++ b/spec/requests/events/public_registrations_spec.rb @@ -833,4 +833,78 @@ def identity_answers end end end + + describe "POST create stamps the submission id on lifecycle events" do + def field(identifier) + create(:form_field, form: form, field_identifier: identifier, name: identifier.humanize, required: false) + end + + # One question per record the registration writes, so every smart-field-induced + # change fires a lifecycle event we can assert carries the submission id. + let!(:first_field) { field("first_name") } + let!(:last_field) { field("last_name") } + let!(:email_field) { field("primary_email") } + let!(:ethnicity_field) { field("racial_ethnic_identity") } + let!(:street_field) { field("mailing_street") } + let!(:city_field) { field("mailing_city") } + let!(:state_field) { field("mailing_state") } + let!(:zip_field) { field("mailing_zip") } + let!(:phone_field) { field("phone") } + let!(:name_field) { field("agency_name") } + let!(:website_field) { field("agency_website") } + let!(:position_field) { field("agency_position") } + let!(:sector_field) do + create(:form_field, form: form, answer_type: :multi_select_checkbox, + field_identifier: "primary_service_area", name: "Primary sector", required: false) + end + let!(:age_field) do + create(:form_field, form: form, answer_type: :multi_select_checkbox, + field_identifier: "primary_age_group", name: "Age group", required: false) + end + + let!(:organization) { create(:organization, name: "Riverside Community Arts", website_url: "old.example.com") } + let!(:sector) { create(:sector, :published, name: "Healthcare") } + let!(:age_type) { create(:category_type, :published, name: "AgeRange") } + let!(:age_group) { create(:category, :published, name: "Adolescents (13-17)", category_type: age_type) } + + it "traces every record the registration wrote back to its form submission" do + tracked = [] + allow_any_instance_of(Ahoy::Tracker).to receive(:track) do |_instance, name, props| + tracked << [ name, props ] + end + + post event_public_registration_path(event), params: { public_registration: { form_fields: { + essay_field.id.to_s => "this answer has more than five words", + first_field.id.to_s => "Dana", + last_field.id.to_s => "Ruiz", + email_field.id.to_s => "dana@example.com", + ethnicity_field.id.to_s => "Prefer not to say", + street_field.id.to_s => "114 SE Alder St", + city_field.id.to_s => "Portland", + state_field.id.to_s => "OR", + zip_field.id.to_s => "97214", + phone_field.id.to_s => "5035550148", + name_field.id.to_s => "Riverside Community Arts", + website_field.id.to_s => "new.example.com", + position_field.id.to_s => "Art therapist", + sector_field.id.to_s => [ sector.id.to_s ], + age_field.id.to_s => [ age_group.id.to_s ] + } } } + + submission = FormSubmission.find_by!(form: form, event: event, role: "registration") + + lifecycle = tracked.select { |name, _| name.match?(/\A(create|update|destroy)\.[a-z_]+\z/) } + record_types = lifecycle.map { |name, _| name.split(".").last }.uniq + + # Every kind of record a smart field touches must be represented… + expect(record_types).to include( + "person", "address", "contact_method", "organization", "affiliation", + "sectorable_item", "categorizable_item", "event_registration", "form_submission" + ) + + # …and each of those events must trace back to this submission. + unstamped = lifecycle.reject { |_name, props| props[:form_submission_id] == submission.id } + expect(unstamped.map(&:first)).to be_empty + end + end end diff --git a/spec/services/analytics/lifecycle_buffer_spec.rb b/spec/services/analytics/lifecycle_buffer_spec.rb new file mode 100644 index 000000000..6bfaf1388 --- /dev/null +++ b/spec/services/analytics/lifecycle_buffer_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe Analytics::LifecycleBuffer do + after do + described_class.store.clear + Current.form_submission_id = nil + end + + def build_controller(tracked) + ahoy = instance_double(Ahoy::Tracker) + allow(ahoy).to receive(:track) { |name, props| tracked << [ name, props ] } + double("controller", ahoy: ahoy) + end + + it "stamps the current form submission id onto every flushed event's properties" do + Current.form_submission_id = 42 + described_class.push(name: "update.organization", properties: { resource_type: "Organization" }) + described_class.push(name: "create.affiliation", properties: { resource_type: "Affiliation" }) + + tracked = [] + described_class.flush(build_controller(tracked)) + + expect(tracked).to contain_exactly( + [ "update.organization", hash_including(form_submission_id: 42) ], + [ "create.affiliation", hash_including(form_submission_id: 42) ] + ) + end + + it "leaves properties untouched when no form submission id is set" do + described_class.push(name: "update.person", properties: { resource_type: "Person" }) + + tracked = [] + described_class.flush(build_controller(tracked)) + + expect(tracked.first.last).not_to have_key(:form_submission_id) + end + + it "does not overwrite a form submission id already on the payload" do + Current.form_submission_id = 42 + described_class.push(name: "update.organization", properties: { form_submission_id: 7 }) + + tracked = [] + described_class.flush(build_controller(tracked)) + + expect(tracked.first.last[:form_submission_id]).to eq(7) + end +end