@reachy/audience-module 1.0.4 → 1.0.6

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.
Files changed (32) hide show
  1. package/dist/AudienceModule.d.ts +1 -0
  2. package/dist/AudienceModule.d.ts.map +1 -1
  3. package/dist/AudienceModule.js +24 -2
  4. package/dist/AudienceModule.js.map +1 -1
  5. package/dist/builders/CriteriaParser.d.ts.map +1 -1
  6. package/dist/builders/CriteriaParser.js +0 -1
  7. package/dist/builders/CriteriaParser.js.map +1 -1
  8. package/dist/engine/V2AudienceEngine.d.ts +20 -0
  9. package/dist/engine/V2AudienceEngine.d.ts.map +1 -0
  10. package/dist/engine/V2AudienceEngine.js +1269 -0
  11. package/dist/engine/V2AudienceEngine.js.map +1 -0
  12. package/dist/executors/StaticAudienceExecutor.d.ts.map +1 -1
  13. package/dist/executors/StaticAudienceExecutor.js +0 -2
  14. package/dist/executors/StaticAudienceExecutor.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +5 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/repositories/SupabaseContactRepository.d.ts +16 -0
  20. package/dist/repositories/SupabaseContactRepository.d.ts.map +1 -0
  21. package/dist/repositories/SupabaseContactRepository.js +33 -0
  22. package/dist/repositories/SupabaseContactRepository.js.map +1 -0
  23. package/dist/types/index.d.ts +36 -4
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/AudienceModule.ts +50 -2
  27. package/src/builders/CriteriaParser.ts +0 -1
  28. package/src/engine/V2AudienceEngine.ts +1242 -0
  29. package/src/executors/StaticAudienceExecutor.ts +0 -2
  30. package/src/index.ts +2 -0
  31. package/src/repositories/SupabaseContactRepository.ts +50 -0
  32. package/src/types/index.ts +40 -4
