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 "
- " 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