@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,1242 @@
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
+ const isDateField = field === 'created_at' || field === 'updated_at'
392
+ const timeFrom = rule?.time?.from
393
+ const timeTo = rule?.time?.to
394
+ const hasTimeRange = isDateField && (typeof timeFrom === 'string' || typeof timeTo === 'string')
395
+
396
+ const computeRelativeRange = (direction: 'past' | 'future' = 'past'): { start: string; end: string } | null => {
397
+ const unitSource = rule.timeUnit ?? rule.unit ?? 'days'
398
+ const rawUnit = String(unitSource).toLowerCase()
399
+ const numericValue = Number(rule.timeValue ?? rule.value ?? NaN)
400
+ if (!Number.isFinite(numericValue) || numericValue < 0) return null
401
+
402
+ const units: Record<string, number> = {
403
+ minutes: 60 * 1000,
404
+ hours: 60 * 60 * 1000,
405
+ days: 24 * 60 * 60 * 1000,
406
+ weeks: 7 * 24 * 60 * 60 * 1000,
407
+ months: 30 * 24 * 60 * 60 * 1000,
408
+ years: 365 * 24 * 60 * 60 * 1000
409
+ }
410
+
411
+ const now = asOf ?? new Date()
412
+ const baseUnitMs: number = units[rawUnit] ?? units['days'] ?? 24 * 60 * 60 * 1000
413
+ const delta = baseUnitMs * numericValue
414
+ const start = direction === 'past' ? new Date(now.getTime() - delta) : now
415
+ const end = direction === 'past' ? now : new Date(now.getTime() + delta)
416
+ return { start: start.toISOString(), end: end.toISOString() }
417
+ }
418
+
419
+ const apply = (dbField: string, isJsonb: boolean = false) => {
420
+ switch (op) {
421
+ case 'equals':
422
+ if (isDateField) {
423
+ if (hasTimeRange) {
424
+ if (typeof timeFrom === 'string' && timeFrom.trim()) query = query.gte(dbField, timeFrom.trim())
425
+ if (typeof timeTo === 'string' && timeTo.trim()) query = query.lte(dbField, timeTo.trim())
426
+ } else {
427
+ const startIso = normalizeDateBoundary(value, 'start')
428
+ const endIsoExclusive = normalizeDateBoundary(value, 'end')
429
+ if (startIso) query = query.gte(dbField, startIso)
430
+ if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
431
+ }
432
+ } else {
433
+ query = query.eq(dbField, value)
434
+ }
435
+ break
436
+ case 'not_equals':
437
+ if (isJsonb && value === '') {
438
+ query = query.not(dbField, 'is', null).neq(dbField, '')
439
+ } else {
440
+ query = query.neq(dbField, value)
441
+ }
442
+ break
443
+ case 'contains':
444
+ query = query.ilike(dbField, `%${value}%`)
445
+ break
446
+ case 'not_contains':
447
+ query = query.not(dbField, 'ilike', `%${value}%`)
448
+ break
449
+ case 'starts_with':
450
+ query = query.ilike(dbField, `${value}%`)
451
+ break
452
+ case 'ends_with':
453
+ query = query.ilike(dbField, `%${value}`)
454
+ break
455
+ case '>':
456
+ query = query.gt(dbField, value)
457
+ break
458
+ case '>=':
459
+ query = query.gte(dbField, value)
460
+ break
461
+ case '<':
462
+ query = query.lt(dbField, value)
463
+ break
464
+ case '<=':
465
+ query = query.lte(dbField, value)
466
+ break
467
+ case 'between':
468
+ if (isDateField) {
469
+ if (hasTimeRange) {
470
+ if (typeof timeFrom === 'string' && timeFrom.trim()) query = query.gte(dbField, timeFrom.trim())
471
+ if (typeof timeTo === 'string' && timeTo.trim()) query = query.lte(dbField, timeTo.trim())
472
+ } else {
473
+ const startIso = normalizeDateBoundary(value, 'start')
474
+ const endIsoExclusive = normalizeDateBoundary(value2 ?? value, 'end')
475
+ if (startIso) query = query.gte(dbField, startIso)
476
+ if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
477
+ }
478
+ } else {
479
+ if (value != null) query = query.gte(dbField, value)
480
+ if (value2 != null) query = query.lte(dbField, value2)
481
+ }
482
+ break
483
+ case 'after':
484
+ if (isDateField) {
485
+ if (hasTimeRange && typeof timeFrom === 'string' && timeFrom.trim()) {
486
+ query = query.gte(dbField, timeFrom.trim())
487
+ } else {
488
+ const startIso = normalizeDateBoundary(value, 'start')
489
+ if (startIso) query = query.gte(dbField, startIso)
490
+ }
491
+ } else {
492
+ query = query.gt(dbField, value)
493
+ }
494
+ break
495
+ case 'before':
496
+ if (isDateField) {
497
+ if (hasTimeRange && typeof timeTo === 'string' && timeTo.trim()) {
498
+ query = query.lte(dbField, timeTo.trim())
499
+ } else {
500
+ const endIsoExclusive = normalizeDateBoundary(value, 'end')
501
+ if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
502
+ }
503
+ } else {
504
+ query = query.lt(dbField, value)
505
+ }
506
+ break
507
+ case 'is_empty':
508
+ if (isJsonb) query = query.or(`${dbField}.is.null,${dbField}.eq.`)
509
+ else query = query.is(dbField, null)
510
+ break
511
+ case 'is_not_empty':
512
+ if (isJsonb) {
513
+ const parts = dbField.split('->>')
514
+ const propertyObj = (parts[0] || '').trim()
515
+ const keyName = field
516
+ if (propertyObj) {
517
+ query = query
518
+ .filter(`${propertyObj}`, 'cs', `{"${keyName}":`)
519
+ .not(dbField, 'is', null)
520
+ .neq(dbField, '')
521
+ } else {
522
+ query = query.not(dbField, 'is', null).neq(dbField, '')
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) query = query.gte(dbField, range.start).lte(dbField, range.end)
537
+ break
538
+ }
539
+ case 'in_the_next': {
540
+ const range = computeRelativeRange('future')
541
+ if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
542
+ break
543
+ }
544
+ }
545
+ }
546
+
547
+ const mappedField = mapAudienceFieldToContactField(field)
548
+ const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
549
+
550
+ if (known.includes(field) || known.includes(mappedField)) {
551
+ apply(mappedField, false)
552
+ } else {
553
+ const dbField = `properties->>${field}`
554
+ apply(dbField, true)
555
+ }
556
+
557
+ const { data, error } = await query
558
+ if (error) return new Set<string>()
559
+ return new Set<string>((data || []).map((r: any) => r.id as string))
560
+ }
561
+
562
+ const evalGroup = async (group: any): Promise<Set<string>> => {
563
+ let acc: Set<string> | null = 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
+
569
+ if (acc == null) {
570
+ acc = s
571
+ } else {
572
+ if (group.operator === 'AND') acc = intersect(acc, s)
573
+ else if (group.operator === 'OR') acc = union(acc, s)
574
+ else if (group.operator === 'NOT') acc = diff(acc, s)
575
+ }
576
+ }
577
+ return acc || new Set<string>()
578
+ }
579
+
580
+ let result: Set<string> | null = null
581
+ for (let i = 0; i < (criteria as any).groups.length; i++) {
582
+ const group = (criteria as any).groups[i]
583
+ const gset = await evalGroup(group)
584
+ if (result == null) {
585
+ result = gset
586
+ } else {
587
+ if (group.operator === 'AND') result = intersect(result, gset)
588
+ else if (group.operator === 'OR') result = union(result, gset)
589
+ else if (group.operator === 'NOT') result = diff(result, gset)
590
+ }
591
+ }
592
+
593
+ const finalSet = result || new Set<string>()
594
+ this.dlog('done', { total: finalSet.size })
595
+ return finalSet
596
+ }
597
+
598
+ /**
599
+ * Variante otimizada para avaliar apenas UM contato (useful para Journeys/Live).
600
+ * Retorna true se o contact_id atende ao critério.
601
+ */
602
+ async matchesContactByAudienceCriteriaV2(
603
+ organizationId: string,
604
+ projectId: string,
605
+ criteriaRaw: any,
606
+ contactId: string
607
+ ): Promise<boolean> {
608
+ let criteria: AudienceCriteria = CriteriaParser.parse(criteriaRaw as any) as any
609
+
610
+ if (
611
+ (!Array.isArray((criteria as any).groups) || (criteria as any).groups.length === 0) &&
612
+ (Array.isArray((criteria as any).filters) || Array.isArray((criteria as any).conditions))
613
+ ) {
614
+ criteria = coerceCriteriaToGroups(criteria)
615
+ }
616
+
617
+ if (!criteria || !Array.isArray((criteria as any).groups) || (criteria as any).groups.length === 0) {
618
+ return false
619
+ }
620
+
621
+ const typeId = String((criteria as any)?.type || '')
622
+ const isPastBehavior = typeId === 'past-behavior'
623
+ const rawAsOf = ((criteria as any)?.asOf ?? (criteria as any)?.as_of ?? (criteria as any)?.frozenAt ?? (criteria as any)?.frozen_at) as any
624
+ const asOfDate = isPastBehavior && typeof rawAsOf === 'string' && rawAsOf.trim() ? new Date(rawAsOf) : null
625
+ const asOf =
626
+ isPastBehavior && asOfDate && !Number.isNaN(asOfDate.getTime())
627
+ ? asOfDate
628
+ : null
629
+ const asOfIso = asOf ? asOf.toISOString() : null
630
+
631
+ // Carregar identidade do contato (para reachy_id)
632
+ const { data: contactRow } = await this.supabase
633
+ .from('contacts')
634
+ .select('id, reachy_id, email')
635
+ .eq('organization_id', organizationId)
636
+ .eq('project_id', projectId)
637
+ .eq('id', contactId)
638
+ .maybeSingle()
639
+
640
+ const reachyId = contactRow?.reachy_id ? String(contactRow.reachy_id).trim() : ''
641
+
642
+ const evalEventRuleForContact = async (rule: any): Promise<boolean> => {
643
+ const cfg = (criteria as any)?.config || {}
644
+ const effectiveEventName =
645
+ cfg.eventType && String(cfg.eventType).trim() !== ''
646
+ ? String(cfg.eventType)
647
+ : String(rule.eventName)
648
+
649
+ let query = this.supabase
650
+ .from('contact_events')
651
+ .select('contact_id, reachy_id, event_data, event_timestamp, event_name')
652
+ .eq('organization_id', organizationId)
653
+ .eq('project_id', projectId)
654
+ .eq('event_name', effectiveEventName)
655
+
656
+ if (isPastBehavior && asOfIso) {
657
+ query = query.lte('event_timestamp', asOfIso)
658
+ }
659
+
660
+ // NARROW para 1 contato (sempre que possível)
661
+ const ors: string[] = []
662
+ if (contactId) ors.push(`contact_id.eq.${contactId}`)
663
+ if (reachyId) ors.push(`reachy_id.eq.${reachyId}`)
664
+ if (ors.length > 0) query = query.or(ors.join(','))
665
+
666
+ // live presets helpers (paridade)
667
+ if (typeId === 'live-page-visit') {
668
+ const v = cfg.pageUrl
669
+ const d = cfg.domain
670
+ const ors2: string[] = []
671
+ if (v && String(v).trim() !== '') {
672
+ const like = `%${String(v)}%`
673
+ ors2.push(`path.ilike.${like}`)
674
+ ors2.push(`current_url.ilike.${like}`)
675
+ }
676
+ if (d && String(d).trim() !== '') {
677
+ const dlike = `%${String(d)}%`
678
+ ors2.push(`domain.ilike.${dlike}`)
679
+ ors2.push(`event_data->>domain.ilike.${dlike}`)
680
+ }
681
+ if (ors2.length > 0) query = query.or(ors2.join(','))
682
+ }
683
+
684
+ if (typeId === 'live-referrer') {
685
+ query = query.eq('session_is_new', true)
686
+ const ors2: string[] = []
687
+ const v = cfg.referrerUrl
688
+ if (v && String(v).trim() !== '') ors2.push(`referrer.ilike.%${String(v)}%`)
689
+ if (cfg.utm_source && String(cfg.utm_source).trim() !== '') ors2.push(`event_data->>utm_source.ilike.%${String(cfg.utm_source)}%`)
690
+ if (cfg.utm_medium && String(cfg.utm_medium).trim() !== '') ors2.push(`event_data->>utm_medium.ilike.%${String(cfg.utm_medium)}%`)
691
+ if (cfg.utm_campaign && String(cfg.utm_campaign).trim() !== '') ors2.push(`event_data->>utm_campaign.ilike.%${String(cfg.utm_campaign)}%`)
692
+ if (cfg.utm_term && String(cfg.utm_term).trim() !== '') ors2.push(`event_data->>utm_term.ilike.%${String(cfg.utm_term)}%`)
693
+ if (cfg.utm_content && String(cfg.utm_content).trim() !== '') ors2.push(`event_data->>utm_content.ilike.%${String(cfg.utm_content)}%`)
694
+ if (ors2.length > 0) query = query.or(ors2.join(','))
695
+ }
696
+
697
+ // TimeFrame via config (fallback)
698
+ if (!rule.time && cfg && cfg.timeFrame) {
699
+ const tf = String(cfg.timeFrame).trim()
700
+ const now = asOf ?? new Date()
701
+ let unit: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' = 'days'
702
+ let value = 7
703
+ if (/^\d+$/.test(tf)) { value = Number(tf); unit = 'days' }
704
+ else if (/^\d+\s*m$/.test(tf)) { value = Number(tf.replace(/m$/, '')); unit = 'minutes' }
705
+ else if (/^\d+\s*h$/.test(tf)) { value = Number(tf.replace(/h$/, '')); unit = 'hours' }
706
+ else if (/^\d+\s*d$/.test(tf)) { value = Number(tf.replace(/d$/, '')); unit = 'days' }
707
+ else if (/^\d+\s*w$/.test(tf)) { value = Number(tf.replace(/w$/, '')); unit = 'weeks' }
708
+ else if (/^\d+\s*mo$/.test(tf)) { value = Number(tf.replace(/mo$/, '')); unit = 'months' }
709
+ const units: Record<typeof unit, number> = {
710
+ minutes: 60 * 1000,
711
+ hours: 60 * 60 * 1000,
712
+ days: 24 * 60 * 60 * 1000,
713
+ weeks: 7 * 24 * 60 * 60 * 1000,
714
+ months: 30 * 24 * 60 * 60 * 1000
715
+ }
716
+ const from = new Date(now.getTime() - value * units[unit])
717
+ query = query.gte('event_timestamp', from.toISOString())
718
+ }
719
+
720
+ // Janela temporal explícita (relativa ou absoluta)
721
+ if (rule.time) {
722
+ if (rule.time.unit && rule.time.value) {
723
+ type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'
724
+ const unit = String(rule.time.unit) as TimeUnit
725
+ const value = Number(rule.time.value)
726
+ const now = asOf ?? new Date()
727
+ const units: Record<TimeUnit, number> = {
728
+ minutes: 60 * 1000,
729
+ hours: 60 * 60 * 1000,
730
+ days: 24 * 60 * 60 * 1000,
731
+ weeks: 7 * 24 * 60 * 60 * 1000,
732
+ months: 30 * 24 * 60 * 60 * 1000
733
+ }
734
+ const ms = units[unit] ?? units['days']
735
+ const from = new Date(now.getTime() - value * ms)
736
+ query = query.gte('event_timestamp', from.toISOString())
737
+ } else {
738
+ if (rule.time.from) {
739
+ const rawFrom = rule.time.from
740
+ const fromStr = typeof rawFrom === 'string' ? rawFrom : String(rawFrom)
741
+ const isDateOnly = typeof rawFrom === 'string' && !fromStr.includes('T')
742
+ const startIso = isDateOnly ? normalizeDateBoundary(fromStr, 'start') : undefined
743
+ const finalFrom = startIso || fromStr
744
+ query = query.gte('event_timestamp', finalFrom)
745
+ }
746
+ if (rule.time.to) {
747
+ const rawTo = rule.time.to
748
+ const toStr = typeof rawTo === 'string' ? rawTo : String(rawTo)
749
+ const isDateOnly = typeof rawTo === 'string' && !toStr.includes('T')
750
+ const endIsoExclusive = isDateOnly ? normalizeDateBoundary(toStr, 'end') : undefined
751
+ const finalTo = endIsoExclusive || toStr
752
+ query = isDateOnly ? query.lt('event_timestamp', finalTo) : query.lte('event_timestamp', finalTo)
753
+ }
754
+ }
755
+ }
756
+
757
+ // Event attributes
758
+ try {
759
+ const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
760
+ const resolveDbFieldForAttrKey = (keyRaw: string) => {
761
+ const key = String(keyRaw || '').trim()
762
+ if (!key) return null
763
+ const columnKeys = new Set([
764
+ 'current_url',
765
+ 'domain',
766
+ 'path',
767
+ 'referrer',
768
+ 'page_title',
769
+ 'utm_source',
770
+ 'utm_medium',
771
+ 'utm_campaign',
772
+ 'utm_term',
773
+ 'utm_content',
774
+ 'session_id',
775
+ 'session_is_new'
776
+ ])
777
+ if (columnKeys.has(key)) return { kind: 'column', field: key }
778
+ const buildNested = (root: string, dottedPath: string) => jsonNestedTextAccessor(root, dottedPath)
779
+ if (key.startsWith('event_data.')) return { kind: 'json', field: buildNested('event_data', key.replace(/^event_data\./, '')) }
780
+ if (key.startsWith('custom_data.')) return { kind: 'json', field: buildNested('event_data->custom_data', key.replace(/^custom_data\./, '')) }
781
+ if (key.startsWith('url_data.')) return { kind: 'json', field: buildNested('event_data->url_data', key.replace(/^url_data\./, '')) }
782
+ if (key.startsWith('utm_data.')) return { kind: 'json', field: buildNested('event_data->utm_data', key.replace(/^utm_data\./, '')) }
783
+ if (key.startsWith('session_data.')) return { kind: 'json', field: buildNested('event_data->session_data', key.replace(/^session_data\./, '')) }
784
+ if (key.includes('.')) return { kind: 'json', field: buildNested('event_data->custom_data', key) }
785
+ return { kind: 'json', field: jsonTextAccessor('event_data', key) }
786
+ }
787
+
788
+ for (const attr of attributes) {
789
+ const key = String(attr.key || '').trim()
790
+ const op = String(attr.op || 'contains')
791
+ const value = attr.value
792
+ if (!key || value == null) continue
793
+ const resolved = resolveDbFieldForAttrKey(key)
794
+ if (!resolved) continue
795
+ const dbField = resolved.field
796
+
797
+ switch (op) {
798
+ case 'equals':
799
+ query = query.filter(dbField, 'eq', value)
800
+ break
801
+ case 'not_equals':
802
+ query = query.filter(dbField, 'neq', value)
803
+ break
804
+ case 'starts_with':
805
+ query = query.filter(dbField, 'ilike', `${value}%`)
806
+ break
807
+ case 'ends_with':
808
+ query = query.filter(dbField, 'ilike', `%${value}`)
809
+ break
810
+ case 'contains':
811
+ default:
812
+ query = query.filter(dbField, 'ilike', `%${value}%`)
813
+ break
814
+ }
815
+ }
816
+ } catch {
817
+ }
818
+
819
+ const { data, error } = await query
820
+ if (error) return false
821
+
822
+ // Frequência / agregação (para 1 contato)
823
+ const rows = data || []
824
+ if (rule.frequency && rule.frequency.value != null) {
825
+ const { op, value, type = 'count', field = 'value' } = rule.frequency
826
+ let aggregatedValue = 0
827
+ if (type === 'count') {
828
+ aggregatedValue = rows.length
829
+ } else {
830
+ for (const row of rows) {
831
+ const eventData = row.event_data || {}
832
+ let numVal = 0
833
+ if (eventData[field] !== undefined) numVal = Number(eventData[field])
834
+ else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
835
+ else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
836
+ if (!Number.isNaN(numVal)) aggregatedValue += numVal
837
+ }
838
+ if (type === 'avg') aggregatedValue = rows.length > 0 ? aggregatedValue / rows.length : 0
839
+ }
840
+
841
+ return (
842
+ (op === '>=' && aggregatedValue >= value) ||
843
+ (op === '>' && aggregatedValue > value) ||
844
+ (op === '=' && aggregatedValue === value) ||
845
+ (op === '<=' && aggregatedValue <= value) ||
846
+ (op === '<' && aggregatedValue < value)
847
+ )
848
+ }
849
+
850
+ if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
851
+ const threshold = Number(cfg.pageCount)
852
+ return rows.length >= threshold
853
+ }
854
+
855
+ return rows.length > 0
856
+ }
857
+
858
+ const evalPropertyRuleForContact = async (rule: any): Promise<boolean> => {
859
+ let query = this.supabase
860
+ .from('contacts')
861
+ .select('id')
862
+ .eq('organization_id', organizationId)
863
+ .eq('project_id', projectId)
864
+ .eq('id', contactId)
865
+
866
+ if (isPastBehavior && asOfIso) {
867
+ query = query.lte('created_at', asOfIso)
868
+ }
869
+
870
+ const field = rule.field as string
871
+ const op = rule.op as string
872
+ const value = rule.value
873
+ const value2 = rule.value2
874
+ const isDateField = field === 'created_at' || field === 'updated_at'
875
+ const timeFrom = rule?.time?.from
876
+ const timeTo = rule?.time?.to
877
+ const hasTimeRange = isDateField && (typeof timeFrom === 'string' || typeof timeTo === 'string')
878
+
879
+ const computeRelativeRange = (direction: 'past' | 'future' = 'past'): { start: string; end: string } | null => {
880
+ const unitSource = rule.timeUnit ?? rule.unit ?? 'days'
881
+ const rawUnit = String(unitSource).toLowerCase()
882
+ const numericValue = Number(rule.timeValue ?? rule.value ?? NaN)
883
+ if (!Number.isFinite(numericValue) || numericValue < 0) return null
884
+ const units: Record<string, number> = {
885
+ minutes: 60 * 1000,
886
+ hours: 60 * 60 * 1000,
887
+ days: 24 * 60 * 60 * 1000,
888
+ weeks: 7 * 24 * 60 * 60 * 1000,
889
+ months: 30 * 24 * 60 * 60 * 1000,
890
+ years: 365 * 24 * 60 * 60 * 1000
891
+ }
892
+ const now = asOf ?? new Date()
893
+ const baseUnitMs: number = units[rawUnit] ?? units['days'] ?? 24 * 60 * 60 * 1000
894
+ const delta = baseUnitMs * numericValue
895
+ const start = direction === 'past' ? new Date(now.getTime() - delta) : now
896
+ const end = direction === 'past' ? now : new Date(now.getTime() + delta)
897
+ return { start: start.toISOString(), end: end.toISOString() }
898
+ }
899
+
900
+ const apply = (dbField: string, isJsonb: boolean = false) => {
901
+ switch (op) {
902
+ case 'equals':
903
+ if (isDateField) {
904
+ if (hasTimeRange) {
905
+ if (typeof timeFrom === 'string' && timeFrom.trim()) query = query.gte(dbField, timeFrom.trim())
906
+ if (typeof timeTo === 'string' && timeTo.trim()) query = query.lte(dbField, timeTo.trim())
907
+ } else {
908
+ const startIso = normalizeDateBoundary(value, 'start')
909
+ const endIsoExclusive = normalizeDateBoundary(value, 'end')
910
+ if (startIso) query = query.gte(dbField, startIso)
911
+ if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
912
+ }
913
+ } else {
914
+ query = query.eq(dbField, value)
915
+ }
916
+ break
917
+ case 'not_equals':
918
+ if (isJsonb && value === '') query = query.not(dbField, 'is', null).neq(dbField, '')
919
+ else query = query.neq(dbField, value)
920
+ break
921
+ case 'contains':
922
+ query = query.ilike(dbField, `%${value}%`)
923
+ break
924
+ case 'not_contains':
925
+ query = query.not(dbField, 'ilike', `%${value}%`)
926
+ break
927
+ case 'starts_with':
928
+ query = query.ilike(dbField, `${value}%`)
929
+ break
930
+ case 'ends_with':
931
+ query = query.ilike(dbField, `%${value}`)
932
+ break
933
+ case '>':
934
+ query = query.gt(dbField, value)
935
+ break
936
+ case '>=':
937
+ query = query.gte(dbField, value)
938
+ break
939
+ case '<':
940
+ query = query.lt(dbField, value)
941
+ break
942
+ case '<=':
943
+ query = query.lte(dbField, value)
944
+ break
945
+ case 'between':
946
+ if (isDateField) {
947
+ if (hasTimeRange) {
948
+ if (typeof timeFrom === 'string' && timeFrom.trim()) query = query.gte(dbField, timeFrom.trim())
949
+ if (typeof timeTo === 'string' && timeTo.trim()) query = query.lte(dbField, timeTo.trim())
950
+ } else {
951
+ const startIso = normalizeDateBoundary(value, 'start')
952
+ const endIsoExclusive = normalizeDateBoundary(value2 ?? value, 'end')
953
+ if (startIso) query = query.gte(dbField, startIso)
954
+ if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
955
+ }
956
+ } else {
957
+ if (value != null) query = query.gte(dbField, value)
958
+ if (value2 != null) query = query.lte(dbField, value2)
959
+ }
960
+ break
961
+ case 'after':
962
+ if (isDateField) {
963
+ if (hasTimeRange && typeof timeFrom === 'string' && timeFrom.trim()) query = query.gte(dbField, timeFrom.trim())
964
+ else {
965
+ const startIso = normalizeDateBoundary(value, 'start')
966
+ if (startIso) query = query.gte(dbField, startIso)
967
+ }
968
+ } else query = query.gt(dbField, value)
969
+ break
970
+ case 'before':
971
+ if (isDateField) {
972
+ if (hasTimeRange && typeof timeTo === 'string' && timeTo.trim()) query = query.lte(dbField, timeTo.trim())
973
+ else {
974
+ const endIsoExclusive = normalizeDateBoundary(value, 'end')
975
+ if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
976
+ }
977
+ } else query = query.lt(dbField, value)
978
+ break
979
+ case 'is_empty':
980
+ if (isJsonb) query = query.or(`${dbField}.is.null,${dbField}.eq.`)
981
+ else query = query.is(dbField, null)
982
+ break
983
+ case 'is_not_empty':
984
+ if (isJsonb) query = query.not(dbField, 'is', null).neq(dbField, '')
985
+ else query = query.not(dbField, 'is', null)
986
+ break
987
+ case 'is_true':
988
+ query = query.eq(dbField, true)
989
+ break
990
+ case 'is_false':
991
+ query = query.eq(dbField, false)
992
+ break
993
+ case 'in_the_last': {
994
+ const range = computeRelativeRange('past')
995
+ if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
996
+ break
997
+ }
998
+ case 'in_the_next': {
999
+ const range = computeRelativeRange('future')
1000
+ if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
1001
+ break
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ const mappedField = mapAudienceFieldToContactField(field)
1007
+ const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
1008
+
1009
+ if (known.includes(field) || known.includes(mappedField)) {
1010
+ apply(mappedField, false)
1011
+ } else {
1012
+ const dbField = `properties->>${field}`
1013
+ apply(dbField, true)
1014
+ }
1015
+
1016
+ const { data, error } = await query
1017
+ if (error) return false
1018
+ return (data || []).length > 0
1019
+ }
1020
+
1021
+ const evalRuleForContact = async (rule: any): Promise<boolean> => {
1022
+ const base = rule.kind === 'event' ? await evalEventRuleForContact(rule) : await evalPropertyRuleForContact(rule)
1023
+ const res = rule.negate ? !base : base
1024
+ return res
1025
+ }
1026
+
1027
+ const evalGroupForContact = async (group: any): Promise<boolean> => {
1028
+ let acc: boolean | null = null
1029
+ for (const rule of group.rules || []) {
1030
+ const r = await evalRuleForContact(rule)
1031
+ if (acc == null) acc = r
1032
+ else {
1033
+ if (group.operator === 'AND') acc = acc && r
1034
+ else if (group.operator === 'OR') acc = acc || r
1035
+ else if (group.operator === 'NOT') acc = acc && !r
1036
+ }
1037
+ }
1038
+ return !!acc
1039
+ }
1040
+
1041
+ let result: boolean | null = null
1042
+ for (const group of (criteria as any).groups) {
1043
+ const g = await evalGroupForContact(group)
1044
+ if (result == null) result = g
1045
+ else {
1046
+ if (group.operator === 'AND') result = result && g
1047
+ else if (group.operator === 'OR') result = result || g
1048
+ else if (group.operator === 'NOT') result = result && !g
1049
+ }
1050
+ }
1051
+
1052
+ return !!result
1053
+ }
1054
+
1055
+ private dlog(...args: any[]) {
1056
+ if (!this.debug) return
1057
+ this.logger.log('[AUDIENCE_MODULE_ENGINE]', ...args)
1058
+ }
1059
+ }
1060
+
1061
+ function jsonTextAccessor(base: string, key: string): string {
1062
+ const safeKey = String(key || '').replace(/'/g, "''")
1063
+ return `${base}->>'${safeKey}'`
1064
+ }
1065
+
1066
+ function jsonNestedTextAccessor(base: string, path: string): string {
1067
+ const parts = String(path || '')
1068
+ .split('.')
1069
+ .map((p) => p.trim())
1070
+ .filter(Boolean)
1071
+ if (parts.length === 0) return jsonTextAccessor(base, 'value')
1072
+ if (parts.length === 1) return jsonTextAccessor(base, parts[0]!)
1073
+ const chain = parts
1074
+ .slice(0, -1)
1075
+ .map((p) => `->'${String(p).replace(/'/g, "''")}'`)
1076
+ .join('')
1077
+ const last = String(parts[parts.length - 1]!).replace(/'/g, "''")
1078
+ return `${base}${chain}->>'${last}'`
1079
+ }
1080
+
1081
+ function mapAudienceFieldToContactField(audienceField: string): string {
1082
+ const fieldMapping: { [key: string]: string } = {
1083
+ is_subscribed: 'is_subscribed',
1084
+ email: 'email',
1085
+ name: 'first_name',
1086
+ first_name: 'first_name',
1087
+ last_name: 'last_name',
1088
+ phone: 'phone',
1089
+ created_at: 'created_at',
1090
+ updated_at: 'updated_at',
1091
+ city: 'city',
1092
+ country: 'country',
1093
+ tags: 'tags',
1094
+ status: 'status'
1095
+ }
1096
+ return fieldMapping[audienceField] || audienceField
1097
+ }
1098
+
1099
+ function normalizeDateBoundary(value: any, boundary: 'start' | 'end'): string | undefined {
1100
+ if (!value || typeof value !== 'string') return undefined
1101
+
1102
+ const hasTimeComponent = value.includes('T')
1103
+
1104
+ const toUtcStartOfDay = (y: number, mZeroBased: number, d: number): Date => {
1105
+ return new Date(Date.UTC(y, mZeroBased, d, 0, 0, 0, 0))
1106
+ }
1107
+
1108
+ let baseDate: Date | null = null
1109
+
1110
+ if (hasTimeComponent) {
1111
+ const dt = new Date(value)
1112
+ baseDate = Number.isNaN(dt.getTime()) ? null : dt
1113
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
1114
+ const parts = value.split('-')
1115
+ if (parts.length === 3) {
1116
+ const y = parseInt(parts[0]!, 10)
1117
+ const mm = parseInt(parts[1]!, 10)
1118
+ const d = parseInt(parts[2]!, 10)
1119
+ if (Number.isFinite(y) && Number.isFinite(mm) && Number.isFinite(d)) {
1120
+ baseDate = toUtcStartOfDay(y, mm - 1, d)
1121
+ }
1122
+ }
1123
+ } else if (/^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
1124
+ const parts = value.split('/')
1125
+ if (parts.length === 3) {
1126
+ const d = parseInt(parts[0]!, 10)
1127
+ const mm = parseInt(parts[1]!, 10)
1128
+ const y = parseInt(parts[2]!, 10)
1129
+ if (Number.isFinite(y) && Number.isFinite(mm) && Number.isFinite(d)) {
1130
+ baseDate = toUtcStartOfDay(y, mm - 1, d)
1131
+ }
1132
+ }
1133
+ } else {
1134
+ const dt = new Date(`${value}T00:00:00Z`)
1135
+ baseDate = Number.isNaN(dt.getTime()) ? null : dt
1136
+ }
1137
+
1138
+ if (!baseDate) return undefined
1139
+
1140
+ const utcYear = baseDate.getUTCFullYear()
1141
+ const utcMonth = baseDate.getUTCMonth()
1142
+ const utcDay = baseDate.getUTCDate()
1143
+
1144
+ if (boundary === 'start') {
1145
+ const startOfDay = new Date(Date.UTC(utcYear, utcMonth, utcDay, 0, 0, 0, 0))
1146
+ return startOfDay.toISOString()
1147
+ }
1148
+
1149
+ const nextDay = new Date(Date.UTC(utcYear, utcMonth, utcDay + 1, 0, 0, 0, 0))
1150
+ return nextDay.toISOString()
1151
+ }
1152
+
1153
+ function coerceCriteriaToGroups(criteria: AudienceCriteria): AudienceCriteria {
1154
+ const groups: any[] = []
1155
+
1156
+ const pushGroup = (operator: any, rules: any[]) => {
1157
+ if (!rules || rules.length === 0) return
1158
+ const op = operator === 'OR' || operator === 'NOT' ? operator : 'AND'
1159
+ groups.push({ operator: op, rules })
1160
+ }
1161
+
1162
+ // From `filters` (legado)
1163
+ if (Array.isArray((criteria as any).filters)) {
1164
+ for (const fg of (criteria as any).filters) {
1165
+ const rules: any[] = []
1166
+ for (const c of fg?.conditions || []) {
1167
+ const fieldRaw = String(c?.field || '')
1168
+ const operator = c?.operator
1169
+ const value = c?.value
1170
+ const value2 = c?.value2
1171
+
1172
+ if (fieldRaw === 'event') {
1173
+ rules.push({
1174
+ kind: 'event',
1175
+ eventName: value,
1176
+ negate: operator === 'not_equals',
1177
+ ...(c?.dateFrom || c?.dateTo ? { time: { from: c?.dateFrom, to: c?.dateTo } } : {}),
1178
+ ...(c?.timeValue != null ? { time: { unit: c?.timeUnit || 'days', value: Number(c?.timeValue) } } : {})
1179
+ })
1180
+ continue
1181
+ }
1182
+
1183
+ // Custom field: usar a chave real para cair em properties->>key
1184
+ const field = fieldRaw === 'custom_field' && c?.customFieldKey ? String(c.customFieldKey) : fieldRaw
1185
+ const rule: any = {
1186
+ kind: 'property',
1187
+ field,
1188
+ op: operator
1189
+ }
1190
+
1191
+ if (operator === 'in_the_last' || operator === 'in_the_next') {
1192
+ rule.timeValue = c?.timeValue ?? value
1193
+ rule.timeUnit = c?.timeUnit || 'days'
1194
+ } else if (operator === 'between') {
1195
+ rule.value = value
1196
+ rule.value2 = value2
1197
+ } else if (operator === 'is_empty' || operator === 'is_not_empty') {
1198
+ // sem value
1199
+ } else {
1200
+ rule.value = value
1201
+ if (value2 != null) rule.value2 = value2
1202
+ }
1203
+
1204
+ rules.push(rule)
1205
+ }
1206
+ pushGroup(fg?.operator, rules)
1207
+ }
1208
+ }
1209
+
1210
+ // From `conditions` (normalizado/legado)
1211
+ if (Array.isArray((criteria as any).conditions)) {
1212
+ for (const cg of (criteria as any).conditions) {
1213
+ const rules: any[] = []
1214
+ for (const c of cg?.conditions || []) {
1215
+ const fieldRaw = String(c?.field || '')
1216
+ const operator = c?.operator
1217
+ const value = c?.value
1218
+ const value2 = c?.value2
1219
+ const field = fieldRaw === 'custom_field' && c?.customFieldKey ? String(c.customFieldKey) : fieldRaw
1220
+
1221
+ const rule: any = { kind: 'property', field, op: operator }
1222
+ if (operator === 'between') {
1223
+ rule.value = value
1224
+ rule.value2 = value2
1225
+ } else if (operator === 'in_the_last' || operator === 'in_the_next') {
1226
+ rule.timeValue = (c as any)?.timeValue ?? value
1227
+ rule.timeUnit = (c as any)?.timeUnit || 'days'
1228
+ } else if (operator === 'is_empty' || operator === 'is_not_empty') {
1229
+ } else {
1230
+ rule.value = value
1231
+ if (value2 != null) rule.value2 = value2
1232
+ }
1233
+ rules.push(rule)
1234
+ }
1235
+ pushGroup(cg?.operator, rules)
1236
+ }
1237
+ }
1238
+
1239
+ return { ...(criteria as any), groups }
1240
+ }
1241
+
1242
+