@@ -0,0 +1,1269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.V2AudienceEngine = void 0;
4
+ const CriteriaParser_1 = require("../builders/CriteriaParser");
5
+ class V2AudienceEngine {
6
+ constructor(params) {
7
+ this.supabase = params.supabaseClient;
8
+ this.debug = !!params.debug;
9
+ this.logger = params.logger || console;
10
+ }
11
+ async getContactIdsByAudienceCriteriaV2(organizationId, projectId, criteriaRaw) {
12
+ let criteria = CriteriaParser_1.CriteriaParser.parse(criteriaRaw);
13
+ if ((!Array.isArray(criteria.groups) || criteria.groups.length === 0) &&
14
+ (Array.isArray(criteria.filters) || Array.isArray(criteria.conditions))) {
15
+ criteria = coerceCriteriaToGroups(criteria);
16
+ }
17
+ this.dlog('start', {
18
+ organizationId,
19
+ projectId,
20
+ type: String(criteria?.type || ''),
21
+ groups: Array.isArray(criteria?.groups) ? criteria.groups.length : 0
22
+ });
23
+ if (!criteria || !Array.isArray(criteria.groups) || criteria.groups.length === 0) {
24
+ this.dlog('V2 Engine: no groups; returning empty set');
25
+ return new Set();
26
+ }
27
+ const typeId = String(criteria?.type || '');
28
+ const isPastBehavior = typeId === 'past-behavior';
29
+ const rawAsOf = (criteria?.asOf ?? criteria?.as_of ?? criteria?.frozenAt ?? criteria?.frozen_at);
30
+ const asOfDate = isPastBehavior && typeof rawAsOf === 'string' && rawAsOf.trim() ? new Date(rawAsOf) : null;
31
+ const asOf = isPastBehavior && asOfDate && !Number.isNaN(asOfDate.getTime())
32
+ ? asOfDate
33
+ : null;
34
+ const asOfIso = asOf ? asOf.toISOString() : null;
35
+ const { data: allContacts } = await this.supabase
36
+ .from('contacts')
37
+ .select('id, reachy_id, email')
38
+ .eq('organization_id', organizationId)
39
+ .eq('project_id', projectId);
40
+ const allContactIds = new Set((allContacts || []).map((c) => c.id));
41
+ const reachyIdToContactId = new Map();
42
+ const emailToContactId = new Map();
43
+ for (const c of allContacts || []) {
44
+ const rid = c?.reachy_id ? String(c.reachy_id).trim() : '';
45
+ const cid = c?.id ? String(c.id).trim() : '';
46
+ if (rid && cid)
47
+ reachyIdToContactId.set(rid, cid);
48
+ const email = c?.email ? String(c.email).trim().toLowerCase() : '';
49
+ if (email && cid)
50
+ emailToContactId.set(email, cid);
51
+ }
52
+ const union = (a, b) => new Set([...a, ...b]);
53
+ const intersect = (a, b) => new Set([...a].filter(x => b.has(x)));
54
+ const diff = (a, b) => new Set([...a].filter(x => !b.has(x)));
55
+ const resolveEventContactId = (row) => {
56
+ const rawReachyId = row?.reachy_id ? String(row.reachy_id).trim() : '';
57
+ if (rawReachyId) {
58
+ const mapped = reachyIdToContactId.get(rawReachyId);
59
+ if (mapped)
60
+ return mapped;
61
+ }
62
+ const rawContactId = row?.contact_id ? String(row.contact_id).trim() : '';
63
+ if (rawContactId && allContactIds.has(rawContactId))
64
+ return rawContactId;
65
+ const ed = row?.event_data || {};
66
+ const emailCandidates = [ed.email, ed.user_email, ed.userEmail, ed.contact_email, ed.contactEmail];
67
+ for (const e of emailCandidates) {
68
+ if (typeof e === 'string') {
69
+ const normalized = e.trim().toLowerCase();
70
+ if (!normalized)
71
+ continue;
72
+ const mapped = emailToContactId.get(normalized);
73
+ if (mapped)
74
+ return mapped;
75
+ }
76
+ }
77
+ return null;
78
+ };
79
+ const evalEventRule = async (rule) => {
80
+ const cfg = criteria?.config || {};
81
+ const effectiveEventName = cfg.eventType && String(cfg.eventType).trim() !== ''
82
+ ? String(cfg.eventType)
83
+ : String(rule.eventName);
84
+ let query = this.supabase
85
+ .from('contact_events')
86
+ .select('contact_id, reachy_id, event_data, event_timestamp, event_name')
87
+ .eq('organization_id', organizationId)
88
+ .eq('project_id', projectId)
89
+ .eq('event_name', effectiveEventName);
90
+ if (isPastBehavior && asOfIso) {
91
+ query = query.lte('event_timestamp', asOfIso);
92
+ }
93
+ if (typeId === 'live-page-visit') {
94
+ const v = cfg.pageUrl;
95
+ const d = cfg.domain;
96
+ const ors = [];
97
+ if (v && String(v).trim() !== '') {
98
+ const like = `%${String(v)}%`;
99
+ ors.push(`path.ilike.${like}`);
100
+ ors.push(`current_url.ilike.${like}`);
101
+ }
102
+ if (d && String(d).trim() !== '') {
103
+ const dlike = `%${String(d)}%`;
104
+ ors.push(`domain.ilike.${dlike}`);
105
+ ors.push(`event_data->>domain.ilike.${dlike}`);
106
+ }
107
+ if (ors.length > 0)
108
+ query = query.or(ors.join(','));
109
+ }
110
+ if (typeId === 'live-referrer') {
111
+ query = query.eq('session_is_new', true);
112
+ const ors = [];
113
+ const v = cfg.referrerUrl;
114
+ if (v && String(v).trim() !== '')
115
+ ors.push(`referrer.ilike.%${String(v)}%`);
116
+ if (cfg.utm_source && String(cfg.utm_source).trim() !== '')
117
+ ors.push(`event_data->>utm_source.ilike.%${String(cfg.utm_source)}%`);
118
+ if (cfg.utm_medium && String(cfg.utm_medium).trim() !== '')
119
+ ors.push(`event_data->>utm_medium.ilike.%${String(cfg.utm_medium)}%`);
120
+ if (cfg.utm_campaign && String(cfg.utm_campaign).trim() !== '')
121
+ ors.push(`event_data->>utm_campaign.ilike.%${String(cfg.utm_campaign)}%`);
122
+ if (cfg.utm_term && String(cfg.utm_term).trim() !== '')
123
+ ors.push(`event_data->>utm_term.ilike.%${String(cfg.utm_term)}%`);
124
+ if (cfg.utm_content && String(cfg.utm_content).trim() !== '')
125
+ ors.push(`event_data->>utm_content.ilike.%${String(cfg.utm_content)}%`);
126
+ if (ors.length > 0)
127
+ query = query.or(ors.join(','));
128
+ }
129
+ if (!rule.time && cfg && cfg.timeFrame) {
130
+ const tf = String(cfg.timeFrame).trim();
131
+ const now = asOf ?? new Date();
132
+ let unit = 'days';
133
+ let value = 7;
134
+ if (/^\d+$/.test(tf)) {
135
+ value = Number(tf);
136
+ unit = 'days';
137
+ }
138
+ else if (/^\d+\s*m$/.test(tf)) {
139
+ value = Number(tf.replace(/m$/, ''));
140
+ unit = 'minutes';
141
+ }
142
+ else if (/^\d+\s*h$/.test(tf)) {
143
+ value = Number(tf.replace(/h$/, ''));
144
+ unit = 'hours';
145
+ }
146
+ else if (/^\d+\s*d$/.test(tf)) {
147
+ value = Number(tf.replace(/d$/, ''));
148
+ unit = 'days';
149
+ }
150
+ else if (/^\d+\s*w$/.test(tf)) {
151
+ value = Number(tf.replace(/w$/, ''));
152
+ unit = 'weeks';
153
+ }
154
+ else if (/^\d+\s*mo$/.test(tf)) {
155
+ value = Number(tf.replace(/mo$/, ''));
156
+ unit = 'months';
157
+ }
158
+ const units = {
159
+ minutes: 60 * 1000,
160
+ hours: 60 * 60 * 1000,
161
+ days: 24 * 60 * 60 * 1000,
162
+ weeks: 7 * 24 * 60 * 60 * 1000,
163
+ months: 30 * 24 * 60 * 60 * 1000
164
+ };
165
+ const from = new Date(now.getTime() - value * units[unit]);
166
+ query = query.gte('event_timestamp', from.toISOString());
167
+ }
168
+ if (rule.time) {
169
+ if (rule.time.unit && rule.time.value) {
170
+ const unit = String(rule.time.unit);
171
+ const value = Number(rule.time.value);
172
+ const now = asOf ?? new Date();
173
+ const units = {
174
+ minutes: 60 * 1000,
175
+ hours: 60 * 60 * 1000,
176
+ days: 24 * 60 * 60 * 1000,
177
+ weeks: 7 * 24 * 60 * 60 * 1000,
178
+ months: 30 * 24 * 60 * 60 * 1000
179
+ };
180
+ const ms = units[unit] ?? units['days'];
181
+ const from = new Date(now.getTime() - value * ms);
182
+ query = query.gte('event_timestamp', from.toISOString());
183
+ }
184
+ else {
185
+ if (rule.time.from) {
186
+ const rawFrom = rule.time.from;
187
+ const fromStr = typeof rawFrom === 'string' ? rawFrom : String(rawFrom);
188
+ const isDateOnly = typeof rawFrom === 'string' && !fromStr.includes('T');
189
+ const startIso = isDateOnly ? normalizeDateBoundary(fromStr, 'start') : undefined;
190
+ const finalFrom = startIso || fromStr;
191
+ query = query.gte('event_timestamp', finalFrom);
192
+ }
193
+ if (rule.time.to) {
194
+ const rawTo = rule.time.to;
195
+ const toStr = typeof rawTo === 'string' ? rawTo : String(rawTo);
196
+ const isDateOnly = typeof rawTo === 'string' && !toStr.includes('T');
197
+ const endIsoExclusive = isDateOnly ? normalizeDateBoundary(toStr, 'end') : undefined;
198
+ const finalTo = endIsoExclusive || toStr;
199
+ query = isDateOnly ? query.lt('event_timestamp', finalTo) : query.lte('event_timestamp', finalTo);
200
+ }
201
+ }
202
+ }
203
+ try {
204
+ const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : []);
205
+ const trackerEvents = new Set([
206
+ 'click',
207
+ 'page_view',
208
+ 'form_submit',
209
+ 'time_on_page',
210
+ 'heartbeat',
211
+ 'scroll_depth',
212
+ 'scroll_depth_snapshot'
213
+ ]);
214
+ const resolveDbFieldForAttrKey = (keyRaw) => {
215
+ const key = String(keyRaw || '').trim();
216
+ if (!key)
217
+ return null;
218
+ const columnKeys = new Set([
219
+ 'current_url',
220
+ 'domain',
221
+ 'path',
222
+ 'referrer',
223
+ 'page_title',
224
+ 'utm_source',
225
+ 'utm_medium',
226
+ 'utm_campaign',
227
+ 'utm_term',
228
+ 'utm_content',
229
+ 'session_id',
230
+ 'session_is_new'
231
+ ]);
232
+ if (columnKeys.has(key))
233
+ return { kind: 'column', field: key };
234
+ const buildNested = (root, dottedPath) => jsonNestedTextAccessor(root, dottedPath);
235
+ if (key.startsWith('event_data.'))
236
+ return { kind: 'json', field: buildNested('event_data', key.replace(/^event_data\./, '')) };
237
+ if (key.startsWith('custom_data.'))
238
+ return { kind: 'json', field: buildNested('event_data->custom_data', key.replace(/^custom_data\./, '')) };
239
+ if (key.startsWith('url_data.'))
240
+ return { kind: 'json', field: buildNested('event_data->url_data', key.replace(/^url_data\./, '')) };
241
+ if (key.startsWith('utm_data.'))
242
+ return { kind: 'json', field: buildNested('event_data->utm_data', key.replace(/^utm_data\./, '')) };
243
+ if (key.startsWith('session_data.'))
244
+ return { kind: 'json', field: buildNested('event_data->session_data', key.replace(/^session_data\./, '')) };
245
+ if (key.includes('.'))
246
+ return { kind: 'json', field: buildNested('event_data->custom_data', key) };
247
+ if (trackerEvents.has(effectiveEventName))
248
+ return { kind: 'json', field: jsonTextAccessor('event_data->custom_data', key) };
249
+ return { kind: 'json', field: jsonTextAccessor('event_data', key) };
250
+ };
251
+ for (const attr of attributes) {
252
+ const key = String(attr.key || '').trim();
253
+ const op = String(attr.op || 'contains');
254
+ const value = attr.value;
255
+ if (!key || value == null)
256
+ continue;
257
+ const resolved = resolveDbFieldForAttrKey(key);
258
+ if (!resolved)
259
+ continue;
260
+ const dbField = resolved.field;
261
+ switch (op) {
262
+ case 'equals':
263
+ query = query.filter(dbField, 'eq', value);
264
+ break;
265
+ case 'not_equals':
266
+ query = query.filter(dbField, 'neq', value);
267
+ break;
268
+ case 'starts_with':
269
+ query = query.filter(dbField, 'ilike', `${value}%`);
270
+ break;
271
+ case 'ends_with':
272
+ query = query.filter(dbField, 'ilike', `%${value}`);
273
+ break;
274
+ case 'contains':
275
+ default:
276
+ query = query.filter(dbField, 'ilike', `%${value}%`);
277
+ break;
278
+ }
279
+ }
280
+ }
281
+ catch (e) {
282
+ if (this.debug)
283
+ this.logger.warn('[AUDIENCE_MODULE_ENGINE] event attributes filter error');
284
+ }
285
+ const { data, error } = await query;
286
+ if (error)
287
+ return new Set();
288
+ if (rule.frequency && rule.frequency.value != null) {
289
+ const { op, value, type = 'count', field = 'value' } = rule.frequency;
290
+ const counts = new Map();
291
+ for (const row of data || []) {
292
+ const id = resolveEventContactId(row);
293
+ if (!id)
294
+ continue;
295
+ if (type === 'count') {
296
+ counts.set(id, (counts.get(id) || 0) + 1);
297
+ }
298
+ else if (type === 'sum' || type === 'avg') {
299
+ let numVal = 0;
300
+ const eventData = row.event_data || {};
301
+ if (eventData[field] !== undefined)
302
+ numVal = Number(eventData[field]);
303
+ else if (eventData['value'] !== undefined)
304
+ numVal = Number(eventData['value']);
305
+ else if (eventData['amount'] !== undefined)
306
+ numVal = Number(eventData['amount']);
307
+ if (!Number.isNaN(numVal)) {
308
+ counts.set(id, (counts.get(id) || 0) + numVal);
309
+ }
310
+ }
311
+ }
312
+ const res = new Set();
313
+ counts.forEach((aggregatedValue, id) => {
314
+ let finalValue = aggregatedValue;
315
+ if (type === 'avg') {
316
+ let eventCount = 0;
317
+ for (const row of data || []) {
318
+ const rid = resolveEventContactId(row);
319
+ if (rid === id)
320
+ eventCount++;
321
+ }
322
+ finalValue = eventCount > 0 ? aggregatedValue / eventCount : 0;
323
+ }
324
+ if ((op === '>=' && finalValue >= value) ||
325
+ (op === '>' && finalValue > value) ||
326
+ (op === '=' && finalValue === value) ||
327
+ (op === '<=' && finalValue <= value) ||
328
+ (op === '<' && finalValue < value)) {
329
+ res.add(id);
330
+ }
331
+ });
332
+ return res;
333
+ }
334
+ if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
335
+ const threshold = Number(cfg.pageCount);
336
+ const counts = new Map();
337
+ for (const row of data || []) {
338
+ const id = resolveEventContactId(row);
339
+ if (!id)
340
+ continue;
341
+ counts.set(id, (counts.get(id) || 0) + 1);
342
+ }
343
+ const res = new Set();
344
+ counts.forEach((cnt, id) => { if (cnt >= threshold)
345
+ res.add(id); });
346
+ return res;
347
+ }
348
+ const res = new Set();
349
+ for (const row of data || []) {
350
+ const id = resolveEventContactId(row);
351
+ if (id)
352
+ res.add(id);
353
+ }
354
+ return res;
355
+ };
356
+ const evalPropertyRule = async (rule) => {
357
+ let query = this.supabase
358
+ .from('contacts')
359
+ .select('id')
360
+ .eq('organization_id', organizationId)
361
+ .eq('project_id', projectId);
362
+ if (isPastBehavior && asOfIso) {
363
+ query = query.lte('created_at', asOfIso);
364
+ }
365
+ const field = rule.field;
366
+ const op = rule.op;
367
+ const value = rule.value;
368
+ const value2 = rule.value2;
369
+ const isDateField = field === 'created_at' || field === 'updated_at';
370
+ const timeFrom = rule?.time?.from;
371
+ const timeTo = rule?.time?.to;
372
+ const hasTimeRange = isDateField && (typeof timeFrom === 'string' || typeof timeTo === 'string');
373
+ const computeRelativeRange = (direction = 'past') => {
374
+ const unitSource = rule.timeUnit ?? rule.unit ?? 'days';
375
+ const rawUnit = String(unitSource).toLowerCase();
376
+ const numericValue = Number(rule.timeValue ?? rule.value ?? NaN);
377
+ if (!Number.isFinite(numericValue) || numericValue < 0)
378
+ return null;
379
+ const units = {
380
+ minutes: 60 * 1000,
381
+ hours: 60 * 60 * 1000,
382
+ days: 24 * 60 * 60 * 1000,
383
+ weeks: 7 * 24 * 60 * 60 * 1000,
384
+ months: 30 * 24 * 60 * 60 * 1000,
385
+ years: 365 * 24 * 60 * 60 * 1000
386
+ };
387
+ const now = asOf ?? new Date();
388
+ const baseUnitMs = units[rawUnit] ?? units['days'] ?? 24 * 60 * 60 * 1000;
389
+ const delta = baseUnitMs * numericValue;
390
+ const start = direction === 'past' ? new Date(now.getTime() - delta) : now;
391
+ const end = direction === 'past' ? now : new Date(now.getTime() + delta);
392
+ return { start: start.toISOString(), end: end.toISOString() };
393
+ };
394
+ const apply = (dbField, isJsonb = false) => {
395
+ switch (op) {
396
+ case 'equals':
397
+ if (isDateField) {
398
+ if (hasTimeRange) {
399
+ if (typeof timeFrom === 'string' && timeFrom.trim())
400
+ query = query.gte(dbField, timeFrom.trim());
401
+ if (typeof timeTo === 'string' && timeTo.trim())
402
+ query = query.lte(dbField, timeTo.trim());
403
+ }
404
+ else {
405
+ const startIso = normalizeDateBoundary(value, 'start');
406
+ const endIsoExclusive = normalizeDateBoundary(value, 'end');
407
+ if (startIso)
408
+ query = query.gte(dbField, startIso);
409
+ if (endIsoExclusive)
410
+ query = query.lt(dbField, endIsoExclusive);
411
+ }
412
+ }
413
+ else {
414
+ query = query.eq(dbField, value);
415
+ }
416
+ break;
417
+ case 'not_equals':
418
+ if (isJsonb && value === '') {
419
+ query = query.not(dbField, 'is', null).neq(dbField, '');
420
+ }
421
+ else {
422
+ query = query.neq(dbField, value);
423
+ }
424
+ break;
425
+ case 'contains':
426
+ query = query.ilike(dbField, `%${value}%`);
427
+ break;
428
+ case 'not_contains':
429
+ query = query.not(dbField, 'ilike', `%${value}%`);
430
+ break;
431
+ case 'starts_with':
432
+ query = query.ilike(dbField, `${value}%`);
433
+ break;
434
+ case 'ends_with':
435
+ query = query.ilike(dbField, `%${value}`);
436
+ break;
437
+ case '>':
438
+ query = query.gt(dbField, value);
439
+ break;
440
+ case '>=':
441
+ query = query.gte(dbField, value);
442
+ break;
443
+ case '<':
444
+ query = query.lt(dbField, value);
445
+ break;
446
+ case '<=':
447
+ query = query.lte(dbField, value);
448
+ break;
449
+ case 'between':
450
+ if (isDateField) {
451
+ if (hasTimeRange) {
452
+ if (typeof timeFrom === 'string' && timeFrom.trim())
453
+ query = query.gte(dbField, timeFrom.trim());
454
+ if (typeof timeTo === 'string' && timeTo.trim())
455
+ query = query.lte(dbField, timeTo.trim());
456
+ }
457
+ else {
458
+ const startIso = normalizeDateBoundary(value, 'start');
459
+ const endIsoExclusive = normalizeDateBoundary(value2 ?? value, 'end');
460
+ if (startIso)
461
+ query = query.gte(dbField, startIso);
462
+ if (endIsoExclusive)
463
+ query = query.lt(dbField, endIsoExclusive);
464
+ }
465
+ }
466
+ else {
467
+ if (value != null)
468
+ query = query.gte(dbField, value);
469
+ if (value2 != null)
470
+ query = query.lte(dbField, value2);
471
+ }
472
+ break;
473
+ case 'after':
474
+ if (isDateField) {
475
+ if (hasTimeRange && typeof timeFrom === 'string' && timeFrom.trim()) {
476
+ query = query.gte(dbField, timeFrom.trim());
477
+ }
478
+ else {
479
+ const startIso = normalizeDateBoundary(value, 'start');
480
+ if (startIso)
481
+ query = query.gte(dbField, startIso);
482
+ }
483
+ }
484
+ else {
485
+ query = query.gt(dbField, value);
486
+ }
487
+ break;
488
+ case 'before':
489
+ if (isDateField) {
490
+ if (hasTimeRange && typeof timeTo === 'string' && timeTo.trim()) {
491
+ query = query.lte(dbField, timeTo.trim());
492
+ }
493
+ else {
494
+ const endIsoExclusive = normalizeDateBoundary(value, 'end');
495
+ if (endIsoExclusive)
496
+ query = query.lt(dbField, endIsoExclusive);
497
+ }
498
+ }
499
+ else {
500
+ query = query.lt(dbField, value);
501
+ }
502
+ break;
503
+ case 'is_empty':
504
+ if (isJsonb)
505
+ query = query.or(`${dbField}.is.null,${dbField}.eq.`);
506
+ else
507
+ query = query.is(dbField, null);
508
+ break;
509
+ case 'is_not_empty':
510
+ if (isJsonb) {
511
+ const parts = dbField.split('->>');
512
+ const propertyObj = (parts[0] || '').trim();
513
+ const keyName = field;
514
+ if (propertyObj) {
515
+ query = query
516
+ .filter(`${propertyObj}`, 'cs', `{"${keyName}":`)
517
+ .not(dbField, 'is', null)
518
+ .neq(dbField, '');
519
+ }
520
+ else {
521
+ query = query.not(dbField, 'is', null).neq(dbField, '');
522
+ }
523
+ }
524
+ else {
525
+ query = query.not(dbField, 'is', null);
526
+ }
527
+ break;
528
+ case 'is_true':
529
+ query = query.eq(dbField, true);
530
+ break;
531
+ case 'is_false':
532
+ query = query.eq(dbField, false);
533
+ break;
534
+ case 'in_the_last': {
535
+ const range = computeRelativeRange('past');
536
+ if (range)
537
+ query = query.gte(dbField, range.start).lte(dbField, range.end);
538
+ break;
539
+ }
540
+ case 'in_the_next': {
541
+ const range = computeRelativeRange('future');
542
+ if (range)
543
+ query = query.gte(dbField, range.start).lte(dbField, range.end);
544
+ break;
545
+ }
546
+ }
547
+ };
548
+ const mappedField = mapAudienceFieldToContactField(field);
549
+ const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name'];
550
+ if (known.includes(field) || known.includes(mappedField)) {
551
+ apply(mappedField, false);
552
+ }
553
+ else {
554
+ const dbField = `properties->>${field}`;
555
+ apply(dbField, true);
556
+ }
557
+ const { data, error } = await query;
558
+ if (error)
559
+ return new Set();
560
+ return new Set((data || []).map((r) => r.id));
561
+ };
562
+ const evalGroup = async (group) => {
563
+ let acc = null;
564
+ for (const rule of group.rules || []) {
565
+ const set = rule.kind === 'event' ? await evalEventRule(rule) : await evalPropertyRule(rule);
566
+ const shouldNegate = rule.negate;
567
+ const s = shouldNegate ? diff(allContactIds, set) : set;
568
+ if (acc == null) {
569
+ acc = s;
570
+ }
571
+ else {
572
+ if (group.operator === 'AND')
573
+ acc = intersect(acc, s);
574
+ else if (group.operator === 'OR')
575
+ acc = union(acc, s);
576
+ else if (group.operator === 'NOT')
577
+ acc = diff(acc, s);
578
+ }
579
+ }
580
+ return acc || new Set();
581
+ };
582
+ let result = null;
583
+ for (let i = 0; i < criteria.groups.length; i++) {
584
+ const group = criteria.groups[i];
585
+ const gset = await evalGroup(group);
586
+ if (result == null) {
587
+ result = gset;
588
+ }
589
+ else {
590
+ if (group.operator === 'AND')
591
+ result = intersect(result, gset);
592
+ else if (group.operator === 'OR')
593
+ result = union(result, gset);
594
+ else if (group.operator === 'NOT')
595
+ result = diff(result, gset);
596
+ }
597
+ }
598
+ const finalSet = result || new Set();
599
+ this.dlog('done', { total: finalSet.size });
600
+ return finalSet;
601
+ }
602
+ async matchesContactByAudienceCriteriaV2(organizationId, projectId, criteriaRaw, contactId) {
603
+ let criteria = CriteriaParser_1.CriteriaParser.parse(criteriaRaw);
604
+ if ((!Array.isArray(criteria.groups) || criteria.groups.length === 0) &&
605
+ (Array.isArray(criteria.filters) || Array.isArray(criteria.conditions))) {
606
+ criteria = coerceCriteriaToGroups(criteria);
607
+ }
608
+ if (!criteria || !Array.isArray(criteria.groups) || criteria.groups.length === 0) {
609
+ return false;
610
+ }
611
+ const typeId = String(criteria?.type || '');
612
+ const isPastBehavior = typeId === 'past-behavior';
613
+ const rawAsOf = (criteria?.asOf ?? criteria?.as_of ?? criteria?.frozenAt ?? criteria?.frozen_at);
614
+ const asOfDate = isPastBehavior && typeof rawAsOf === 'string' && rawAsOf.trim() ? new Date(rawAsOf) : null;
615
+ const asOf = isPastBehavior && asOfDate && !Number.isNaN(asOfDate.getTime())
616
+ ? asOfDate
617
+ : null;
618
+ const asOfIso = asOf ? asOf.toISOString() : null;
619
+ const { data: contactRow } = await this.supabase
620
+ .from('contacts')
621
+ .select('id, reachy_id, email')
622
+ .eq('organization_id', organizationId)
623
+ .eq('project_id', projectId)
624
+ .eq('id', contactId)
625
+ .maybeSingle();
626
+ const reachyId = contactRow?.reachy_id ? String(contactRow.reachy_id).trim() : '';
627
+ const evalEventRuleForContact = async (rule) => {
628
+ const cfg = criteria?.config || {};
629
+ const effectiveEventName = cfg.eventType && String(cfg.eventType).trim() !== ''
630
+ ? String(cfg.eventType)
631
+ : String(rule.eventName);
632
+ let query = this.supabase
633
+ .from('contact_events')
634
+ .select('contact_id, reachy_id, event_data, event_timestamp, event_name')
635
+ .eq('organization_id', organizationId)
636
+ .eq('project_id', projectId)
637
+ .eq('event_name', effectiveEventName);
638
+ if (isPastBehavior && asOfIso) {
639
+ query = query.lte('event_timestamp', asOfIso);
640
+ }
641
+ const ors = [];
642
+ if (contactId)
643
+ ors.push(`contact_id.eq.${contactId}`);
644
+ if (reachyId)
645
+ ors.push(`reachy_id.eq.${reachyId}`);
646
+ if (ors.length > 0)
647
+ query = query.or(ors.join(','));
648
+ if (typeId === 'live-page-visit') {
649
+ const v = cfg.pageUrl;
650
+ const d = cfg.domain;
651
+ const ors2 = [];
652
+ if (v && String(v).trim() !== '') {
653
+ const like = `%${String(v)}%`;
654
+ ors2.push(`path.ilike.${like}`);
655
+ ors2.push(`current_url.ilike.${like}`);
656
+ }
657
+ if (d && String(d).trim() !== '') {
658
+ const dlike = `%${String(d)}%`;
659
+ ors2.push(`domain.ilike.${dlike}`);
660
+ ors2.push(`event_data->>domain.ilike.${dlike}`);
661
+ }
662
+ if (ors2.length > 0)
663
+ query = query.or(ors2.join(','));
664
+ }
665
+ if (typeId === 'live-referrer') {
666
+ query = query.eq('session_is_new', true);
667
+ const ors2 = [];
668
+ const v = cfg.referrerUrl;
669
+ if (v && String(v).trim() !== '')
670
+ ors2.push(`referrer.ilike.%${String(v)}%`);
671
+ if (cfg.utm_source && String(cfg.utm_source).trim() !== '')
672
+ ors2.push(`event_data->>utm_source.ilike.%${String(cfg.utm_source)}%`);
673
+ if (cfg.utm_medium && String(cfg.utm_medium).trim() !== '')
674
+ ors2.push(`event_data->>utm_medium.ilike.%${String(cfg.utm_medium)}%`);
675
+ if (cfg.utm_campaign && String(cfg.utm_campaign).trim() !== '')
676
+ ors2.push(`event_data->>utm_campaign.ilike.%${String(cfg.utm_campaign)}%`);
677
+ if (cfg.utm_term && String(cfg.utm_term).trim() !== '')
678
+ ors2.push(`event_data->>utm_term.ilike.%${String(cfg.utm_term)}%`);
679
+ if (cfg.utm_content && String(cfg.utm_content).trim() !== '')
680
+ ors2.push(`event_data->>utm_content.ilike.%${String(cfg.utm_content)}%`);
681
+ if (ors2.length > 0)
682
+ query = query.or(ors2.join(','));
683
+ }
684
+ if (!rule.time && cfg && cfg.timeFrame) {
685
+ const tf = String(cfg.timeFrame).trim();
686
+ const now = asOf ?? new Date();
687
+ let unit = 'days';
688
+ let value = 7;
689
+ if (/^\d+$/.test(tf)) {
690
+ value = Number(tf);
691
+ unit = 'days';
692
+ }
693
+ else if (/^\d+\s*m$/.test(tf)) {
694
+ value = Number(tf.replace(/m$/, ''));
695
+ unit = 'minutes';
696
+ }
697
+ else if (/^\d+\s*h$/.test(tf)) {
698
+ value = Number(tf.replace(/h$/, ''));
699
+ unit = 'hours';
700
+ }
701
+ else if (/^\d+\s*d$/.test(tf)) {
702
+ value = Number(tf.replace(/d$/, ''));
703
+ unit = 'days';
704
+ }
705
+ else if (/^\d+\s*w$/.test(tf)) {
706
+ value = Number(tf.replace(/w$/, ''));
707
+ unit = 'weeks';
708
+ }
709
+ else if (/^\d+\s*mo$/.test(tf)) {
710
+ value = Number(tf.replace(/mo$/, ''));
711
+ unit = 'months';
712
+ }
713
+ const units = {
714
+ minutes: 60 * 1000,
715
+ hours: 60 * 60 * 1000,
716
+ days: 24 * 60 * 60 * 1000,
717
+ weeks: 7 * 24 * 60 * 60 * 1000,
718
+ months: 30 * 24 * 60 * 60 * 1000
719
+ };
720
+ const from = new Date(now.getTime() - value * units[unit]);
721
+ query = query.gte('event_timestamp', from.toISOString());
722
+ }
723
+ if (rule.time) {
724
+ if (rule.time.unit && rule.time.value) {
725
+ const unit = String(rule.time.unit);
726
+ const value = Number(rule.time.value);
727
+ const now = asOf ?? new Date();
728
+ const units = {
729
+ minutes: 60 * 1000,
730
+ hours: 60 * 60 * 1000,
731
+ days: 24 * 60 * 60 * 1000,
732
+ weeks: 7 * 24 * 60 * 60 * 1000,
733
+ months: 30 * 24 * 60 * 60 * 1000
734
+ };
735
+ const ms = units[unit] ?? units['days'];
736
+ const from = new Date(now.getTime() - value * ms);
737
+ query = query.gte('event_timestamp', from.toISOString());
738
+ }
739
+ else {
740
+ if (rule.time.from) {
741
+ const rawFrom = rule.time.from;
742
+ const fromStr = typeof rawFrom === 'string' ? rawFrom : String(rawFrom);
743
+ const isDateOnly = typeof rawFrom === 'string' && !fromStr.includes('T');
744
+ const startIso = isDateOnly ? normalizeDateBoundary(fromStr, 'start') : undefined;
745
+ const finalFrom = startIso || fromStr;
746
+ query = query.gte('event_timestamp', finalFrom);
747
+ }
748
+ if (rule.time.to) {
749
+ const rawTo = rule.time.to;
750
+ const toStr = typeof rawTo === 'string' ? rawTo : String(rawTo);
751
+ const isDateOnly = typeof rawTo === 'string' && !toStr.includes('T');
752
+ const endIsoExclusive = isDateOnly ? normalizeDateBoundary(toStr, 'end') : undefined;
753
+ const finalTo = endIsoExclusive || toStr;
754
+ query = isDateOnly ? query.lt('event_timestamp', finalTo) : query.lte('event_timestamp', finalTo);
755
+ }
756
+ }
757
+ }
758
+ try {
759
+ const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : []);
760
+ const resolveDbFieldForAttrKey = (keyRaw) => {
761
+ const key = String(keyRaw || '').trim();
762
+ if (!key)
763
+ return null;
764
+ const columnKeys = new Set([
765
+ 'current_url',
766
+ 'domain',
767
+ 'path',
768
+ 'referrer',
769
+ 'page_title',
770
+ 'utm_source',
771
+ 'utm_medium',
772
+ 'utm_campaign',
773
+ 'utm_term',
774
+ 'utm_content',
775
+ 'session_id',
776
+ 'session_is_new'
777
+ ]);
778
+ if (columnKeys.has(key))
779
+ return { kind: 'column', field: key };
780
+ const buildNested = (root, dottedPath) => jsonNestedTextAccessor(root, dottedPath);
781
+ if (key.startsWith('event_data.'))
782
+ return { kind: 'json', field: buildNested('event_data', key.replace(/^event_data\./, '')) };
783
+ if (key.startsWith('custom_data.'))
784
+ return { kind: 'json', field: buildNested('event_data->custom_data', key.replace(/^custom_data\./, '')) };
785
+ if (key.startsWith('url_data.'))
786
+ return { kind: 'json', field: buildNested('event_data->url_data', key.replace(/^url_data\./, '')) };
787
+ if (key.startsWith('utm_data.'))
788
+ return { kind: 'json', field: buildNested('event_data->utm_data', key.replace(/^utm_data\./, '')) };
789
+ if (key.startsWith('session_data.'))
790
+ return { kind: 'json', field: buildNested('event_data->session_data', key.replace(/^session_data\./, '')) };
791
+ if (key.includes('.'))
792
+ return { kind: 'json', field: buildNested('event_data->custom_data', key) };
793
+ return { kind: 'json', field: jsonTextAccessor('event_data', key) };
794
+ };
795
+ for (const attr of attributes) {
796
+ const key = String(attr.key || '').trim();
797
+ const op = String(attr.op || 'contains');
798
+ const value = attr.value;
799
+ if (!key || value == null)
800
+ continue;
801
+ const resolved = resolveDbFieldForAttrKey(key);
802
+ if (!resolved)
803
+ continue;
804
+ const dbField = resolved.field;
805
+ switch (op) {
806
+ case 'equals':
807
+ query = query.filter(dbField, 'eq', value);
808
+ break;
809
+ case 'not_equals':
810
+ query = query.filter(dbField, 'neq', value);
811
+ break;
812
+ case 'starts_with':
813
+ query = query.filter(dbField, 'ilike', `${value}%`);
814
+ break;
815
+ case 'ends_with':
816
+ query = query.filter(dbField, 'ilike', `%${value}`);
817
+ break;
818
+ case 'contains':
819
+ default:
820
+ query = query.filter(dbField, 'ilike', `%${value}%`);
821
+ break;
822
+ }
823
+ }
824
+ }
825
+ catch {
826
+ }
827
+ const { data, error } = await query;
828
+ if (error)
829
+ return false;
830
+ const rows = data || [];
831
+ if (rule.frequency && rule.frequency.value != null) {
832
+ const { op, value, type = 'count', field = 'value' } = rule.frequency;
833
+ let aggregatedValue = 0;
834
+ if (type === 'count') {
835
+ aggregatedValue = rows.length;
836
+ }
837
+ else {
838
+ for (const row of rows) {
839
+ const eventData = row.event_data || {};
840
+ let numVal = 0;
841
+ if (eventData[field] !== undefined)
842
+ numVal = Number(eventData[field]);
843
+ else if (eventData['value'] !== undefined)
844
+ numVal = Number(eventData['value']);
845
+ else if (eventData['amount'] !== undefined)
846
+ numVal = Number(eventData['amount']);
847
+ if (!Number.isNaN(numVal))
848
+ aggregatedValue += numVal;
849
+ }
850
+ if (type === 'avg')
851
+ aggregatedValue = rows.length > 0 ? aggregatedValue / rows.length : 0;
852
+ }
853
+ return ((op === '>=' && aggregatedValue >= value) ||
854
+ (op === '>' && aggregatedValue > value) ||
855
+ (op === '=' && aggregatedValue === value) ||
856
+ (op === '<=' && aggregatedValue <= value) ||
857
+ (op === '<' && aggregatedValue < value));
858
+ }
859
+ if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
860
+ const threshold = Number(cfg.pageCount);
861
+ return rows.length >= threshold;
862
+ }
863
+ return rows.length > 0;
864
+ };
865
+ const evalPropertyRuleForContact = async (rule) => {
866
+ let query = this.supabase
867
+ .from('contacts')
868
+ .select('id')
869
+ .eq('organization_id', organizationId)
870
+ .eq('project_id', projectId)
871
+ .eq('id', contactId);
872
+ if (isPastBehavior && asOfIso) {
873
+ query = query.lte('created_at', asOfIso);
874
+ }
875
+ const field = rule.field;
876
+ const op = rule.op;
877
+ const value = rule.value;
878
+ const value2 = rule.value2;
879
+ const isDateField = field === 'created_at' || field === 'updated_at';
880
+ const timeFrom = rule?.time?.from;
881
+ const timeTo = rule?.time?.to;
882
+ const hasTimeRange = isDateField && (typeof timeFrom === 'string' || typeof timeTo === 'string');
883
+ const computeRelativeRange = (direction = 'past') => {
884
+ const unitSource = rule.timeUnit ?? rule.unit ?? 'days';
885
+ const rawUnit = String(unitSource).toLowerCase();
886
+ const numericValue = Number(rule.timeValue ?? rule.value ?? NaN);
887
+ if (!Number.isFinite(numericValue) || numericValue < 0)
888
+ return null;
889
+ const units = {
890
+ minutes: 60 * 1000,
891
+ hours: 60 * 60 * 1000,
892
+ days: 24 * 60 * 60 * 1000,
893
+ weeks: 7 * 24 * 60 * 60 * 1000,
894
+ months: 30 * 24 * 60 * 60 * 1000,
895
+ years: 365 * 24 * 60 * 60 * 1000
896
+ };
897
+ const now = asOf ?? new Date();
898
+ const baseUnitMs = units[rawUnit] ?? units['days'] ?? 24 * 60 * 60 * 1000;
899
+ const delta = baseUnitMs * numericValue;
900
+ const start = direction === 'past' ? new Date(now.getTime() - delta) : now;
901
+ const end = direction === 'past' ? now : new Date(now.getTime() + delta);
902
+ return { start: start.toISOString(), end: end.toISOString() };
903
+ };
904
+ const apply = (dbField, isJsonb = false) => {
905
+ switch (op) {
906
+ case 'equals':
907
+ if (isDateField) {
908
+ if (hasTimeRange) {
909
+ if (typeof timeFrom === 'string' && timeFrom.trim())
910
+ query = query.gte(dbField, timeFrom.trim());
911
+ if (typeof timeTo === 'string' && timeTo.trim())
912
+ query = query.lte(dbField, timeTo.trim());
913
+ }
914
+ else {
915
+ const startIso = normalizeDateBoundary(value, 'start');
916
+ const endIsoExclusive = normalizeDateBoundary(value, 'end');
917
+ if (startIso)
918
+ query = query.gte(dbField, startIso);
919
+ if (endIsoExclusive)
920
+ query = query.lt(dbField, endIsoExclusive);
921
+ }
922
+ }
923
+ else {
924
+ query = query.eq(dbField, value);
925
+ }
926
+ break;
927
+ case 'not_equals':
928
+ if (isJsonb && value === '')
929
+ query = query.not(dbField, 'is', null).neq(dbField, '');
930
+ else
931
+ query = query.neq(dbField, value);
932
+ break;
933
+ case 'contains':
934
+ query = query.ilike(dbField, `%${value}%`);
935
+ break;
936
+ case 'not_contains':
937
+ query = query.not(dbField, 'ilike', `%${value}%`);
938
+ break;
939
+ case 'starts_with':
940
+ query = query.ilike(dbField, `${value}%`);
941
+ break;
942
+ case 'ends_with':
943
+ query = query.ilike(dbField, `%${value}`);
944
+ break;
945
+ case '>':
946
+ query = query.gt(dbField, value);
947
+ break;
948
+ case '>=':
949
+ query = query.gte(dbField, value);
950
+ break;
951
+ case '<':
952
+ query = query.lt(dbField, value);
953
+ break;
954
+ case '<=':
955
+ query = query.lte(dbField, value);
956
+ break;
957
+ case 'between':
958
+ if (isDateField) {
959
+ if (hasTimeRange) {
960
+ if (typeof timeFrom === 'string' && timeFrom.trim())
961
+ query = query.gte(dbField, timeFrom.trim());
962
+ if (typeof timeTo === 'string' && timeTo.trim())
963
+ query = query.lte(dbField, timeTo.trim());
964
+ }
965
+ else {
966
+ const startIso = normalizeDateBoundary(value, 'start');
967
+ const endIsoExclusive = normalizeDateBoundary(value2 ?? value, 'end');
968
+ if (startIso)
969
+ query = query.gte(dbField, startIso);
970
+ if (endIsoExclusive)
971
+ query = query.lt(dbField, endIsoExclusive);
972
+ }
973
+ }
974
+ else {
975
+ if (value != null)
976
+ query = query.gte(dbField, value);
977
+ if (value2 != null)
978
+ query = query.lte(dbField, value2);
979
+ }
980
+ break;
981
+ case 'after':
982
+ if (isDateField) {
983
+ if (hasTimeRange && typeof timeFrom === 'string' && timeFrom.trim())
984
+ query = query.gte(dbField, timeFrom.trim());
985
+ else {
986
+ const startIso = normalizeDateBoundary(value, 'start');
987
+ if (startIso)
988
+ query = query.gte(dbField, startIso);
989
+ }
990
+ }
991
+ else
992
+ query = query.gt(dbField, value);
993
+ break;
994
+ case 'before':
995
+ if (isDateField) {
996
+ if (hasTimeRange && typeof timeTo === 'string' && timeTo.trim())
997
+ query = query.lte(dbField, timeTo.trim());
998
+ else {
999
+ const endIsoExclusive = normalizeDateBoundary(value, 'end');
1000
+ if (endIsoExclusive)
1001
+ query = query.lt(dbField, endIsoExclusive);
1002
+ }
1003
+ }
1004
+ else
1005
+ query = query.lt(dbField, value);
1006
+ break;
1007
+ case 'is_empty':
1008
+ if (isJsonb)
1009
+ query = query.or(`${dbField}.is.null,${dbField}.eq.`);
1010
+ else
1011
+ query = query.is(dbField, null);
1012
+ break;
1013
+ case 'is_not_empty':
1014
+ if (isJsonb)
1015
+ query = query.not(dbField, 'is', null).neq(dbField, '');
1016
+ else
1017
+ query = query.not(dbField, 'is', null);
1018
+ break;
1019
+ case 'is_true':
1020
+ query = query.eq(dbField, true);
1021
+ break;
1022
+ case 'is_false':
1023
+ query = query.eq(dbField, false);
1024
+ break;
1025
+ case 'in_the_last': {
1026
+ const range = computeRelativeRange('past');
1027
+ if (range)
1028
+ query = query.gte(dbField, range.start).lte(dbField, range.end);
1029
+ break;
1030
+ }
1031
+ case 'in_the_next': {
1032
+ const range = computeRelativeRange('future');
1033
+ if (range)
1034
+ query = query.gte(dbField, range.start).lte(dbField, range.end);
1035
+ break;
1036
+ }
1037
+ }
1038
+ };
1039
+ const mappedField = mapAudienceFieldToContactField(field);
1040
+ const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name'];
1041
+ if (known.includes(field) || known.includes(mappedField)) {
1042
+ apply(mappedField, false);
1043
+ }
1044
+ else {
1045
+ const dbField = `properties->>${field}`;
1046
+ apply(dbField, true);
1047
+ }
1048
+ const { data, error } = await query;
1049
+ if (error)
1050
+ return false;
1051
+ return (data || []).length > 0;
1052
+ };
1053
+ const evalRuleForContact = async (rule) => {
1054
+ const base = rule.kind === 'event' ? await evalEventRuleForContact(rule) : await evalPropertyRuleForContact(rule);
1055
+ const res = rule.negate ? !base : base;
1056
+ return res;
1057
+ };
1058
+ const evalGroupForContact = async (group) => {
1059
+ let acc = null;
1060
+ for (const rule of group.rules || []) {
1061
+ const r = await evalRuleForContact(rule);
1062
+ if (acc == null)
1063
+ acc = r;
1064
+ else {
1065
+ if (group.operator === 'AND')
1066
+ acc = acc && r;
1067
+ else if (group.operator === 'OR')
1068
+ acc = acc || r;
1069
+ else if (group.operator === 'NOT')
1070
+ acc = acc && !r;
1071
+ }
1072
+ }
1073
+ return !!acc;
1074
+ };
1075
+ let result = null;
1076
+ for (const group of criteria.groups) {
1077
+ const g = await evalGroupForContact(group);
1078
+ if (result == null)
1079
+ result = g;
1080
+ else {
1081
+ if (group.operator === 'AND')
1082
+ result = result && g;
1083
+ else if (group.operator === 'OR')
1084
+ result = result || g;
1085
+ else if (group.operator === 'NOT')
1086
+ result = result && !g;
1087
+ }
1088
+ }
1089
+ return !!result;
1090
+ }
1091
+ dlog(...args) {
1092
+ if (!this.debug)
1093
+ return;
1094
+ this.logger.log('[AUDIENCE_MODULE_ENGINE]', ...args);
1095
+ }
1096
+ }
1097
+ exports.V2AudienceEngine = V2AudienceEngine;
1098
+ function jsonTextAccessor(base, key) {
1099
+ const safeKey = String(key || '').replace(/'/g, "''");
1100
+ return `${base}->>'${safeKey}'`;
1101
+ }
1102
+ function jsonNestedTextAccessor(base, path) {
1103
+ const parts = String(path || '')
1104
+ .split('.')
1105
+ .map((p) => p.trim())
1106
+ .filter(Boolean);
1107
+ if (parts.length === 0)
1108
+ return jsonTextAccessor(base, 'value');
1109
+ if (parts.length === 1)
1110
+ return jsonTextAccessor(base, parts[0]);
1111
+ const chain = parts
1112
+ .slice(0, -1)
1113
+ .map((p) => `->'${String(p).replace(/'/g, "''")}'`)
1114
+ .join('');
1115
+ const last = String(parts[parts.length - 1]).replace(/'/g, "''");
1116
+ return `${base}${chain}->>'${last}'`;
1117
+ }
1118
+ function mapAudienceFieldToContactField(audienceField) {
1119
+ const fieldMapping = {
1120
+ is_subscribed: 'is_subscribed',
1121
+ email: 'email',
1122
+ name: 'first_name',
1123
+ first_name: 'first_name',
1124
+ last_name: 'last_name',
1125
+ phone: 'phone',
1126
+ created_at: 'created_at',
1127
+ updated_at: 'updated_at',
1128
+ city: 'city',
1129
+ country: 'country',
1130
+ tags: 'tags',
1131
+ status: 'status'
1132
+ };
1133
+ return fieldMapping[audienceField] || audienceField;
1134
+ }
1135
+ function normalizeDateBoundary(value, boundary) {
1136
+ if (!value || typeof value !== 'string')
1137
+ return undefined;
1138
+ const hasTimeComponent = value.includes('T');
1139
+ const toUtcStartOfDay = (y, mZeroBased, d) => {
1140
+ return new Date(Date.UTC(y, mZeroBased, d, 0, 0, 0, 0));
1141
+ };
1142
+ let baseDate = null;
1143
+ if (hasTimeComponent) {
1144
+ const dt = new Date(value);
1145
+ baseDate = Number.isNaN(dt.getTime()) ? null : dt;
1146
+ }
1147
+ else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1148
+ const parts = value.split('-');
1149
+ if (parts.length === 3) {
1150
+ const y = parseInt(parts[0], 10);
1151
+ const mm = parseInt(parts[1], 10);
1152
+ const d = parseInt(parts[2], 10);
1153
+ if (Number.isFinite(y) && Number.isFinite(mm) && Number.isFinite(d)) {
1154
+ baseDate = toUtcStartOfDay(y, mm - 1, d);
1155
+ }
1156
+ }
1157
+ }
1158
+ else if (/^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
1159
+ const parts = value.split('/');
1160
+ if (parts.length === 3) {
1161
+ const d = parseInt(parts[0], 10);
1162
+ const mm = parseInt(parts[1], 10);
1163
+ const y = parseInt(parts[2], 10);
1164
+ if (Number.isFinite(y) && Number.isFinite(mm) && Number.isFinite(d)) {
1165
+ baseDate = toUtcStartOfDay(y, mm - 1, d);
1166
+ }
1167
+ }
1168
+ }
1169
+ else {
1170
+ const dt = new Date(`${value}T00:00:00Z`);
1171
+ baseDate = Number.isNaN(dt.getTime()) ? null : dt;
1172
+ }
1173
+ if (!baseDate)
1174
+ return undefined;
1175
+ const utcYear = baseDate.getUTCFullYear();
1176
+ const utcMonth = baseDate.getUTCMonth();
1177
+ const utcDay = baseDate.getUTCDate();
1178
+ if (boundary === 'start') {
1179
+ const startOfDay = new Date(Date.UTC(utcYear, utcMonth, utcDay, 0, 0, 0, 0));
1180
+ return startOfDay.toISOString();
1181
+ }
1182
+ const nextDay = new Date(Date.UTC(utcYear, utcMonth, utcDay + 1, 0, 0, 0, 0));
1183
+ return nextDay.toISOString();
1184
+ }
1185
+ function coerceCriteriaToGroups(criteria) {
1186
+ const groups = [];
1187
+ const pushGroup = (operator, rules) => {
1188
+ if (!rules || rules.length === 0)
1189
+ return;
1190
+ const op = operator === 'OR' || operator === 'NOT' ? operator : 'AND';
1191
+ groups.push({ operator: op, rules });
1192
+ };
1193
+ if (Array.isArray(criteria.filters)) {
1194
+ for (const fg of criteria.filters) {
1195
+ const rules = [];
1196
+ for (const c of fg?.conditions || []) {
1197
+ const fieldRaw = String(c?.field || '');
1198
+ const operator = c?.operator;
1199
+ const value = c?.value;
1200
+ const value2 = c?.value2;
1201
+ if (fieldRaw === 'event') {
1202
+ rules.push({
1203
+ kind: 'event',
1204
+ eventName: value,
1205
+ negate: operator === 'not_equals',
1206
+ ...(c?.dateFrom || c?.dateTo ? { time: { from: c?.dateFrom, to: c?.dateTo } } : {}),
1207
+ ...(c?.timeValue != null ? { time: { unit: c?.timeUnit || 'days', value: Number(c?.timeValue) } } : {})
1208
+ });
1209
+ continue;
1210
+ }
1211
+ const field = fieldRaw === 'custom_field' && c?.customFieldKey ? String(c.customFieldKey) : fieldRaw;
1212
+ const rule = {
1213
+ kind: 'property',
1214
+ field,
1215
+ op: operator
1216
+ };
1217
+ if (operator === 'in_the_last' || operator === 'in_the_next') {
1218
+ rule.timeValue = c?.timeValue ?? value;
1219
+ rule.timeUnit = c?.timeUnit || 'days';
1220
+ }
1221
+ else if (operator === 'between') {
1222
+ rule.value = value;
1223
+ rule.value2 = value2;
1224
+ }
1225
+ else if (operator === 'is_empty' || operator === 'is_not_empty') {
1226
+ }
1227
+ else {
1228
+ rule.value = value;
1229
+ if (value2 != null)
1230
+ rule.value2 = value2;
1231
+ }
1232
+ rules.push(rule);
1233
+ }
1234
+ pushGroup(fg?.operator, rules);
1235
+ }
1236
+ }
1237
+ if (Array.isArray(criteria.conditions)) {
1238
+ for (const cg of criteria.conditions) {
1239
+ const rules = [];
1240
+ for (const c of cg?.conditions || []) {
1241
+ const fieldRaw = String(c?.field || '');
1242
+ const operator = c?.operator;
1243
+ const value = c?.value;
1244
+ const value2 = c?.value2;
1245
+ const field = fieldRaw === 'custom_field' && c?.customFieldKey ? String(c.customFieldKey) : fieldRaw;
1246
+ const rule = { kind: 'property', field, op: operator };
1247
+ if (operator === 'between') {
1248
+ rule.value = value;
1249
+ rule.value2 = value2;
1250
+ }
1251
+ else if (operator === 'in_the_last' || operator === 'in_the_next') {
1252
+ rule.timeValue = c?.timeValue ?? value;
1253
+ rule.timeUnit = c?.timeUnit || 'days';
1254
+ }
1255
+ else if (operator === 'is_empty' || operator === 'is_not_empty') {
1256
+ }
1257
+ else {
1258
+ rule.value = value;
1259
+ if (value2 != null)
1260
+ rule.value2 = value2;
1261
+ }
1262
+ rules.push(rule);
1263
+ }
1264
+ pushGroup(cg?.operator, rules);
1265
+ }
1266
+ }
1267
+ return { ...criteria, groups };
1268
+ }
1269
+ //# sourceMappingURL=V2AudienceEngine.js.map