Class: Lead
- Inherits:
-
BaseFormObject
- Object
- BaseFormObject
- Lead
- Defined in:
- app/forms/lead.rb
Overview
Form object: lead.
Defined Under Namespace
Classes: MidAtlanticScrapper, NuheatDealerScrapper
Constant Summary collapse
- SUPPORT_CASE_DEDUPE_WINDOW =
Window in which an identical support-form submission is treated as a
duplicate of an already-created case rather than a fresh case. 5.minutes
- SUPPORT_CASE_LOCK_PREFIX =
Lock-key prefix for the advisory lock that serializes concurrent
create_lead_support_casecalls on the same description fingerprint. 'lead_support_case'
Class Method Summary collapse
-
.application_type ⇒ Array<String>
Heating application types offered, for the lead form's select options.
-
.floor_type_indoor ⇒ Array<String>
Indoor floor-covering options, for the lead form's select options.
-
.floor_type_outdoor ⇒ Array<String>
Outdoor surface options, for the lead form's select options.
-
.project_status ⇒ Array<String>
Buyer-journey stages, for the lead form's select options.
-
.room_type_indoor ⇒ Array<String>
Indoor room options, for the lead form's select options.
-
.room_type_outdoor ⇒ Array<String>
Outdoor area options, for the lead form's select options.
-
.subfloor_type_indoor ⇒ Array<String>
Indoor subfloor options, for the lead form's select options.
Instance Method Summary collapse
-
#append_contact_point_if_new(contact, category, value) ⇒ ContactPoint?
Build a fresh ContactPoint on
contactonly when the normalized value doesn't already exist on the party. -
#apply_sms_consent(contact) ⇒ void
When the lead checked the SMS-consent box, flip the ContactPoint for the phone they entered to sms_enabled so the SMS sender (
sms_numbersscope) may text them. -
#build_support_case(party, description) ⇒ SupportCase
Build and save a new low-priority Tech SupportCase with the given description, wiring up participants/rooms for +party+ and associating any sketch uploads after the case persists.
-
#create_lead_activity(party) ⇒ Activity
Create the lead Activity for +party+, attaching any sketch uploads (Dragonfly/Uppy) and stamping the LEAD_FORM activity type.
-
#create_lead_support_case(party) ⇒ SupportCase
Create a Tech SupportCase for +party+, deduplicating near-simultaneous identical submissions.
-
#from_party(party) ⇒ self
Prefill the form from an existing Party — name/email/phone, plus company_name/profile_id when the party belongs to an organization.
-
#profile_id ⇒ Integer?
Selected trade/profile id; validated against Profile ids.
-
#recent_duplicate_support_case(description) ⇒ SupportCase?
Find a recent Tech SupportCase with an identical description (which encodes name/email/phone/message, so it acts as a per-person fingerprint) within SUPPORT_CASE_DEDUPE_WINDOW.
-
#save_to_user(customer = nil, send_invite = false) ⇒ OpenStruct
Contact (never clobbering canonical data on established/"locked" customers), apply any SMS consent, and record the outcome as a lead Activity or a SupportCase.
-
#to_note ⇒ String
Render the submitted form fields as a human-readable, multi-line note for the lead Activity / SupportCase — excluding internal tracking and upload fields and dropping blanks.
-
#validate_phone_or_email ⇒ void
Validation: require at least one reachable contact method so we can answer the inquiry.
Methods inherited from BaseFormObject
#attributes, #initialize, #persisted?
Constructor Details
This class inherits a constructor from BaseFormObject
Class Method Details
.application_type ⇒ Array<String>
Heating application types offered, for the lead form's select options.
98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'app/forms/lead.rb', line 98 def self.application_type [ 'Floor Heating', 'Snow Melting', 'Roof & Gutter Deicing', 'Pipe Freeze Protection', 'Towel Warmers', 'Infrared Heating Panels', 'Mirrors & Defoggers', 'Countertop Heating' ] end |
.floor_type_indoor ⇒ Array<String>
Indoor floor-covering options, for the lead form's select options.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'app/forms/lead.rb', line 152 def self.floor_type_indoor [ 'Tile, Marble or Stone', 'Bamboo (Floating)', 'Bamboo (Glued)', 'Carpet (Glued)', 'Carpet (Stretch In)', 'Cork', 'Engineered Wood (Floating)', 'Engineered Wood (Glued)', 'Engineered Wood (Nailed)', 'Hard Wood (Glued)', 'Hard Wood (Nailed)', 'Laminate Wood (Click Together Floating)', 'Laminate Wood (Glued Together Floating)', 'Laminate Wood (Nailed)', 'Resilients, Vinyl and Luxury Vinyl Tile (LVT)', 'Other' ] end |
.floor_type_outdoor ⇒ Array<String>
Outdoor surface options, for the lead form's select options.
187 188 189 190 191 192 193 |
# File 'app/forms/lead.rb', line 187 def self.floor_type_outdoor %w[ Asphalt Concrete Pavers ] end |
.project_status ⇒ Array<String>
Buyer-journey stages, for the lead form's select options.
114 115 116 117 118 119 120 |
# File 'app/forms/lead.rb', line 114 def self.project_status [ 'Looking for inspiration', 'I am planning and budgeting', 'Ready to buy - I need some support' ] end |
.room_type_indoor ⇒ Array<String>
Indoor room options, for the lead form's select options.
125 126 127 128 129 130 131 132 133 134 |
# File 'app/forms/lead.rb', line 125 def self.room_type_indoor [ 'Bathroom', 'Kitchen', 'Bedroom', 'Living Room', 'Basement', 'Other' ] end |
.room_type_outdoor ⇒ Array<String>
Outdoor area options, for the lead form's select options.
139 140 141 142 143 144 145 146 147 |
# File 'app/forms/lead.rb', line 139 def self.room_type_outdoor %w[ Driveway Walkway Stairs Patio Other ] end |
.subfloor_type_indoor ⇒ Array<String>
Indoor subfloor options, for the lead form's select options.
176 177 178 179 180 181 182 |
# File 'app/forms/lead.rb', line 176 def self.subfloor_type_indoor [ 'Existing Concrete Slab', 'Newly Poured Indoor Concrete Slab', 'Wood' ] end |
Instance Method Details
#append_contact_point_if_new(contact, category, value) ⇒ ContactPoint?
Build a fresh ContactPoint on contact only when the normalized value
doesn't already exist on the party. acts_as_list assigns the next
(highest) position so the new row lands at the bottom — never replaces
the existing primary. Used by the locked-customer branch of
save_to_user to record divergent phone/email values without
corrupting canonical contact info.
554 555 556 557 558 559 560 561 562 563 |
# File 'app/forms/lead.rb', line 554 def append_contact_point_if_new(contact, category, value) return if value.blank? candidate = ContactPoint.new(category: category, detail: value) candidate.normalize_format return if candidate.detail.blank? return if contact.contact_points.any? { |cp| cp.detail == candidate.detail } contact.contact_points.build(category: category, detail: value) end |
#apply_sms_consent(contact) ⇒ void
This method returns an undefined value.
When the lead checked the SMS-consent box, flip the ContactPoint for the
phone they entered to sms_enabled so the SMS sender (sms_numbers scope)
may text them. Stamped with sms_status_source 'web_consent' so
set_default_sms_status treats it as authoritative — that callback only
re-bootstraps to the category default when detail/category change, so a
later Twilio Lookup (which edits those) still wins on carrier capability.
Additive only: a no-op when consent is unchecked or the phone is blank /
invalid, and it never downgrades a number. The lead Activity note records
the timestamped opt-in regardless (sms_consent flows through to_note).
578 579 580 581 582 583 584 585 586 587 |
# File 'app/forms/lead.rb', line 578 def (contact) return unless && phone.present? && PhoneNumber.valid?(phone) normalized = ContactPoint.new(category: ContactPoint::PHONE, detail: phone) normalized.normalize_format return if normalized.detail.blank? cp = contact.contact_points.where(detail: normalized.detail).detect(&:phone_number?) cp&.update(sms_status: :sms_enabled, sms_status_source: 'web_consent') end |
#build_support_case(party, description) ⇒ SupportCase
Build and save a new low-priority Tech SupportCase with the given
description, wiring up participants/rooms for +party+ and associating any
sketch uploads after the case persists.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 |
# File 'app/forms/lead.rb', line 359 def build_support_case(party, description) upload_ids = [] %i[sketch_drawing_1 sketch_drawing_2 sketch_drawing_3].each do |dp| next if attributes[dp].blank? upload_ids << Upload.uploadify(attributes[dp].path, 'room_layout').id end # Include any pre-uploaded files provided via hidden field (Uppy) if attributes[:sketch_drawings].present? begin ids = JSON.parse(attributes[:sketch_drawings].to_s) upload_ids.concat(Array(ids).compact.map(&:to_i)) if ids.present? rescue StandardError # ignore parse errors end end a = SupportCase.new( description: description, priority: 'Low', case_type: 'Tech' ) a.build_participants_and_rooms(participant_id: party.id) if a.save && upload_ids.present? # Associate uploads after support case creation to avoid triggering Dragonfly file access Upload.where(id: upload_ids).update_all( resource_type: 'SupportCase', resource_id: a.id, updated_at: Time.current ) end a end |
#create_lead_activity(party) ⇒ Activity
Create the lead Activity for +party+, attaching any sketch uploads
(Dragonfly/Uppy) and stamping the LEAD_FORM activity type. Uploads are
associated after the activity persists to avoid premature file access.
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'app/forms/lead.rb', line 258 def create_lead_activity(party) # Going to do in two steps, first create teh uploads, this will allow us # to recover them in case of failure upload_ids = [] %i[sketch_drawing_1 sketch_drawing_2 sketch_drawing_3].each do |dp| next unless attributes[dp].present? && attributes[dp].respond_to?(:path) upload_ids << Upload.uploadify(attributes[dp].path, 'room_layout').id end # Include any pre-uploaded files provided via hidden field (Uppy) if attributes[:sketch_drawings].present? begin ids = JSON.parse(attributes[:sketch_drawings].to_s) upload_ids.concat(Array(ids).compact.map(&:to_i)) if ids.present? rescue StandardError # ignore parse errors end end self.activity_type_id ||= ActivityTypeConstants::LEAD_FORM activity = party.activities.create( party:, activity_type_id:, target_datetime: 1.working.hour.from_now, lock_target_datetime: true, new_note: to_note ) # Associate uploads after activity creation to avoid triggering Dragonfly file access if activity.persisted? && upload_ids.present? Upload.where(id: upload_ids).update_all( resource_type: 'Activity', resource_id: activity.id, updated_at: Time.current ) end activity end |
#create_lead_support_case(party) ⇒ SupportCase
Create a Tech SupportCase for +party+, deduplicating near-simultaneous
identical submissions. Serializes on the description fingerprint via an
advisory lock so concurrent retries reuse the first committed case; falls
back to a plain create if the lock times out.
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 |
# File 'app/forms/lead.rb', line 314 def create_lead_support_case(party) description = to_note lock_key = "#{SUPPORT_CASE_LOCK_PREFIX}|#{Digest::SHA256.hexdigest(description.to_s)}" # Stimulus form-locking blocks human double-clicks, but Cloudflare retries # and concurrent guest sessions still hit the server with two near- # simultaneous POSTs that both pass the time-window SELECT before either # commits — producing duplicate TTK tickets (10 in one overnight burst). # Serialize them on the description fingerprint so the second request # reads the first's committed row. `with_advisory_lock` returns nil on # timeout (>10s of contention — extremely rare); fall back to creating # without the lock so the request still completes. SupportCase.with_advisory_lock(lock_key, timeout_seconds: 10) do recent_duplicate_support_case(description) || build_support_case(party, description) end || build_support_case(party, description) end |
#from_party(party) ⇒ self
Prefill the form from an existing Party — name/email/phone, plus
company_name/profile_id when the party belongs to an organization. Used to
pre-populate the lead form for a logged-in or known visitor.
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'app/forms/lead.rb', line 213 def from_party(party) if party if (org = party.customer)&.is_organization? # Prefill company_name only when it looks like a real company — # not the "Guest …" placeholder (has_guest_name? — set by the # lead_qualify guest customer flow), and not just a duplicate of # the party's personal name. The latter happens when a one-person # business's customer record has full_name copied from the # contact's personal name; surfacing that as company_name on the # lead form just shows the user their own name in the Company # field, which looks like a bug. if org.full_name.present? && !org.has_guest_name? && org.full_name.casecmp(party.full_name.to_s) != 0 self.company_name = org.full_name end self.profile_id = org.profile_id end # Prefer the logged-in party's own name over an org's first-contact # fallback. The previous logic only used party.full_name when # `party.is_person?` was true, otherwise it fell through to # `org.contacts.active.first.full_name` — which surfaces a random # coworker's name on the form when an account is linked to the # customer party itself rather than to a Contact under it (common # in this codebase's auth flow). The party's own name is the right # answer regardless of STI type. if party.full_name.present? && !party.has_guest_name? self.name = party.full_name elsif org&.is_organization? && (first_contact = org.contacts.active.first) && !first_contact.has_guest_name? self.name = first_contact.full_name end self.email = party.email self.phone = party.phone end self end |
#profile_id ⇒ Integer?
Returns selected trade/profile id; validated against Profile ids.
Validations:
- Inclusion ({ in: Profile.pluck(:id), message: 'is not a valid profile', allow_nil: true })
32 |
# File 'app/forms/lead.rb', line 32 attribute :profile_id, :integer |
#recent_duplicate_support_case(description) ⇒ SupportCase?
Find a recent Tech SupportCase with an identical description (which encodes
name/email/phone/message, so it acts as a per-person fingerprint) within
SUPPORT_CASE_DEDUPE_WINDOW.
337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'app/forms/lead.rb', line 337 def recent_duplicate_support_case(description) return nil if description.blank? # Match on description alone. The description (from to_note) includes the # submitter's name, email, phone, and message, making it an effective # per-person fingerprint. Matching on party_id failed when Cloudflare # retries or multi-session resubmits created a fresh guest Customer per # request, giving each attempt a different party_id. SupportCase .where(case_type: 'Tech', description: description) .where(created_at: SUPPORT_CASE_DEDUPE_WINDOW.ago..) .order(created_at: :desc) .first end |
#save_to_user(customer = nil, send_invite = false) ⇒ OpenStruct
Contact (never clobbering canonical data on established/"locked" customers),
apply any SMS consent, and record the outcome as a lead Activity or a
SupportCase. Builds a fresh lead_qualify Customer when none is supplied.
:reek:ControlParameter
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 |
# File 'app/forms/lead.rb', line 408 def save_to_user(customer = nil, send_invite = false) if customer.nil? country_iso = PhoneNumber.new(phone)&.country_iso if phone.present? catalog = Catalog.default_for_country(country_iso) if country_iso catalog ||= Catalog.locale_to_catalog(preferred_locale) if preferred_locale.present? catalog ||= Catalog.us_catalog customer = Customer.new(state: 'lead_qualify', catalog:) end raise StandardError, 'Party is not a customer.' unless customer.is_a?(Customer) # Established customers: backfill any missing form values from the # canonical Customer record before validation. The locked-state UI # hides the contact inputs and round-trips them via hidden_field tags, # but if the round-trip drops them (Turbo edge case, deliberate POST # without those keys, etc.) `validate_phone_or_email` would otherwise # 422 a logged-in user who clearly *does* have a phone and email on # file. The submitted values are ignored for any contact mutation # below when locked, so backfilling here doesn't open any new # mutation path — it just keeps validation and `to_note` aligned with # the canonical contact data. if !customer.guest? && !customer.lead_qualify? self.name = customer.full_name if name.blank? && customer.respond_to?(:has_guest_name?) && !customer.has_guest_name? self.email = customer.email if email.blank? && customer.email.present? self.phone = customer.phone if phone.blank? && customer.phone.present? end result = OpenStruct.new(success: false, message: nil, support_case_id: nil) # If our form does not pass basic validation then return immediately unless valid? msg = errors_to_s Rails.logger.error "Lead form failed validation: #{msg}. Lead data: #{inspect}" result.success = false result. = msg return result end # When customer is a guest or still in lead qualify, then we take the form data and store if customer.guest? || customer.customer.lead_qualify? cu = customer cu.state = 'lead_qualify' if company_name.present? cu.profile_id = profile_id.presence || ProfileConstants::UNKNOWN_TRADE cu.clear_names # Reset, we're rebuilding as a company cu.full_name = company_name unless cu.save # Save before building the contact, if this fails return right away msg = cu.errors_to_s Rails.logger.error "Lead form error saving customer: #{msg}. Lead data: #{inspect}" result. = msg errors.add(:base, result.) return result end contact = cu.contacts.active.where(Party[:full_name].matches(name)).first contact ||= cu.contacts.new(full_name: 'Unknown') else # This is a homeowner, or we presume it is, therefore the contact is the customer itself cu.profile_id = profile_id.presence || ProfileConstants::HOMEOWNER cu.full_name = name contact = cu # Contact is same as homeowner which will be saved later on end # When not a guest, and a company, we create a contact under that customer. But we don't modify name on the customer elsif customer.is_organization? # Try to find an existing contact of the same name contact = customer.contacts.active.where(Party[:full_name].matches(name)).first # Just initialize a new one contact ||= customer.contacts.new(full_name: 'Unknown') else # homeowner contact = customer end customer.gclid = gclid customer.source_id = source_id if source_id.present? && source_id != Source::UNKNOWN_ID && (customer.source_id.nil? || customer.source_id == Source::UNKNOWN_ID) # Lock canonical contact data on established customers — anything past # guest / lead_qualify is considered "in our system" and must only be # edited via the account-profile flow, never as a side effect of a lead # form submission. Otherwise a logged-in user (or a malicious POST that # bypasses the locked-state UI) could rename their own customer and # promote a typo'd phone/email to primary. # # Newly built contacts (org branch building a fresh `Unknown` contact # for an unmatched name) are NOT locked — there's no canonical data # there to clobber, and the form values are the source of truth for # populating that brand-new contact. locked = !customer.guest? && !customer.lead_qualify? && contact.persisted? if locked # Don't touch contact name or preferred_language on locked records. # Don't promote a new phone/email to primary (which `contact.phone=` # / `contact.email=` would do via set_primary_contact_point's # move_to_top). Instead, append divergent values as additional # ContactPoints so staff has a record of "user gave us this alt phone # with their request" without losing the canonical primary. append_contact_point_if_new(contact, ContactPoint::PHONE, phone) if phone.present? && PhoneNumber.valid?(phone) append_contact_point_if_new(contact, ContactPoint::EMAIL, email) if email.present? && (email =~ RFC822::EMAIL) else # Contacts being people, we try to parse the name of the person and store it in the party model if name.present? pnp = PersonNameParser.new(name) pnp.to_party(contact) end # Store contact info but only if its valid, otherwise it will just be stored in the form data in the activity # We do this to still capture the lead even if we have bad data contact.phone = phone if phone.present? && PhoneNumber.valid?(phone) contact.email = email if email.present? && (email =~ RFC822::EMAIL) contact.preferred_language = 'Spanish' if preferred_locale == 'es' end if contact.save (contact) # Now that the contact info is saved, create the lead activity if skip_activity && create_support_case && (a = create_lead_support_case(contact)).persisted? result.success = true result.support_case_reference = a.case_number # Only invite when we're talking to a brand-new prospect — established # customers already have an account (or have explicitly declined one # before). contact.customer.online_account_invite(email, ignore_if_exist: true) if email.present? && send_invite && !locked elsif (a = create_lead_activity(contact)).persisted? result.success = true contact.customer.online_account_invite(email, ignore_if_exist: true) if email.present? && send_invite && !locked else result.success = false msg = a.errors_to_s result. = msg Rails.logger.error "Lead form error saving lead activity: #{msg}. Lead data: #{inspect}" errors.add(:base, result.) end else msg = contact.errors_to_s result. = msg Rails.logger.error "Lead form error saving contact: #{msg}. Lead data: #{inspect}" errors.add(:base, result.) end result end |
#to_note ⇒ String
Render the submitted form fields as a human-readable, multi-line note for
the lead Activity / SupportCase — excluding internal tracking and upload
fields and dropping blanks. Doubles as the dedupe fingerprint in
#create_lead_support_case.
201 202 203 204 205 |
# File 'app/forms/lead.rb', line 201 def to_note note_attributes = attributes.reject { |k, _v| k.in?(%i[gclid source_id sketch_drawing_1 sketch_drawing_2 sketch_drawing_3 activity_type_id profile_id opportunity_id]) } note_attributes = note_attributes.compact_blank note_attributes.map { |k, v| "#{k.to_s.titleize}: #{v}" }.join("\n\r") end |
#validate_phone_or_email ⇒ void
This method returns an undefined value.
Validation: require at least one reachable contact method so we can answer
the inquiry. Adds a base error when both phone and a valid email are absent.
593 594 595 596 597 |
# File 'app/forms/lead.rb', line 593 def validate_phone_or_email return if phone.present? || (email.present? && Truemail.valid?(email)) errors.add(:base, 'Please provide either a phone or email so we can answer your question') end |