Module: Marketing::Audiences::OpenaiExport

Defined in:
app/services/marketing/audiences/openai_export.rb

Overview

Builds the OpenAI Ads "Custom Audiences" file(s) for an Audience — for MANUAL
upload via ChatGPT Ads → Custom Audiences. OpenAI's API has no data-file
endpoint (its /upload is image-only, verified against the live API), so
unlike Google there's no auto-sync; this export is the ChatGPT path.

Per OpenAI's spec (https://help.openai.com/en/articles/20001346), ONE
identifier type per upload — emails and phones must NOT be mixed in one
file. So we emit a ZIP with a single-column CSV per available type:

  • audience--email_sha256.csv (header email_sha256)
  • audience--phone_number_sha256.csv (header phone_number_sha256)
    each one 64-char SHA-256 hex per line. Hashing per spec: email = lowercase +
    trim then SHA-256 (NO gmail dot/+tag strip — that's Google-only); phone =
    E.164 WITH the leading + then SHA-256. Values are deduped by hash.

ponytail: no 5M-identifier / 500MB split — our largest audience is ~200k per
file, well under OpenAI's cap. Add chunking if an audience ever exceeds it.

Class Method Summary collapse

Class Method Details

.column_csv(header, hashes) ⇒ String

A single-identifier-type CSV: one named column, one value per line.

Parameters:

  • header (String)

    column header per OpenAI's spec

  • hashes (Array<String>)

    SHA-256 hex values

Returns:

  • (String)

    CSV content



59
60
61
62
63
64
# File 'app/services/marketing/audiences/openai_export.rb', line 59

def column_csv(header, hashes)
  CSV.generate do |out|
    out << [header]
    hashes.each { |hash| out << [hash] }
  end
end

.email_hashes(audience) ⇒ Array<String>

Hashed, nil-filtered, deduped BY HASH: case/whitespace variants
(Alice@… vs alice@…) only collide after normalization, so the raw
.distinct/.uniq below can't catch them — a second pass on the hash does.

Parameters:

Returns:

  • (Array<String>)

    SHA-256 hex hashes



85
86
87
# File 'app/services/marketing/audiences/openai_export.rb', line 85

def email_hashes(audience)
  emails(audience).filter_map { |email| hash_email(email) }.uniq
end

.emails(audience) ⇒ Array<String>

Static email lists carry raw addresses on their members; dynamic/customer
audiences resolve through the same consent-gated scope the ad sync uses.
.distinct: party_ids spans the customer Party AND its Contact parties, so a
shared address would otherwise be plucked (and hashed) twice.

Parameters:

Returns:

  • (Array<String>)

    raw email addresses



104
105
106
107
108
109
110
# File 'app/services/marketing/audiences/openai_export.rb', line 104

def emails(audience)
  if audience.static?
    audience.audience_members.pluck(:email_address).compact.uniq
  else
    ContactPoint.marketable_email.where(party_id: party_ids(audience)).distinct.pluck(:detail)
  end
end

.files(audience) ⇒ Hash{String=>String}

{ filename => single-type CSV } for each identifier type present. Public
so callers/tests can inspect the per-type files.

Parameters:

Returns:

  • (Hash{String=>String})


45
46
47
48
49
50
51
52
# File 'app/services/marketing/audiences/openai_export.rb', line 45

def files(audience)
  {}.tap do |out|
    emails = email_hashes(audience)
    phones = phone_hashes(audience)
    out["audience-#{audience.id}-email_sha256.csv"] = column_csv('email_sha256', emails) if emails.any?
    out["audience-#{audience.id}-phone_number_sha256.csv"] = column_csv('phone_number_sha256', phones) if phones.any?
  end
end

.hash_email(email) ⇒ String?

gmail_normalize: false — OpenAI hashes the raw lowercase+trimmed address.

Parameters:

  • email (String)

    raw email address

Returns:

  • (String, nil)

    SHA-256 hex hash, nil when unhashable



134
135
136
# File 'app/services/marketing/audiences/openai_export.rb', line 134

def hash_email(email)
  CustomerMatch::Hasher.email(email, gmail_normalize: false)
end

.party_ids(audience) ⇒ ActiveRecord::Relation

Party ids behind an audience (customers + their contacts).

Parameters:

Returns:

  • (ActiveRecord::Relation)

    id-selecting Party relation



126
127
128
# File 'app/services/marketing/audiences/openai_export.rb', line 126

def party_ids(audience)
  Audiences.member_parties(Target.new(audience).customer_scope).select(:id)
end

.phone_hashes(audience) ⇒ Array<String>

Hashed, deduped phone identifiers for an audience.

Parameters:

Returns:

  • (Array<String>)

    SHA-256 hex hashes



93
94
95
# File 'app/services/marketing/audiences/openai_export.rb', line 93

def phone_hashes(audience)
  phones(audience).filter_map { |phone| CustomerMatch::Hasher.phone(phone) }.uniq
end

.phones(audience) ⇒ Array<String>

Consent-gated marketable phone numbers for an audience ([] when static).

Parameters:

Returns:

  • (Array<String>)

    raw phone numbers



116
117
118
119
120
# File 'app/services/marketing/audiences/openai_export.rb', line 116

def phones(audience)
  return [] if audience.static? # a raw-email list has no phones

  ContactPoint.marketable_phone.where(party_id: party_ids(audience)).distinct.pluck(:detail)
end

.zip(audience) ⇒ String?

Returns ZIP bytes, or nil when there are no identifiers.

Parameters:

Returns:

  • (String, nil)

    ZIP bytes, or nil when there are no identifiers



33
34
35
36
37
38
# File 'app/services/marketing/audiences/openai_export.rb', line 33

def zip(audience)
  files = files(audience)
  return nil if files.empty?

  zip_of(files)
end

.zip_of(files) ⇒ String

Bundles per-type CSVs into one in-memory ZIP.

Parameters:

  • files (Hash{String=>String})

    filename → CSV content

Returns:

  • (String)

    ZIP bytes



70
71
72
73
74
75
76
77
# File 'app/services/marketing/audiences/openai_export.rb', line 70

def zip_of(files)
  Zip::OutputStream.write_buffer do |zos|
    files.each do |name, content|
      zos.put_next_entry(name)
      zos.write(content)
    end
  end.string
end