@reachy/audience-module 1.0.4 → 1.0.5

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