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
-
.column_csv(header, hashes) ⇒ String
A single-identifier-type CSV: one named column, one value per line.
-
.email_hashes(audience) ⇒ Array<String>
Hashed, nil-filtered, deduped BY HASH: case/whitespace variants (
Alice@…vsalice@…) only collide after normalization, so the raw .distinct/.uniq below can't catch them — a second pass on the hash does. -
.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.
-
.files(audience) ⇒ Hash{String=>String}
{ filename => single-type CSV } for each identifier type present.
-
.hash_email(email) ⇒ String?
gmail_normalize: false — OpenAI hashes the raw lowercase+trimmed address.
-
.party_ids(audience) ⇒ ActiveRecord::Relation
Party ids behind an audience (customers + their contacts).
-
.phone_hashes(audience) ⇒ Array<String>
Hashed, deduped phone identifiers for an audience.
-
.phones(audience) ⇒ Array<String>
Consent-gated marketable phone numbers for an audience ([] when static).
-
.zip(audience) ⇒ String?
ZIP bytes, or nil when there are no identifiers.
-
.zip_of(files) ⇒ String
Bundles per-type CSVs into one in-memory ZIP.
Class Method Details
.column_csv(header, hashes) ⇒ String
A single-identifier-type CSV: one named column, one value per line.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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 |