Skip to content
Draft
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: 3 additions & 0 deletions app/controllers/event_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions app/controllers/events/public_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/models/current.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
attribute :source
attribute :form_submission_id
end
10 changes: 8 additions & 2 deletions app/models/sectorable_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<title> - <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
Expand Down
13 changes: 13 additions & 0 deletions app/services/analytics/lifecycle_buffer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions spec/models/sectorable_item_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions spec/requests/event_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions spec/requests/events/public_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions spec/services/analytics/lifecycle_buffer_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading