@aglyn/tenant-data-admin 1.0.0-beta.250 → 1.0.0-beta.252
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/ad-audience-sync-run.d.ts +134 -0
- package/src/lib/server/ad-audience-sync-run.js +364 -0
- package/src/lib/server/ad-audience-sync-run.js.map +1 -0
- package/src/lib/server/ad-audience-sync.d.ts +250 -0
- package/src/lib/server/ad-audience-sync.js +446 -0
- package/src/lib/server/ad-audience-sync.js.map +1 -0
- package/src/lib/server/consent-by-address.d.ts +101 -0
- package/src/lib/server/consent-by-address.js +178 -0
- package/src/lib/server/consent-by-address.js.map +1 -0
- package/src/lib/server/notifications.d.ts +23 -0
- package/src/lib/server/notifications.js +35 -16
- package/src/lib/server/notifications.js.map +1 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { createHash } from "node:crypto";
|
|
18
|
+
/**
|
|
19
|
+
* THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — the pure half.
|
|
20
|
+
*
|
|
21
|
+
* Once a night the operator's own account holders are kept in step with a
|
|
22
|
+
* customer-list audience on each of the operator's OWN ad accounts — Meta
|
|
23
|
+
* Custom Audiences, Google Customer Match and LinkedIn DMP segments — so
|
|
24
|
+
* signed-up people can be retargeted and left out of prospecting. This is
|
|
25
|
+
* the platform's marketing, not a merchant feature: a merchant's own pixels
|
|
26
|
+
* and conversion APIs are the ad-conversions plugin, which this shares
|
|
27
|
+
* nothing with.
|
|
28
|
+
*
|
|
29
|
+
* Everything here is pure — normalization, hashing, who is eligible, the
|
|
30
|
+
* add/remove diff and each provider's request — so the spec walks every rule
|
|
31
|
+
* without a network. `ad-audience-sync-run.ts` reads the accounts, sends and
|
|
32
|
+
* records.
|
|
33
|
+
*
|
|
34
|
+
* No address ever leaves in the clear: each provider receives the SHA-256 hex
|
|
35
|
+
* digest of the address normalized the way that provider documents, and
|
|
36
|
+
* nothing here logs either one.
|
|
37
|
+
*/ export const AD_AUDIENCE_PROVIDERS = [
|
|
38
|
+
'meta',
|
|
39
|
+
'google',
|
|
40
|
+
'linkedin'
|
|
41
|
+
];
|
|
42
|
+
export const AD_AUDIENCE_PROVIDER_LABELS = {
|
|
43
|
+
meta: 'Meta',
|
|
44
|
+
google: 'Google',
|
|
45
|
+
linkedin: 'LinkedIn'
|
|
46
|
+
};
|
|
47
|
+
/*==========================================
|
|
48
|
+
* NORMALIZATION AND HASHING
|
|
49
|
+
*=========================================*/ const GOOGLE_CONSUMER_DOMAINS = new Set([
|
|
50
|
+
'gmail.com',
|
|
51
|
+
'googlemail.com'
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* The address the way one provider says to normalize it before hashing, or
|
|
55
|
+
* null when it is not an address at all.
|
|
56
|
+
*
|
|
57
|
+
* - **Meta** (Custom Audiences, `EMAIL`): trim leading and trailing white
|
|
58
|
+
* space, lowercase.
|
|
59
|
+
* - **LinkedIn** (DMP segment users, `SHA256_EMAIL`): lowercase, remove any
|
|
60
|
+
* white space.
|
|
61
|
+
* - **Google** (Data Manager API, Customer Match): lowercase, remove leading,
|
|
62
|
+
* trailing and inner white space, and for `gmail.com` and `googlemail.com`
|
|
63
|
+
* only, remove the dots from the local part and drop a `+` and everything
|
|
64
|
+
* after it. Other domains keep both.
|
|
65
|
+
*
|
|
66
|
+
* Inner white space is never part of a deliverable address, so removing it
|
|
67
|
+
* for Meta as well would change nothing real; it is left exactly as Meta
|
|
68
|
+
* documents it.
|
|
69
|
+
*/ export function normalizeAudienceEmail(provider, raw) {
|
|
70
|
+
if (typeof raw !== 'string') return null;
|
|
71
|
+
let email = provider === 'meta' ? raw.trim().toLowerCase() : raw.replace(/\s+/g, '').toLowerCase();
|
|
72
|
+
const at = email.lastIndexOf('@');
|
|
73
|
+
if (at <= 0 || at === email.length - 1 || email.indexOf('@') !== at) return null;
|
|
74
|
+
if (provider === 'google') {
|
|
75
|
+
let local = email.slice(0, at);
|
|
76
|
+
const domain = email.slice(at + 1);
|
|
77
|
+
if (GOOGLE_CONSUMER_DOMAINS.has(domain)) {
|
|
78
|
+
const plus = local.indexOf('+');
|
|
79
|
+
if (plus >= 0) local = local.slice(0, plus);
|
|
80
|
+
local = local.replace(/\./g, '');
|
|
81
|
+
if (!local) return null;
|
|
82
|
+
email = `${local}@${domain}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return email;
|
|
86
|
+
}
|
|
87
|
+
/** Lowercase SHA-256 hex of a UTF-8 string: what all three providers take. */ export function sha256Hex(value) {
|
|
88
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
89
|
+
}
|
|
90
|
+
/** The hash one provider receives for an address, or null for no address. */ export function hashAudienceEmail(provider, raw) {
|
|
91
|
+
const normalized = normalizeAudienceEmail(provider, raw);
|
|
92
|
+
return normalized ? sha256Hex(normalized) : null;
|
|
93
|
+
}
|
|
94
|
+
export const AD_AUDIENCE_EXCLUSIONS = [
|
|
95
|
+
'no-email',
|
|
96
|
+
'disabled',
|
|
97
|
+
'staff',
|
|
98
|
+
'internal',
|
|
99
|
+
'locked',
|
|
100
|
+
'banned',
|
|
101
|
+
'opted-out',
|
|
102
|
+
'unverified',
|
|
103
|
+
'no-country',
|
|
104
|
+
'not-us'
|
|
105
|
+
];
|
|
106
|
+
/** The one country the audience is built for. */ export const AD_AUDIENCE_COUNTRY = 'US';
|
|
107
|
+
const ALPHA2 = /^[A-Z]{2}$/;
|
|
108
|
+
/**
|
|
109
|
+
* The account's country, and which signal said so.
|
|
110
|
+
*
|
|
111
|
+
* 1. **The billing address** of a workspace the account owns. It is the
|
|
112
|
+
* address the account holder typed or gave Stripe, it is the address tax
|
|
113
|
+
* is computed on, and it is the strongest country fact the platform holds.
|
|
114
|
+
* When the account owns workspaces billed in different countries, the
|
|
115
|
+
* answer is US only when EVERY one of them is US: an account that bills
|
|
116
|
+
* from abroad is not counted as a US account holder on the strength of
|
|
117
|
+
* one US workspace.
|
|
118
|
+
* 2. Otherwise **the sign-up geography**: the country the edge reported for
|
|
119
|
+
* the request that created the account (AGL-3289). It says where the
|
|
120
|
+
* person was when they signed up, which is a weaker fact, and it is the
|
|
121
|
+
* one most accounts have.
|
|
122
|
+
* 3. Otherwise nothing, and an account with no country is left out. A phone
|
|
123
|
+
* number's `+1` is deliberately not a signal: it is shared with Canada and
|
|
124
|
+
* the Caribbean.
|
|
125
|
+
*/ export function resolveAccountCountry(account) {
|
|
126
|
+
var _account_signupCountry;
|
|
127
|
+
const billing = [
|
|
128
|
+
...new Set(account.billingCountries.map((country)=>String(country != null ? country : '').trim().toUpperCase()).filter((country)=>ALPHA2.test(country)))
|
|
129
|
+
];
|
|
130
|
+
if (billing.length) {
|
|
131
|
+
const foreign = billing.find((country)=>country !== AD_AUDIENCE_COUNTRY);
|
|
132
|
+
return {
|
|
133
|
+
country: foreign != null ? foreign : AD_AUDIENCE_COUNTRY,
|
|
134
|
+
source: 'billing'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const signup = String((_account_signupCountry = account.signupCountry) != null ? _account_signupCountry : '').trim().toUpperCase();
|
|
138
|
+
if (ALPHA2.test(signup)) return {
|
|
139
|
+
country: signup,
|
|
140
|
+
source: 'signup'
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
country: null,
|
|
144
|
+
source: null
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** The domain part of an operator address, lowercased, or null. */ export function emailDomain(address) {
|
|
148
|
+
const value = String(address != null ? address : '').trim().toLowerCase();
|
|
149
|
+
const at = value.lastIndexOf('@');
|
|
150
|
+
return at > 0 && at < value.length - 1 ? value.slice(at + 1) : null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Why this account is left out, or null when it belongs in the audience.
|
|
154
|
+
*
|
|
155
|
+
* Every condition must hold: an address, not disabled, not staff, not on the
|
|
156
|
+
* operator's own domain, no account lock (an `abuse` lock is a permanent ban),
|
|
157
|
+
* not on the ban list, not opted out, a verified address, and a United
|
|
158
|
+
* States country. Account deletion needs no rule: an erased account has no
|
|
159
|
+
* Auth record, so it is never read at all, and the diff removes it.
|
|
160
|
+
*
|
|
161
|
+
* The address must be VERIFIED because anyone can sign up with an address
|
|
162
|
+
* that is not theirs, and uploading it would put a person who never signed up
|
|
163
|
+
* into the audience. A Google or SSO sign-in arrives verified.
|
|
164
|
+
*/ export function adAudienceExclusion(account, options = {}) {
|
|
165
|
+
var _options_internalDomains;
|
|
166
|
+
const domain = emailDomain(account.email);
|
|
167
|
+
if (!domain) return 'no-email';
|
|
168
|
+
if (account.disabled) return 'disabled';
|
|
169
|
+
if (account.staff) return 'staff';
|
|
170
|
+
if (((_options_internalDomains = options.internalDomains) != null ? _options_internalDomains : []).some((internal)=>internal.toLowerCase() === domain)) {
|
|
171
|
+
return 'internal';
|
|
172
|
+
}
|
|
173
|
+
if (account.locked) return 'locked';
|
|
174
|
+
if (account.banned) return 'banned';
|
|
175
|
+
if (account.optedOut) return 'opted-out';
|
|
176
|
+
if (!account.emailVerified) return 'unverified';
|
|
177
|
+
const { country } = resolveAccountCountry(account);
|
|
178
|
+
if (!country) return 'no-country';
|
|
179
|
+
if (country !== AD_AUDIENCE_COUNTRY) return 'not-us';
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/*==========================================
|
|
183
|
+
* THE DIFF
|
|
184
|
+
*=========================================*/ /** What has to change for a provider to hold `next` when it holds `previous`. */ export function diffAudience(previous, next) {
|
|
185
|
+
const before = new Set(previous);
|
|
186
|
+
const after = new Set(next);
|
|
187
|
+
return {
|
|
188
|
+
toAdd: [
|
|
189
|
+
...after
|
|
190
|
+
].filter((hash)=>!before.has(hash)).sort(),
|
|
191
|
+
toRemove: [
|
|
192
|
+
...before
|
|
193
|
+
].filter((hash)=>!after.has(hash)).sort()
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Whether a night's set shrank so far it is more likely a failed read than
|
|
198
|
+
* real churn. Below twenty there is no telling the two apart, so it is never
|
|
199
|
+
* refused; above it, losing more than half the audience in one night is held
|
|
200
|
+
* until a person passes `force`.
|
|
201
|
+
*/ export function isSuspiciousShrink(previousSize, nextSize) {
|
|
202
|
+
return previousSize >= 20 && nextSize < previousSize / 2;
|
|
203
|
+
}
|
|
204
|
+
/** `items` in runs of at most `size`. */ export function chunk(items, size) {
|
|
205
|
+
const runs = [];
|
|
206
|
+
for(let index = 0; index < items.length; index += size){
|
|
207
|
+
runs.push(items.slice(index, index + size));
|
|
208
|
+
}
|
|
209
|
+
return runs;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The variables, read here by their literal names so the self-host
|
|
213
|
+
* environment reference can see every one of them.
|
|
214
|
+
*/ export function adAudienceEnv() {
|
|
215
|
+
return {
|
|
216
|
+
META_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.META_AUDIENCE_SYNC_ACCESS_TOKEN,
|
|
217
|
+
META_AUDIENCE_SYNC_AUDIENCE_ID: process.env.META_AUDIENCE_SYNC_AUDIENCE_ID,
|
|
218
|
+
GOOGLE_AUDIENCE_SYNC_CLIENT_ID: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_ID,
|
|
219
|
+
GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET,
|
|
220
|
+
GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN: process.env.GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN,
|
|
221
|
+
GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID,
|
|
222
|
+
GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID,
|
|
223
|
+
GOOGLE_AUDIENCE_SYNC_USER_LIST_ID: process.env.GOOGLE_AUDIENCE_SYNC_USER_LIST_ID,
|
|
224
|
+
LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN,
|
|
225
|
+
LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID: process.env.LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const REQUIRED = {
|
|
229
|
+
meta: [
|
|
230
|
+
'META_AUDIENCE_SYNC_ACCESS_TOKEN',
|
|
231
|
+
'META_AUDIENCE_SYNC_AUDIENCE_ID'
|
|
232
|
+
],
|
|
233
|
+
google: [
|
|
234
|
+
'GOOGLE_AUDIENCE_SYNC_CLIENT_ID',
|
|
235
|
+
'GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET',
|
|
236
|
+
'GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN',
|
|
237
|
+
'GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID',
|
|
238
|
+
'GOOGLE_AUDIENCE_SYNC_USER_LIST_ID'
|
|
239
|
+
],
|
|
240
|
+
linkedin: [
|
|
241
|
+
'LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN',
|
|
242
|
+
'LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID'
|
|
243
|
+
]
|
|
244
|
+
};
|
|
245
|
+
const digits = (value)=>value.replace(/\D/g, '');
|
|
246
|
+
/**
|
|
247
|
+
* Each provider's settings, or null with the names it lacks. A provider is
|
|
248
|
+
* configured only when every required variable is set; a half-configured one
|
|
249
|
+
* is skipped, never half-sent.
|
|
250
|
+
*
|
|
251
|
+
* The Meta audience is never created here. Creating one when the id is unset
|
|
252
|
+
* would mint a fresh, empty audience on every run whose variable was lost, and
|
|
253
|
+
* the ads targeting the real one would go on pointing at it, so the id is
|
|
254
|
+
* required and the audience is made once, by hand.
|
|
255
|
+
*/ export function readAdAudienceConfig(env = adAudienceEnv()) {
|
|
256
|
+
const value = (name)=>{
|
|
257
|
+
var _env_name;
|
|
258
|
+
return String((_env_name = env[name]) != null ? _env_name : '').trim();
|
|
259
|
+
};
|
|
260
|
+
const missing = Object.fromEntries(AD_AUDIENCE_PROVIDERS.map((provider)=>[
|
|
261
|
+
provider,
|
|
262
|
+
REQUIRED[provider].filter((name)=>!value(name))
|
|
263
|
+
]));
|
|
264
|
+
const loginCustomerId = digits(value('GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID'));
|
|
265
|
+
return {
|
|
266
|
+
meta: missing.meta.length ? null : {
|
|
267
|
+
accessToken: value('META_AUDIENCE_SYNC_ACCESS_TOKEN'),
|
|
268
|
+
// `act_` belongs to the ad account, never to an audience id.
|
|
269
|
+
audienceId: value('META_AUDIENCE_SYNC_AUDIENCE_ID')
|
|
270
|
+
},
|
|
271
|
+
google: missing.google.length ? null : {
|
|
272
|
+
clientId: value('GOOGLE_AUDIENCE_SYNC_CLIENT_ID'),
|
|
273
|
+
clientSecret: value('GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET'),
|
|
274
|
+
refreshToken: value('GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN'),
|
|
275
|
+
// Google Ads shows `841-500-9958`; the API takes the digits.
|
|
276
|
+
customerId: digits(value('GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID')),
|
|
277
|
+
loginCustomerId: loginCustomerId || null,
|
|
278
|
+
userListId: digits(value('GOOGLE_AUDIENCE_SYNC_USER_LIST_ID'))
|
|
279
|
+
},
|
|
280
|
+
linkedin: missing.linkedin.length ? null : {
|
|
281
|
+
accessToken: value('LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN'),
|
|
282
|
+
// The segment URN's id, whichever form was pasted.
|
|
283
|
+
segmentId: value('LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID').replace(/^urn:li:dmpSegment:/, '')
|
|
284
|
+
},
|
|
285
|
+
missing
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* META — the replace session.
|
|
290
|
+
*
|
|
291
|
+
* `POST /{audience}/usersreplace` swaps the audience's whole membership for
|
|
292
|
+
* the batches of one session: `batch_seq` 1 opens it and `last_batch_flag`
|
|
293
|
+
* closes it, so a removal needs no separate call and nothing has to be
|
|
294
|
+
* remembered about who was there before. Each batch carries at most 10,000
|
|
295
|
+
* hashes, and the session must finish inside 90 minutes. A replace does not
|
|
296
|
+
* reset an ad set's learning phase, which an add and a delete would.
|
|
297
|
+
*/ export const META_GRAPH_VERSION = 'v25.0';
|
|
298
|
+
export const META_REPLACE_BATCH = 10000;
|
|
299
|
+
export function buildMetaReplaceRequests(config, hashes, sessionId) {
|
|
300
|
+
const batches = chunk(hashes, META_REPLACE_BATCH);
|
|
301
|
+
return batches.map((batch, index)=>({
|
|
302
|
+
method: 'POST',
|
|
303
|
+
url: `https://graph.facebook.com/${META_GRAPH_VERSION}/${encodeURIComponent(config.audienceId)}/usersreplace`,
|
|
304
|
+
headers: {
|
|
305
|
+
'Content-Type': 'application/json'
|
|
306
|
+
},
|
|
307
|
+
body: {
|
|
308
|
+
payload: {
|
|
309
|
+
schema: 'EMAIL_SHA256',
|
|
310
|
+
data: batch
|
|
311
|
+
},
|
|
312
|
+
session: {
|
|
313
|
+
session_id: sessionId,
|
|
314
|
+
batch_seq: index + 1,
|
|
315
|
+
last_batch_flag: index === batches.length - 1,
|
|
316
|
+
estimated_num_total: hashes.length
|
|
317
|
+
},
|
|
318
|
+
access_token: config.accessToken
|
|
319
|
+
}
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* GOOGLE — the Data Manager API, not the Google Ads API.
|
|
324
|
+
*
|
|
325
|
+
* Since 2026-04-01 the Google Ads API's `OfflineUserDataJobService` refuses
|
|
326
|
+
* Customer Match uploads for a developer token with no Customer Match
|
|
327
|
+
* activity in the six months before, and Google points every such project at
|
|
328
|
+
* the Data Manager API instead. It needs an OAuth token with the
|
|
329
|
+
* `datamanager` scope and no developer token.
|
|
330
|
+
*
|
|
331
|
+
* Adds go to `audienceMembers:ingest` and removals to `audienceMembers:remove`,
|
|
332
|
+
* 10,000 members a request. The Data Manager API has no replace, so the
|
|
333
|
+
* removals come from the set this sync last sent.
|
|
334
|
+
*
|
|
335
|
+
* No `consent` is sent. The audience is United States account holders only,
|
|
336
|
+
* for whom Google requires no consent signal, and stating `CONSENT_GRANTED`
|
|
337
|
+
* would assert a consent this platform does not collect.
|
|
338
|
+
* `customerMatchTermsOfServiceStatus: ACCEPTED` is the operator's own
|
|
339
|
+
* acceptance of the Customer Match terms, which Google requires on an
|
|
340
|
+
* ingest; they are accepted in the Google Ads account before this is
|
|
341
|
+
* configured.
|
|
342
|
+
*/ export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
343
|
+
export const GOOGLE_DATA_MANAGER_BASE = 'https://datamanager.googleapis.com/v1';
|
|
344
|
+
export const GOOGLE_MEMBERS_BATCH = 10000;
|
|
345
|
+
export function buildGoogleTokenRequest(config) {
|
|
346
|
+
return {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
url: GOOGLE_TOKEN_URL,
|
|
349
|
+
headers: {
|
|
350
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
351
|
+
},
|
|
352
|
+
body: new URLSearchParams({
|
|
353
|
+
grant_type: 'refresh_token',
|
|
354
|
+
client_id: config.clientId,
|
|
355
|
+
client_secret: config.clientSecret,
|
|
356
|
+
refresh_token: config.refreshToken
|
|
357
|
+
}).toString()
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
export function buildGoogleMemberRequests(config, accessToken, action, hashes) {
|
|
361
|
+
const destination = _extends({
|
|
362
|
+
operatingAccount: {
|
|
363
|
+
accountType: 'GOOGLE_ADS',
|
|
364
|
+
accountId: config.customerId
|
|
365
|
+
}
|
|
366
|
+
}, config.loginCustomerId ? {
|
|
367
|
+
loginAccount: {
|
|
368
|
+
accountType: 'GOOGLE_ADS',
|
|
369
|
+
accountId: config.loginCustomerId
|
|
370
|
+
}
|
|
371
|
+
} : {}, {
|
|
372
|
+
productDestinationId: config.userListId
|
|
373
|
+
});
|
|
374
|
+
return chunk(hashes, GOOGLE_MEMBERS_BATCH).map((batch)=>({
|
|
375
|
+
method: 'POST',
|
|
376
|
+
url: `${GOOGLE_DATA_MANAGER_BASE}/audienceMembers:${action}`,
|
|
377
|
+
headers: {
|
|
378
|
+
'Content-Type': 'application/json',
|
|
379
|
+
Authorization: `Bearer ${accessToken}`
|
|
380
|
+
},
|
|
381
|
+
body: _extends({
|
|
382
|
+
destinations: [
|
|
383
|
+
destination
|
|
384
|
+
],
|
|
385
|
+
audienceMembers: batch.map((hash)=>({
|
|
386
|
+
userData: {
|
|
387
|
+
userIdentifiers: [
|
|
388
|
+
{
|
|
389
|
+
emailAddress: hash
|
|
390
|
+
}
|
|
391
|
+
]
|
|
392
|
+
}
|
|
393
|
+
})),
|
|
394
|
+
encoding: 'HEX'
|
|
395
|
+
}, action === 'ingest' ? {
|
|
396
|
+
termsOfService: {
|
|
397
|
+
customerMatchTermsOfServiceStatus: 'ACCEPTED'
|
|
398
|
+
}
|
|
399
|
+
} : {})
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* LINKEDIN — DMP segment users, streamed.
|
|
404
|
+
*
|
|
405
|
+
* `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE`
|
|
406
|
+
* takes up to 5,000 `ADD` or `REMOVE` elements a request. There is no
|
|
407
|
+
* replace, so the removals come from the set this sync last sent. Adding an
|
|
408
|
+
* id twice or removing one already gone both answer 201, so a retry is safe.
|
|
409
|
+
* The API is open only to developers LinkedIn has approved for Matched
|
|
410
|
+
* Audiences, with the `rw_dmp_segments` permission.
|
|
411
|
+
*/ export const LINKEDIN_VERSION = '202609';
|
|
412
|
+
export const LINKEDIN_USERS_BATCH = 5000;
|
|
413
|
+
export function buildLinkedInUserRequests(config, action, hashes) {
|
|
414
|
+
return chunk(hashes, LINKEDIN_USERS_BATCH).map((batch)=>({
|
|
415
|
+
method: 'POST',
|
|
416
|
+
url: `https://api.linkedin.com/rest/dmpSegments/${encodeURIComponent(config.segmentId)}/users`,
|
|
417
|
+
headers: {
|
|
418
|
+
'Content-Type': 'application/json',
|
|
419
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
420
|
+
'LinkedIn-Version': LINKEDIN_VERSION,
|
|
421
|
+
'X-Restli-Protocol-Version': '2.0.0',
|
|
422
|
+
'X-RestLi-Method': 'BATCH_CREATE'
|
|
423
|
+
},
|
|
424
|
+
body: {
|
|
425
|
+
elements: batch.map((hash)=>({
|
|
426
|
+
action,
|
|
427
|
+
userIds: [
|
|
428
|
+
{
|
|
429
|
+
idType: 'SHA256_EMAIL',
|
|
430
|
+
idValue: hash
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
}))
|
|
434
|
+
}
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
/** How many elements of a LinkedIn batch response were refused. */ export function linkedInRefusedCount(answer) {
|
|
438
|
+
const elements = answer == null ? void 0 : answer.elements;
|
|
439
|
+
if (!Array.isArray(elements)) return 0;
|
|
440
|
+
return elements.filter((element)=>{
|
|
441
|
+
const status = Number(element == null ? void 0 : element.status);
|
|
442
|
+
return !(status >= 200 && status < 300);
|
|
443
|
+
}).length;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
//# sourceMappingURL=ad-audience-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/ad-audience-sync.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHash } from 'node:crypto'\n\n/**\n * THE ACCOUNT-HOLDER AD AUDIENCE (AGL-3780) — the pure half.\n *\n * Once a night the operator's own account holders are kept in step with a\n * customer-list audience on each of the operator's OWN ad accounts — Meta\n * Custom Audiences, Google Customer Match and LinkedIn DMP segments — so\n * signed-up people can be retargeted and left out of prospecting. This is\n * the platform's marketing, not a merchant feature: a merchant's own pixels\n * and conversion APIs are the ad-conversions plugin, which this shares\n * nothing with.\n *\n * Everything here is pure — normalization, hashing, who is eligible, the\n * add/remove diff and each provider's request — so the spec walks every rule\n * without a network. `ad-audience-sync-run.ts` reads the accounts, sends and\n * records.\n *\n * No address ever leaves in the clear: each provider receives the SHA-256 hex\n * digest of the address normalized the way that provider documents, and\n * nothing here logs either one.\n */\n\nexport const AD_AUDIENCE_PROVIDERS = ['meta', 'google', 'linkedin'] as const\nexport type AdAudienceProvider = (typeof AD_AUDIENCE_PROVIDERS)[number]\n\nexport const AD_AUDIENCE_PROVIDER_LABELS: Record<AdAudienceProvider, string> = {\n meta: 'Meta',\n google: 'Google',\n linkedin: 'LinkedIn',\n}\n\n/*==========================================\n * NORMALIZATION AND HASHING\n *=========================================*/\n\nconst GOOGLE_CONSUMER_DOMAINS = new Set(['gmail.com', 'googlemail.com'])\n\n/**\n * The address the way one provider says to normalize it before hashing, or\n * null when it is not an address at all.\n *\n * - **Meta** (Custom Audiences, `EMAIL`): trim leading and trailing white\n * space, lowercase.\n * - **LinkedIn** (DMP segment users, `SHA256_EMAIL`): lowercase, remove any\n * white space.\n * - **Google** (Data Manager API, Customer Match): lowercase, remove leading,\n * trailing and inner white space, and for `gmail.com` and `googlemail.com`\n * only, remove the dots from the local part and drop a `+` and everything\n * after it. Other domains keep both.\n *\n * Inner white space is never part of a deliverable address, so removing it\n * for Meta as well would change nothing real; it is left exactly as Meta\n * documents it.\n */\nexport function normalizeAudienceEmail(\n provider: AdAudienceProvider,\n raw: unknown,\n): string | null {\n if (typeof raw !== 'string') return null\n let email =\n provider === 'meta' ? raw.trim().toLowerCase() : raw.replace(/\\s+/g, '').toLowerCase()\n const at = email.lastIndexOf('@')\n if (at <= 0 || at === email.length - 1 || email.indexOf('@') !== at) return null\n if (provider === 'google') {\n let local = email.slice(0, at)\n const domain = email.slice(at + 1)\n if (GOOGLE_CONSUMER_DOMAINS.has(domain)) {\n const plus = local.indexOf('+')\n if (plus >= 0) local = local.slice(0, plus)\n local = local.replace(/\\./g, '')\n if (!local) return null\n email = `${local}@${domain}`\n }\n }\n return email\n}\n\n/** Lowercase SHA-256 hex of a UTF-8 string: what all three providers take. */\nexport function sha256Hex(value: string): string {\n return createHash('sha256').update(value, 'utf8').digest('hex')\n}\n\n/** The hash one provider receives for an address, or null for no address. */\nexport function hashAudienceEmail(provider: AdAudienceProvider, raw: unknown): string | null {\n const normalized = normalizeAudienceEmail(provider, raw)\n return normalized ? sha256Hex(normalized) : null\n}\n\n/*==========================================\n * ELIGIBILITY\n *=========================================*/\n\n/** What the sync knows about one account, read from Auth and Firestore. */\nexport interface AdAudienceAccount {\n uid: string\n email: string | null\n emailVerified: boolean\n disabled: boolean\n /** The `staff` custom claim. */\n staff: boolean\n /** `users/{uid}.adAudienceOptOut` is set. */\n optedOut: boolean\n /** An account lockdown is in force (`lockdowns/user--{uid}`). */\n locked: boolean\n /** The address is on the account-ban suppression list (an `abuse` ban). */\n banned: boolean\n /**\n * The billing-address countries (`orgs/{id}.contact.address.country`) of\n * the workspaces the account OWNS.\n */\n billingCountries: readonly string[]\n /** `users/{uid}.acquisition.geo.country`: where the sign-up request came from. */\n signupCountry: string | null\n}\n\n/** Why an account is left out. Counted in every report; never per person. */\nexport type AdAudienceExclusion =\n | 'no-email'\n | 'disabled'\n | 'staff'\n | 'internal'\n | 'locked'\n | 'banned'\n | 'opted-out'\n | 'unverified'\n | 'no-country'\n | 'not-us'\n\nexport const AD_AUDIENCE_EXCLUSIONS: readonly AdAudienceExclusion[] = [\n 'no-email',\n 'disabled',\n 'staff',\n 'internal',\n 'locked',\n 'banned',\n 'opted-out',\n 'unverified',\n 'no-country',\n 'not-us',\n]\n\n/** The one country the audience is built for. */\nexport const AD_AUDIENCE_COUNTRY = 'US'\n\nexport type AdAudienceCountrySource = 'billing' | 'signup'\n\nconst ALPHA2 = /^[A-Z]{2}$/\n\n/**\n * The account's country, and which signal said so.\n *\n * 1. **The billing address** of a workspace the account owns. It is the\n * address the account holder typed or gave Stripe, it is the address tax\n * is computed on, and it is the strongest country fact the platform holds.\n * When the account owns workspaces billed in different countries, the\n * answer is US only when EVERY one of them is US: an account that bills\n * from abroad is not counted as a US account holder on the strength of\n * one US workspace.\n * 2. Otherwise **the sign-up geography**: the country the edge reported for\n * the request that created the account (AGL-3289). It says where the\n * person was when they signed up, which is a weaker fact, and it is the\n * one most accounts have.\n * 3. Otherwise nothing, and an account with no country is left out. A phone\n * number's `+1` is deliberately not a signal: it is shared with Canada and\n * the Caribbean.\n */\nexport function resolveAccountCountry(account: {\n billingCountries: readonly string[]\n signupCountry: string | null\n}): { country: string | null; source: AdAudienceCountrySource | null } {\n const billing = [\n ...new Set(\n account.billingCountries\n .map((country) => String(country ?? '').trim().toUpperCase())\n .filter((country) => ALPHA2.test(country)),\n ),\n ]\n if (billing.length) {\n const foreign = billing.find((country) => country !== AD_AUDIENCE_COUNTRY)\n return { country: foreign ?? AD_AUDIENCE_COUNTRY, source: 'billing' }\n }\n const signup = String(account.signupCountry ?? '').trim().toUpperCase()\n if (ALPHA2.test(signup)) return { country: signup, source: 'signup' }\n return { country: null, source: null }\n}\n\n/** The domain part of an operator address, lowercased, or null. */\nexport function emailDomain(address: string | null | undefined): string | null {\n const value = String(address ?? '').trim().toLowerCase()\n const at = value.lastIndexOf('@')\n return at > 0 && at < value.length - 1 ? value.slice(at + 1) : null\n}\n\n/**\n * Why this account is left out, or null when it belongs in the audience.\n *\n * Every condition must hold: an address, not disabled, not staff, not on the\n * operator's own domain, no account lock (an `abuse` lock is a permanent ban),\n * not on the ban list, not opted out, a verified address, and a United\n * States country. Account deletion needs no rule: an erased account has no\n * Auth record, so it is never read at all, and the diff removes it.\n *\n * The address must be VERIFIED because anyone can sign up with an address\n * that is not theirs, and uploading it would put a person who never signed up\n * into the audience. A Google or SSO sign-in arrives verified.\n */\nexport function adAudienceExclusion(\n account: AdAudienceAccount,\n options: { internalDomains?: readonly string[] } = {},\n): AdAudienceExclusion | null {\n const domain = emailDomain(account.email)\n if (!domain) return 'no-email'\n if (account.disabled) return 'disabled'\n if (account.staff) return 'staff'\n if ((options.internalDomains ?? []).some((internal) => internal.toLowerCase() === domain)) {\n return 'internal'\n }\n if (account.locked) return 'locked'\n if (account.banned) return 'banned'\n if (account.optedOut) return 'opted-out'\n if (!account.emailVerified) return 'unverified'\n const { country } = resolveAccountCountry(account)\n if (!country) return 'no-country'\n if (country !== AD_AUDIENCE_COUNTRY) return 'not-us'\n return null\n}\n\n/*==========================================\n * THE DIFF\n *=========================================*/\n\n/** What has to change for a provider to hold `next` when it holds `previous`. */\nexport function diffAudience(\n previous: Iterable<string>,\n next: Iterable<string>,\n): { toAdd: string[]; toRemove: string[] } {\n const before = new Set(previous)\n const after = new Set(next)\n return {\n toAdd: [...after].filter((hash) => !before.has(hash)).sort(),\n toRemove: [...before].filter((hash) => !after.has(hash)).sort(),\n }\n}\n\n/**\n * Whether a night's set shrank so far it is more likely a failed read than\n * real churn. Below twenty there is no telling the two apart, so it is never\n * refused; above it, losing more than half the audience in one night is held\n * until a person passes `force`.\n */\nexport function isSuspiciousShrink(previousSize: number, nextSize: number): boolean {\n return previousSize >= 20 && nextSize < previousSize / 2\n}\n\n/** `items` in runs of at most `size`. */\nexport function chunk<T>(items: readonly T[], size: number): T[][] {\n const runs: T[][] = []\n for (let index = 0; index < items.length; index += size) {\n runs.push(items.slice(index, index + size))\n }\n return runs\n}\n\n/*==========================================\n * CONFIGURATION\n *=========================================*/\n\nexport interface MetaAudienceConfig {\n accessToken: string\n audienceId: string\n}\n\nexport interface GoogleAudienceConfig {\n clientId: string\n clientSecret: string\n refreshToken: string\n /** The Google Ads customer that owns the list, digits only. */\n customerId: string\n /** A manager account the credentials reach the customer through, digits only. */\n loginCustomerId: string | null\n /** The Customer Match user list id. */\n userListId: string\n}\n\nexport interface LinkedInAudienceConfig {\n accessToken: string\n segmentId: string\n}\n\nexport interface AdAudienceConfig {\n meta: MetaAudienceConfig | null\n google: GoogleAudienceConfig | null\n linkedin: LinkedInAudienceConfig | null\n /** For each provider left unconfigured, the variables it is missing. */\n missing: Record<AdAudienceProvider, string[]>\n}\n\n/**\n * The variables, read here by their literal names so the self-host\n * environment reference can see every one of them.\n */\nexport function adAudienceEnv(): Record<string, string | undefined> {\n return {\n META_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.META_AUDIENCE_SYNC_ACCESS_TOKEN,\n META_AUDIENCE_SYNC_AUDIENCE_ID: process.env.META_AUDIENCE_SYNC_AUDIENCE_ID,\n GOOGLE_AUDIENCE_SYNC_CLIENT_ID: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_ID,\n GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET: process.env.GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET,\n GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN: process.env.GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN,\n GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID,\n GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID: process.env.GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID,\n GOOGLE_AUDIENCE_SYNC_USER_LIST_ID: process.env.GOOGLE_AUDIENCE_SYNC_USER_LIST_ID,\n LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN: process.env.LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN,\n LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID: process.env.LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID,\n }\n}\n\nconst REQUIRED: Record<AdAudienceProvider, readonly string[]> = {\n meta: ['META_AUDIENCE_SYNC_ACCESS_TOKEN', 'META_AUDIENCE_SYNC_AUDIENCE_ID'],\n google: [\n 'GOOGLE_AUDIENCE_SYNC_CLIENT_ID',\n 'GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET',\n 'GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN',\n 'GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID',\n 'GOOGLE_AUDIENCE_SYNC_USER_LIST_ID',\n ],\n linkedin: ['LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN', 'LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID'],\n}\n\nconst digits = (value: string) => value.replace(/\\D/g, '')\n\n/**\n * Each provider's settings, or null with the names it lacks. A provider is\n * configured only when every required variable is set; a half-configured one\n * is skipped, never half-sent.\n *\n * The Meta audience is never created here. Creating one when the id is unset\n * would mint a fresh, empty audience on every run whose variable was lost, and\n * the ads targeting the real one would go on pointing at it, so the id is\n * required and the audience is made once, by hand.\n */\nexport function readAdAudienceConfig(\n env: Record<string, string | undefined> = adAudienceEnv(),\n): AdAudienceConfig {\n const value = (name: string) => String(env[name] ?? '').trim()\n const missing = Object.fromEntries(\n AD_AUDIENCE_PROVIDERS.map((provider) => [\n provider,\n REQUIRED[provider].filter((name) => !value(name)),\n ]),\n ) as Record<AdAudienceProvider, string[]>\n const loginCustomerId = digits(value('GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID'))\n return {\n meta: missing.meta.length\n ? null\n : {\n accessToken: value('META_AUDIENCE_SYNC_ACCESS_TOKEN'),\n // `act_` belongs to the ad account, never to an audience id.\n audienceId: value('META_AUDIENCE_SYNC_AUDIENCE_ID'),\n },\n google: missing.google.length\n ? null\n : {\n clientId: value('GOOGLE_AUDIENCE_SYNC_CLIENT_ID'),\n clientSecret: value('GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET'),\n refreshToken: value('GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN'),\n // Google Ads shows `841-500-9958`; the API takes the digits.\n customerId: digits(value('GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID')),\n loginCustomerId: loginCustomerId || null,\n userListId: digits(value('GOOGLE_AUDIENCE_SYNC_USER_LIST_ID')),\n },\n linkedin: missing.linkedin.length\n ? null\n : {\n accessToken: value('LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN'),\n // The segment URN's id, whichever form was pasted.\n segmentId: value('LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID').replace(/^urn:li:dmpSegment:/, ''),\n },\n missing,\n }\n}\n\n/*==========================================\n * PROVIDER REQUESTS\n *=========================================*/\n\n/** One HTTP request, built without sending it. */\nexport interface AudienceRequest {\n method: 'POST'\n url: string\n headers: Record<string, string>\n /** A JSON body, or a form body when `headers` says so. */\n body: Record<string, unknown> | string\n}\n\n/**\n * META — the replace session.\n *\n * `POST /{audience}/usersreplace` swaps the audience's whole membership for\n * the batches of one session: `batch_seq` 1 opens it and `last_batch_flag`\n * closes it, so a removal needs no separate call and nothing has to be\n * remembered about who was there before. Each batch carries at most 10,000\n * hashes, and the session must finish inside 90 minutes. A replace does not\n * reset an ad set's learning phase, which an add and a delete would.\n */\nexport const META_GRAPH_VERSION = 'v25.0'\nexport const META_REPLACE_BATCH = 10_000\n\nexport function buildMetaReplaceRequests(\n config: MetaAudienceConfig,\n hashes: readonly string[],\n sessionId: number,\n): AudienceRequest[] {\n const batches = chunk(hashes, META_REPLACE_BATCH)\n return batches.map((batch, index) => ({\n method: 'POST',\n url: `https://graph.facebook.com/${META_GRAPH_VERSION}/${encodeURIComponent(config.audienceId)}/usersreplace`,\n headers: { 'Content-Type': 'application/json' },\n body: {\n payload: { schema: 'EMAIL_SHA256', data: batch },\n session: {\n session_id: sessionId,\n batch_seq: index + 1,\n last_batch_flag: index === batches.length - 1,\n estimated_num_total: hashes.length,\n },\n access_token: config.accessToken,\n },\n }))\n}\n\n/**\n * GOOGLE — the Data Manager API, not the Google Ads API.\n *\n * Since 2026-04-01 the Google Ads API's `OfflineUserDataJobService` refuses\n * Customer Match uploads for a developer token with no Customer Match\n * activity in the six months before, and Google points every such project at\n * the Data Manager API instead. It needs an OAuth token with the\n * `datamanager` scope and no developer token.\n *\n * Adds go to `audienceMembers:ingest` and removals to `audienceMembers:remove`,\n * 10,000 members a request. The Data Manager API has no replace, so the\n * removals come from the set this sync last sent.\n *\n * No `consent` is sent. The audience is United States account holders only,\n * for whom Google requires no consent signal, and stating `CONSENT_GRANTED`\n * would assert a consent this platform does not collect.\n * `customerMatchTermsOfServiceStatus: ACCEPTED` is the operator's own\n * acceptance of the Customer Match terms, which Google requires on an\n * ingest; they are accepted in the Google Ads account before this is\n * configured.\n */\nexport const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'\nexport const GOOGLE_DATA_MANAGER_BASE = 'https://datamanager.googleapis.com/v1'\nexport const GOOGLE_MEMBERS_BATCH = 10_000\n\nexport function buildGoogleTokenRequest(config: GoogleAudienceConfig): AudienceRequest {\n return {\n method: 'POST',\n url: GOOGLE_TOKEN_URL,\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: config.clientId,\n client_secret: config.clientSecret,\n refresh_token: config.refreshToken,\n }).toString(),\n }\n}\n\nexport function buildGoogleMemberRequests(\n config: GoogleAudienceConfig,\n accessToken: string,\n action: 'ingest' | 'remove',\n hashes: readonly string[],\n): AudienceRequest[] {\n const destination = {\n operatingAccount: { accountType: 'GOOGLE_ADS', accountId: config.customerId },\n ...(config.loginCustomerId\n ? { loginAccount: { accountType: 'GOOGLE_ADS', accountId: config.loginCustomerId } }\n : {}),\n productDestinationId: config.userListId,\n }\n return chunk(hashes, GOOGLE_MEMBERS_BATCH).map((batch) => ({\n method: 'POST',\n url: `${GOOGLE_DATA_MANAGER_BASE}/audienceMembers:${action}`,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${accessToken}`,\n },\n body: {\n destinations: [destination],\n audienceMembers: batch.map((hash) => ({\n userData: { userIdentifiers: [{ emailAddress: hash }] },\n })),\n encoding: 'HEX',\n ...(action === 'ingest'\n ? { termsOfService: { customerMatchTermsOfServiceStatus: 'ACCEPTED' } }\n : {}),\n },\n }))\n}\n\n/**\n * LINKEDIN — DMP segment users, streamed.\n *\n * `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE`\n * takes up to 5,000 `ADD` or `REMOVE` elements a request. There is no\n * replace, so the removals come from the set this sync last sent. Adding an\n * id twice or removing one already gone both answer 201, so a retry is safe.\n * The API is open only to developers LinkedIn has approved for Matched\n * Audiences, with the `rw_dmp_segments` permission.\n */\nexport const LINKEDIN_VERSION = '202609'\nexport const LINKEDIN_USERS_BATCH = 5_000\n\nexport function buildLinkedInUserRequests(\n config: LinkedInAudienceConfig,\n action: 'ADD' | 'REMOVE',\n hashes: readonly string[],\n): AudienceRequest[] {\n return chunk(hashes, LINKEDIN_USERS_BATCH).map((batch) => ({\n method: 'POST',\n url: `https://api.linkedin.com/rest/dmpSegments/${encodeURIComponent(config.segmentId)}/users`,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${config.accessToken}`,\n 'LinkedIn-Version': LINKEDIN_VERSION,\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: {\n elements: batch.map((hash) => ({\n action,\n userIds: [{ idType: 'SHA256_EMAIL', idValue: hash }],\n })),\n },\n }))\n}\n\n/** How many elements of a LinkedIn batch response were refused. */\nexport function linkedInRefusedCount(answer: unknown): number {\n const elements = (answer as { elements?: unknown } | null)?.elements\n if (!Array.isArray(elements)) return 0\n return elements.filter((element) => {\n const status = Number((element as { status?: unknown } | null)?.status)\n return !(status >= 200 && status < 300)\n }).length\n}\n"],"names":["createHash","AD_AUDIENCE_PROVIDERS","AD_AUDIENCE_PROVIDER_LABELS","meta","google","linkedin","GOOGLE_CONSUMER_DOMAINS","Set","normalizeAudienceEmail","provider","raw","email","trim","toLowerCase","replace","at","lastIndexOf","length","indexOf","local","slice","domain","has","plus","sha256Hex","value","update","digest","hashAudienceEmail","normalized","AD_AUDIENCE_EXCLUSIONS","AD_AUDIENCE_COUNTRY","ALPHA2","resolveAccountCountry","account","billing","billingCountries","map","country","String","toUpperCase","filter","test","foreign","find","source","signup","signupCountry","emailDomain","address","adAudienceExclusion","options","disabled","staff","internalDomains","some","internal","locked","banned","optedOut","emailVerified","diffAudience","previous","next","before","after","toAdd","hash","sort","toRemove","isSuspiciousShrink","previousSize","nextSize","chunk","items","size","runs","index","push","adAudienceEnv","META_AUDIENCE_SYNC_ACCESS_TOKEN","process","env","META_AUDIENCE_SYNC_AUDIENCE_ID","GOOGLE_AUDIENCE_SYNC_CLIENT_ID","GOOGLE_AUDIENCE_SYNC_CLIENT_SECRET","GOOGLE_AUDIENCE_SYNC_REFRESH_TOKEN","GOOGLE_AUDIENCE_SYNC_CUSTOMER_ID","GOOGLE_AUDIENCE_SYNC_LOGIN_CUSTOMER_ID","GOOGLE_AUDIENCE_SYNC_USER_LIST_ID","LINKEDIN_AUDIENCE_SYNC_ACCESS_TOKEN","LINKEDIN_AUDIENCE_SYNC_SEGMENT_ID","REQUIRED","digits","readAdAudienceConfig","name","missing","Object","fromEntries","loginCustomerId","accessToken","audienceId","clientId","clientSecret","refreshToken","customerId","userListId","segmentId","META_GRAPH_VERSION","META_REPLACE_BATCH","buildMetaReplaceRequests","config","hashes","sessionId","batches","batch","method","url","encodeURIComponent","headers","body","payload","schema","data","session","session_id","batch_seq","last_batch_flag","estimated_num_total","access_token","GOOGLE_TOKEN_URL","GOOGLE_DATA_MANAGER_BASE","GOOGLE_MEMBERS_BATCH","buildGoogleTokenRequest","URLSearchParams","grant_type","client_id","client_secret","refresh_token","toString","buildGoogleMemberRequests","action","destination","operatingAccount","accountType","accountId","loginAccount","productDestinationId","Authorization","destinations","audienceMembers","userData","userIdentifiers","emailAddress","encoding","termsOfService","customerMatchTermsOfServiceStatus","LINKEDIN_VERSION","LINKEDIN_USERS_BATCH","buildLinkedInUserRequests","elements","userIds","idType","idValue","linkedInRefusedCount","answer","Array","isArray","element","status","Number"],"mappings":"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"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A person's consent as their CONTACT records it, read by address.
|
|
19
|
+
*
|
|
20
|
+
* Three surfaces ask this, and they must get one answer:
|
|
21
|
+
*
|
|
22
|
+
* - the list gate (`email/list-members-preview` and `-add`), deciding
|
|
23
|
+
* whether an address can be enrolled and on what basis;
|
|
24
|
+
* - `performCampaignSend`, for a list member a rule enrolled — the
|
|
25
|
+
* materializer writes no basis onto those rows, because matching a rule is
|
|
26
|
+
* not an opt-in, so the person's real basis is the one on their contact;
|
|
27
|
+
* - the list's Consent column, which shows what the send will act on.
|
|
28
|
+
*
|
|
29
|
+
* A copy per surface agrees only until the shape moves, which is how the
|
|
30
|
+
* Consent column once reported every member as having no basis while the
|
|
31
|
+
* sender mailed them.
|
|
32
|
+
*/
|
|
33
|
+
import type { ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups';
|
|
34
|
+
import { type MarketingConsentRecord } from '@aglyn/aglyn/app-utils/marketing-consent';
|
|
35
|
+
/** Addresses per `in` query — Firestore's disjunction limit. */
|
|
36
|
+
export declare const CONTACT_LOOKUP_CHUNK = 30;
|
|
37
|
+
/**
|
|
38
|
+
* The stored consent facts for these addresses, off the org's contacts.
|
|
39
|
+
*
|
|
40
|
+
* Read UNSCOPED, deliberately, exactly as the Inbox route reads it:
|
|
41
|
+
* `scopedToHost` narrows an org collection to what one site may see, and a
|
|
42
|
+
* refusal filtered out by that narrowing is a refusal the caller would then
|
|
43
|
+
* step over — the failure mode is mailing or enrolling somebody who said no.
|
|
44
|
+
* A grant cannot leak across sites through this read either: the record is
|
|
45
|
+
* read for `group`, so another site's opt-in answers `otherGrant`, never
|
|
46
|
+
* `granted`. Callers serving a browser must still prove the requester is an
|
|
47
|
+
* org-wide member, which is the tier the rules grant the whole org's
|
|
48
|
+
* contacts to.
|
|
49
|
+
*
|
|
50
|
+
* ## A refusal wins over an opt-in when the CRM holds both
|
|
51
|
+
*
|
|
52
|
+
* Nothing guarantees one contact per address — the collection is keyed by
|
|
53
|
+
* resource id, not by email — so two records for one person can disagree.
|
|
54
|
+
* Taking whichever the query happened to answer first would make the outcome
|
|
55
|
+
* depend on document order. A recorded refusal is the answer whenever one
|
|
56
|
+
* exists, which is the same precedence `assignmentBasis` applies within a
|
|
57
|
+
* single record.
|
|
58
|
+
*
|
|
59
|
+
* ## A refusal outlives the contact it was written on (AGL-3338)
|
|
60
|
+
*
|
|
61
|
+
* Deleting a contact keeps every refusal it held in the org's retained store,
|
|
62
|
+
* so the address is read with what was retained laid over whatever record is
|
|
63
|
+
* left — `withRetainedRefusals` — and an address with no contact at all still
|
|
64
|
+
* reads `declined` when its person said no. A grant given after the refusal,
|
|
65
|
+
* on a contact the person signed up again through, is the person changing
|
|
66
|
+
* their mind and reads `granted`. The grants carried onto a membership come
|
|
67
|
+
* off the same merged record.
|
|
68
|
+
*
|
|
69
|
+
* A failed read falls to `declined` for the whole batch: a read that throws
|
|
70
|
+
* can neither say the person consented nor that they refused, and the
|
|
71
|
+
* direction that costs a retry is the one that does not reach somebody whose
|
|
72
|
+
* stored refusal we simply failed to see. The retained store is read the
|
|
73
|
+
* same way, and fails the same way.
|
|
74
|
+
*
|
|
75
|
+
* @param contacts the org's contacts collection
|
|
76
|
+
* (`orgDataCollectionForHost(hostId, 'contacts')`), passed
|
|
77
|
+
* as the pending lookup so a failure to resolve it reads as
|
|
78
|
+
* the same `declined` as a failed query.
|
|
79
|
+
* @param addresses normalized addresses (`normalizeContactEmail`).
|
|
80
|
+
*/
|
|
81
|
+
export declare function readContactConsent(contacts: FirebaseFirestore.CollectionReference | Promise<FirebaseFirestore.CollectionReference>, group: ConsentGroup, addresses: readonly string[]): Promise<{
|
|
82
|
+
stored: Map<string, MarketingConsentRecord>;
|
|
83
|
+
grants: Map<string, Record<string, Record<string, unknown>>>;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Lays each rule-enrolled member's contact basis over the basis its row
|
|
87
|
+
* carries, where the row carries none.
|
|
88
|
+
*
|
|
89
|
+
* A row the dynamic-list materializer wrote (`via: 'rule'`) never holds a
|
|
90
|
+
* basis of its own — see the ⛔ in `dynamic-list-materialize.ts` — so for
|
|
91
|
+
* those rows the person's contact is the record. Only an `unrecorded` row
|
|
92
|
+
* is filled: a basis stored on the membership (an attestation, an opt-in an
|
|
93
|
+
* automation carried, a refusal) is a fact about that membership and stays
|
|
94
|
+
* the answer. Rows enrolled any other way are untouched.
|
|
95
|
+
*
|
|
96
|
+
* @param contacts as for {@link readContactConsent}.
|
|
97
|
+
* @param records the members' own records, keyed by normalized address;
|
|
98
|
+
* updated in place.
|
|
99
|
+
* @param ruleMembers the normalized addresses of the `via: 'rule'` rows.
|
|
100
|
+
*/
|
|
101
|
+
export declare function withContactConsentForRuleMembers(contacts: FirebaseFirestore.CollectionReference | Promise<FirebaseFirestore.CollectionReference>, group: ConsentGroup, records: Map<string, MarketingConsentRecord>, ruleMembers: ReadonlySet<string>): Promise<Map<string, MarketingConsentRecord>>;
|