Class: Lead

Inherits:
BaseFormObject show all
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_case calls on the same description fingerprint.

'lead_support_case'

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from BaseFormObject

#attributes, #initialize, #persisted?

Constructor Details

This class inherits a constructor from BaseFormObject

Class Method Details

.application_typeArray<String>

Heating application types offered, for the lead form's select options.

Returns:

  • (Array<String>)


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_indoorArray<String>

Indoor floor-covering options, for the lead form's select options.

Returns:

  • (Array<String>)


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_outdoorArray<String>

Outdoor surface options, for the lead form's select options.

Returns:

  • (Array<String>)


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_statusArray<String>

Buyer-journey stages, for the lead form's select options.

Returns:

  • (Array<String>)


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_indoorArray<String>

Indoor room options, for the lead form's select options.

Returns:

  • (Array<String>)


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_outdoorArray<String>

Outdoor area options, for the lead form's select options.

Returns:

  • (Array<String>)


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_indoorArray<String>

Indoor subfloor options, for the lead form's select options.

Returns:

  • (Array<String>)


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.

Parameters:

  • contact (Party)

    the contact party to append to

  • category (String)

    ContactPoint::PHONE or ContactPoint::EMAIL

  • value (String)

    raw user input

Returns:

  • (ContactPoint, nil)

    the built (unsaved) ContactPoint, or nil
    if the value was blank or already present



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

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).

Parameters:

  • contact (Party)

    the just-saved contact



578
579
580
581
582
583
584
585
586
587
# File 'app/forms/lead.rb', line 578

def apply_sms_consent(contact)
  return unless sms_consent && 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.

Parameters:

  • party (Party)

    the participant the case is opened for

  • description (String)

    the case body / fingerprint from #to_note

Returns:



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.

Parameters:

  • party (Party)

    the contact the activity is created on

Returns:

  • (Activity)

    the created activity (check #persisted?)



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.

Parameters:

  • party (Party)

    the participant the case is opened for

Returns:

  • (SupportCase)

    the existing or newly built case (check #persisted?)



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.

Parameters:

  • party (Party, nil)

    the party to prefill from; no-op when nil

Returns:

  • (self)


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_idInteger?

Returns selected trade/profile id; validated against Profile ids.

Returns:

  • (Integer, nil)

    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.

Parameters:

  • description (String)

    the fingerprint from #to_note

Returns:



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

Parameters:

  • customer (Customer, nil) (defaults to: nil)

    the customer to attach to; a new
    lead_qualify Customer is created when nil

  • send_invite (Boolean) (defaults to: false)

    whether to send an online-account invite to a
    brand-new (non-locked) prospect's email

Returns:

  • (OpenStruct)

    result with #success, #message, and
    #support_case_reference

Raises:

  • (StandardError)


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.message = 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.message = msg
        errors.add(:base, result.message)
        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
    apply_sms_consent(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.(email, ignore_if_exist: true) if email.present? && send_invite && !locked
    elsif (a = create_lead_activity(contact)).persisted?
      result.success = true
      contact.customer.(email, ignore_if_exist: true) if email.present? && send_invite && !locked
    else
      result.success = false
      msg = a.errors_to_s
      result.message = msg
      Rails.logger.error "Lead form error saving lead activity: #{msg}.  Lead data: #{inspect}"
      errors.add(:base, result.message)
    end
  else
    msg = contact.errors_to_s
    result.message = msg
    Rails.logger.error "Lead form error saving contact: #{msg}.  Lead data: #{inspect}"
    errors.add(:base, result.message)
  end
  result
end

#to_noteString

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.

Returns:

  • (String)

    newline-joined "Titleized Key: value" lines



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_emailvoid

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