@reachy/audience-module 1.0.12 → 1.0.14

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.
@@ -7,6 +7,247 @@ type Logger = {
7
7
  error: (...args: any[]) => void
8
8
  }
9
9
 
10
+ const getByPath = (obj: any, dottedPath: string): any => {
11
+ if (!obj || !dottedPath) return undefined
12
+ const parts = dottedPath.split('.').filter(Boolean)
13
+ let cur: any = obj
14
+ for (const p of parts) {
15
+ if (cur == null) return undefined
16
+ cur = cur[p]
17
+ }
18
+ return cur
19
+ }
20
+
21
+ const getEventPropertyValue = (eventData: any, keyRaw: any): any => {
22
+ const key = String(keyRaw || '').trim()
23
+ if (!key) return undefined
24
+ const ed = eventData || {}
25
+
26
+ if (key.startsWith('event_data.')) return getByPath(ed, key.replace(/^event_data\./, ''))
27
+ if (key.startsWith('custom_data.')) return getByPath(ed?.custom_data, key.replace(/^custom_data\./, ''))
28
+ if (key.startsWith('utm_data.')) return getByPath(ed?.utm_data, key.replace(/^utm_data\./, ''))
29
+ if (key.startsWith('url_data.')) return getByPath(ed?.url_data, key.replace(/^url_data\./, ''))
30
+ if (key.startsWith('session_data.')) return getByPath(ed?.session_data, key.replace(/^session_data\./, ''))
31
+
32
+ // fallback: tentar direto e depois dentro de custom_data
33
+ return getByPath(ed, key) ?? getByPath(ed?.custom_data, key)
34
+ }
35
+
36
+ const isValidIanaTimezone = (tzRaw: any): boolean => {
37
+ const tz = String(tzRaw || '').trim()
38
+ if (!tz) return false
39
+ try {
40
+ // eslint-disable-next-line no-new
41
+ new Intl.DateTimeFormat('en-US', { timeZone: tz })
42
+ return true
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ const parseTimeToMinutes = (raw: any): number | null => {
49
+ const s = String(raw || '').trim()
50
+ if (!s) return null
51
+ const m = s.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i)
52
+ if (!m) return null
53
+ let hh = Number(m[1])
54
+ const mm = Number(m[2])
55
+ const ampm = m[3] ? String(m[3]).toUpperCase() : null
56
+ if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null
57
+ if (mm < 0 || mm > 59) return null
58
+
59
+ if (ampm) {
60
+ if (hh < 1 || hh > 12) return null
61
+ if (ampm === 'AM') hh = hh === 12 ? 0 : hh
62
+ if (ampm === 'PM') hh = hh === 12 ? 12 : hh + 12
63
+ } else {
64
+ if (hh < 0 || hh > 23) return null
65
+ }
66
+ return hh * 60 + mm
67
+ }
68
+
69
+ const getLocalTimeParts = (iso: any, tz: string): { minutes: number; weekday: number; dayOfMonth: number } | null => {
70
+ const d = new Date(String(iso || ''))
71
+ if (Number.isNaN(d.getTime())) return null
72
+
73
+ const dtf = new Intl.DateTimeFormat('en-US', {
74
+ timeZone: tz,
75
+ hour: '2-digit',
76
+ minute: '2-digit',
77
+ hour12: false,
78
+ weekday: 'short',
79
+ day: '2-digit',
80
+ })
81
+
82
+ const parts = dtf.formatToParts(d)
83
+ let hourStr = ''
84
+ let minStr = ''
85
+ let weekdayStr = ''
86
+ let dayStr = ''
87
+ for (const p of parts) {
88
+ if (p.type === 'hour') hourStr = p.value
89
+ if (p.type === 'minute') minStr = p.value
90
+ if (p.type === 'weekday') weekdayStr = p.value
91
+ if (p.type === 'day') dayStr = p.value
92
+ }
93
+
94
+ const hour = Number(hourStr)
95
+ const minute = Number(minStr)
96
+ const dayOfMonth = Number(dayStr)
97
+ if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(dayOfMonth)) return null
98
+ const minutes = hour * 60 + minute
99
+
100
+ // 0=Sunday ... 6=Saturday
101
+ const weekdayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
102
+ const weekday = weekdayMap[weekdayStr] ?? -1
103
+ if (weekday < 0) return null
104
+
105
+ return { minutes, weekday, dayOfMonth }
106
+ }
107
+
108
+ const parseWeekdayToIndex = (raw: any): number | null => {
109
+ const s = String(raw || '').trim().toLowerCase()
110
+ if (!s) return null
111
+ const map: Record<string, number> = {
112
+ sunday: 0, sun: 0,
113
+ monday: 1, mon: 1,
114
+ tuesday: 2, tue: 2, tues: 2,
115
+ wednesday: 3, wed: 3,
116
+ thursday: 4, thu: 4, thurs: 4,
117
+ friday: 5, fri: 5,
118
+ saturday: 6, sat: 6,
119
+ }
120
+ return map[s] ?? null
121
+ }
122
+
123
+ const matchesInterest = (raw: any, op: string, expected: any): boolean => {
124
+ if (raw === null || raw === undefined) return false
125
+ const a = String(raw).trim()
126
+ const b = String(expected ?? '').trim()
127
+ if (!b) return false
128
+ const nOp = String(op || 'equals').trim()
129
+ if (nOp === 'equals') return a === b
130
+ if (nOp === 'contains') return a.toLowerCase().includes(b.toLowerCase())
131
+ if (nOp === 'starts_with') return a.toLowerCase().startsWith(b.toLowerCase())
132
+ if (nOp === 'ends_with') return a.toLowerCase().endsWith(b.toLowerCase())
133
+ if (nOp === 'not_equals') return a !== b
134
+ return a.toLowerCase().includes(b.toLowerCase())
135
+ }
136
+
137
+ const normalizeEventPropertyOp = (opRaw: any): string => {
138
+ const op = String(opRaw ?? '').trim()
139
+ if (op === '=' || op === '==' || op === 'eq' || op === 'equals') return 'equals'
140
+ if (op === '!=' || op === '<>' || op === 'neq' || op === 'not_equals') return 'not_equals'
141
+ if (op === 'contains') return 'contains'
142
+ if (op === 'not_contains') return 'not_contains'
143
+ if (op === 'starts_with') return 'starts_with'
144
+ if (op === 'ends_with') return 'ends_with'
145
+ if (op === 'is_empty') return 'is_empty'
146
+ if (op === 'is_not_empty') return 'is_not_empty'
147
+ if (op === '>' || op === 'gt') return 'gt'
148
+ if (op === '>=' || op === 'gte') return 'gte'
149
+ if (op === '<' || op === 'lt') return 'lt'
150
+ if (op === '<=' || op === 'lte') return 'lte'
151
+ return op
152
+ }
153
+
154
+ const matchesEventPropertyFilter = (raw: any, opRaw: any, expected: any): boolean => {
155
+ const op = normalizeEventPropertyOp(opRaw)
156
+
157
+ if (op === 'is_empty') {
158
+ return raw === null || raw === undefined || String(raw).trim() === ''
159
+ }
160
+ if (op === 'is_not_empty') {
161
+ return raw !== null && raw !== undefined && String(raw).trim() !== ''
162
+ }
163
+
164
+ if (raw === null || raw === undefined) return false
165
+ const a = String(raw)
166
+ const b = String(expected ?? '')
167
+
168
+ if (op === 'equals') return a === b
169
+ if (op === 'not_equals') return a !== b
170
+ if (op === 'contains') return a.toLowerCase().includes(b.toLowerCase())
171
+ if (op === 'not_contains') return !a.toLowerCase().includes(b.toLowerCase())
172
+ if (op === 'starts_with') return a.toLowerCase().startsWith(b.toLowerCase())
173
+ if (op === 'ends_with') return a.toLowerCase().endsWith(b.toLowerCase())
174
+
175
+ if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') {
176
+ const aNum = Number(raw)
177
+ const bNum = Number(expected)
178
+ if (!Number.isFinite(aNum) || !Number.isFinite(bNum)) return false
179
+ if (op === 'gt') return aNum > bNum
180
+ if (op === 'gte') return aNum >= bNum
181
+ if (op === 'lt') return aNum < bNum
182
+ return aNum <= bNum
183
+ }
184
+
185
+ return false
186
+ }
187
+
188
+ const applyEventRuleFilters = (
189
+ rows: any[],
190
+ filters: any[],
191
+ opts?: { timezone?: string }
192
+ ): any[] => {
193
+ let out = rows
194
+ const tz = String(opts?.timezone || '').trim()
195
+ for (const f of filters) {
196
+ const type = String(f?.type || '').trim()
197
+ if (type === 'event_property') {
198
+ const key = String(f?.key || '').trim()
199
+ if (!key) continue
200
+ const op = f?.op
201
+ const value = f?.value
202
+ out = out.filter((row) => matchesEventPropertyFilter(getEventPropertyValue(row?.event_data, key), op, value))
203
+ } else if (type === 'time_of_day') {
204
+ if (!tz) continue
205
+ const startMin = parseTimeToMinutes(f?.timeStart)
206
+ const endMin = parseTimeToMinutes(f?.timeEnd)
207
+ if (startMin == null || endMin == null) continue
208
+ // compatível com between: se start > end, não casa
209
+ if (startMin > endMin) {
210
+ out = []
211
+ continue
212
+ }
213
+ out = out.filter((row) => {
214
+ const parts = getLocalTimeParts(row?.event_timestamp, tz)
215
+ if (!parts) return false
216
+ return parts.minutes >= startMin && parts.minutes <= endMin
217
+ })
218
+ } else if (type === 'day_of_week') {
219
+ if (!tz) continue
220
+ const days = Array.isArray(f?.days) ? f.days : []
221
+ const allowed = new Set<number>()
222
+ for (const d of days) {
223
+ const idx = parseWeekdayToIndex(d)
224
+ if (idx != null) allowed.add(idx)
225
+ }
226
+ if (allowed.size === 0) continue
227
+ out = out.filter((row) => {
228
+ const parts = getLocalTimeParts(row?.event_timestamp, tz)
229
+ if (!parts) return false
230
+ return allowed.has(parts.weekday)
231
+ })
232
+ } else if (type === 'day_of_month') {
233
+ if (!tz) continue
234
+ const days = Array.isArray(f?.days) ? f.days : []
235
+ const allowed = new Set<number>()
236
+ for (const d of days) {
237
+ const n = Number(d)
238
+ if (Number.isFinite(n) && n >= 1 && n <= 31) allowed.add(Math.floor(n))
239
+ }
240
+ if (allowed.size === 0) continue
241
+ out = out.filter((row) => {
242
+ const parts = getLocalTimeParts(row?.event_timestamp, tz)
243
+ if (!parts) return false
244
+ return allowed.has(parts.dayOfMonth)
245
+ })
246
+ }
247
+ }
248
+ return out
249
+ }
250
+
10
251
  /**
11
252
  * Engine V2 (groups/rules) portado do reachy-api para o audience-module.
12
253
  *
@@ -31,6 +272,24 @@ export class V2AudienceEngine {
31
272
  ): Promise<Set<string>> {
32
273
  let criteria: AudienceCriteria = CriteriaParser.parse(criteriaRaw as any) as any
33
274
 
275
+ // Timezone do projeto: usado em filtros de tempo (time_of_day/day_of_week/day_of_month)
276
+ // Fonte: projects.settings.timezone (mesma tela de Settings > Project no frontend)
277
+ let projectTimezone = 'UTC'
278
+ try {
279
+ const { data } = await this.supabase
280
+ .from('projects')
281
+ .select('settings')
282
+ .eq('organization_id', organizationId)
283
+ .eq('id', projectId)
284
+ .single()
285
+ const tzCandidate = (data as any)?.settings?.timezone
286
+ if (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate)) {
287
+ projectTimezone = tzCandidate.trim()
288
+ }
289
+ } catch {
290
+ // fallback silencioso
291
+ }
292
+
34
293
  // Compat: permitir critérios legados (filters/conditions) sem groups,
35
294
  // convertendo para groups V2 para que o engine avalie sozinho.
36
295
  if (
@@ -63,12 +322,16 @@ export class V2AudienceEngine {
63
322
 
64
323
  const allContactIds = new Set<string>((allContacts || []).map((c: any) => c.id as string))
65
324
  const reachyIdToContactId = new Map<string, string>()
325
+ const contactIdToReachyId = new Map<string, string>()
66
326
  const emailToContactId = new Map<string, string>()
67
327
 
68
328
  for (const c of allContacts || []) {
69
329
  const rid = c?.reachy_id ? String(c.reachy_id).trim() : ''
70
330
  const cid = c?.id ? String(c.id).trim() : ''
71
- if (rid && cid) reachyIdToContactId.set(rid, cid)
331
+ if (rid && cid) {
332
+ reachyIdToContactId.set(rid, cid)
333
+ contactIdToReachyId.set(cid, rid)
334
+ }
72
335
  const email = c?.email ? String(c.email).trim().toLowerCase() : ''
73
336
  if (email && cid) emailToContactId.set(email, cid)
74
337
  }
@@ -206,8 +469,15 @@ export class V2AudienceEngine {
206
469
  }
207
470
 
208
471
  // Event attributes: attributes: [{ key, op, value }]
472
+ const interest = (rule as any)?.interest
209
473
  try {
210
- const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
474
+ let attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
475
+ // Se houver "interest", não podemos pré-filtrar o dataset pelo atributo do interesse,
476
+ // senão o cálculo de % sempre vira 100% (porque só sobram eventos que já casam).
477
+ const interestKey = interest?.key ? String(interest.key).trim() : ''
478
+ if (interestKey) {
479
+ attributes = attributes.filter((a: any) => String(a?.key || '').trim() !== interestKey)
480
+ }
211
481
 
212
482
  const trackerEvents = new Set([
213
483
  'click',
@@ -290,26 +560,256 @@ export class V2AudienceEngine {
290
560
  const { data, error } = await query
291
561
  if (error) return new Set<string>()
292
562
 
563
+ // Event advanced filters (ex.: event_property dentro do DID)
564
+ let rows = (data || []) as any[]
565
+ const ruleFiltersAll = Array.isArray((rule as any).filters) ? (rule as any).filters : []
566
+ const hasFirstTime = ruleFiltersAll.some((f: any) => String(f?.type || '').trim() === 'first_time')
567
+ const hasLastTime = ruleFiltersAll.some((f: any) => String(f?.type || '').trim() === 'last_time')
568
+ const ruleFilters = ruleFiltersAll.filter((f: any) => {
569
+ const t = String(f?.type || '').trim()
570
+ return t && t !== 'first_time' && t !== 'last_time'
571
+ })
572
+
573
+ if (ruleFilters.length > 0) {
574
+ rows = applyEventRuleFilters(rows, ruleFilters, { timezone: projectTimezone })
575
+ }
576
+
577
+ // First Time / Last Time (CleverTap-like): avaliar pelo histórico do evento (vida inteira),
578
+ // e só depois aplicar a janela temporal (rule.time).
579
+ // Implementação eficiente:
580
+ // - candidatos = contatos que têm pelo menos 1 evento na janela atual
581
+ // - first_time: excluir candidatos que já tinham o evento ANTES do início da janela
582
+ // - last_time: excluir candidatos que têm o evento DEPOIS do fim da janela
583
+ if ((hasFirstTime || hasLastTime) && rows.length > 0) {
584
+ const now = new Date()
585
+ let windowStartIso: string | undefined
586
+ let windowEndIso: string | undefined
587
+
588
+ // Preferir rule.time; fallback em cfg.timeFrame (paridade)
589
+ if (rule.time) {
590
+ if (rule.time.unit && rule.time.value != null) {
591
+ type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'
592
+ const unit = String(rule.time.unit) as TimeUnit
593
+ const valueNum = Number(rule.time.value)
594
+ const units: Record<TimeUnit, number> = {
595
+ minutes: 60 * 1000,
596
+ hours: 60 * 60 * 1000,
597
+ days: 24 * 60 * 60 * 1000,
598
+ weeks: 7 * 24 * 60 * 60 * 1000,
599
+ months: 30 * 24 * 60 * 60 * 1000,
600
+ }
601
+ const ms = units[unit] ?? units['days']
602
+ if (Number.isFinite(valueNum) && valueNum >= 0) {
603
+ windowStartIso = new Date(now.getTime() - valueNum * ms).toISOString()
604
+ windowEndIso = now.toISOString()
605
+ }
606
+ } else {
607
+ if (rule.time.from) windowStartIso = String(rule.time.from)
608
+ if (rule.time.to) windowEndIso = String(rule.time.to)
609
+ }
610
+ } else if (cfg && cfg.timeFrame) {
611
+ const tf = String(cfg.timeFrame).trim()
612
+ let unit: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' = 'days'
613
+ let value = 7
614
+ if (/^\d+$/.test(tf)) { value = Number(tf); unit = 'days' }
615
+ else if (/^\d+\s*m$/.test(tf)) { value = Number(tf.replace(/m$/, '')); unit = 'minutes' }
616
+ else if (/^\d+\s*h$/.test(tf)) { value = Number(tf.replace(/h$/, '')); unit = 'hours' }
617
+ else if (/^\d+\s*d$/.test(tf)) { value = Number(tf.replace(/d$/, '')); unit = 'days' }
618
+ else if (/^\d+\s*w$/.test(tf)) { value = Number(tf.replace(/w$/, '')); unit = 'weeks' }
619
+ else if (/^\d+\s*mo$/.test(tf)) { value = Number(tf.replace(/mo$/, '')); unit = 'months' }
620
+ const units: Record<typeof unit, number> = {
621
+ minutes: 60 * 1000,
622
+ hours: 60 * 60 * 1000,
623
+ days: 24 * 60 * 60 * 1000,
624
+ weeks: 7 * 24 * 60 * 60 * 1000,
625
+ months: 30 * 24 * 60 * 60 * 1000,
626
+ }
627
+ if (Number.isFinite(value) && value >= 0) {
628
+ const from = new Date(now.getTime() - value * units[unit])
629
+ windowStartIso = from.toISOString()
630
+ windowEndIso = now.toISOString()
631
+ }
632
+ }
633
+
634
+ if (!windowEndIso) windowEndIso = now.toISOString()
635
+
636
+ const candidateIds = new Set<string>()
637
+ for (const row of rows) {
638
+ const id = resolveEventContactId(row)
639
+ if (id) candidateIds.add(id)
640
+ }
641
+
642
+ if (candidateIds.size === 0) {
643
+ return new Set<string>()
644
+ }
645
+
646
+ const candidateArr = Array.from(candidateIds)
647
+ const CHUNK = 500
648
+ const PAGE = 1000
649
+
650
+ const fetchOutside = async (dir: 'before' | 'after', boundaryIso: string): Promise<Set<string>> => {
651
+ const outIds = new Set<string>()
652
+ for (let i = 0; i < candidateArr.length; i += CHUNK) {
653
+ const chunkIds = candidateArr.slice(i, i + CHUNK)
654
+ const chunkReachyIds = chunkIds.map((cid) => contactIdToReachyId.get(cid)).filter(Boolean) as string[]
655
+
656
+ const runPaged = async (queryBuilderFactory: (rangeFrom: number, rangeTo: number) => any) => {
657
+ let offset = 0
658
+ while (true) {
659
+ const query = queryBuilderFactory(offset, offset + PAGE - 1)
660
+ const { data: rowsPage, error: pageError } = await query
661
+ if (pageError || !rowsPage || rowsPage.length === 0) break
662
+ for (const r of rowsPage as any[]) {
663
+ const cid = resolveEventContactId(r)
664
+ if (cid) outIds.add(cid)
665
+ }
666
+ if (outIds.size >= candidateIds.size) return
667
+ if (rowsPage.length < PAGE) break
668
+ offset += PAGE
669
+ if (offset > 50_000) break
670
+ }
671
+ }
672
+
673
+ // Query por contact_id
674
+ await runPaged((from: number, to: number) => {
675
+ let q = this.supabase
676
+ .from('contact_events')
677
+ .select('contact_id, reachy_id, event_timestamp')
678
+ .eq('organization_id', organizationId)
679
+ .eq('project_id', projectId)
680
+ .eq('event_name', effectiveEventName)
681
+ q = dir === 'before' ? q.lt('event_timestamp', boundaryIso) : q.gt('event_timestamp', boundaryIso)
682
+ q = q.in('contact_id', chunkIds)
683
+ return q.range(from, to)
684
+ })
685
+
686
+ if (outIds.size >= candidateIds.size) break
687
+
688
+ // Query por reachy_id (para casos em que contact_id é null)
689
+ if (chunkReachyIds.length > 0) {
690
+ await runPaged((from: number, to: number) => {
691
+ let q = this.supabase
692
+ .from('contact_events')
693
+ .select('contact_id, reachy_id, event_timestamp')
694
+ .eq('organization_id', organizationId)
695
+ .eq('project_id', projectId)
696
+ .eq('event_name', effectiveEventName)
697
+ q = dir === 'before' ? q.lt('event_timestamp', boundaryIso) : q.gt('event_timestamp', boundaryIso)
698
+ q = q.in('reachy_id', chunkReachyIds)
699
+ return q.range(from, to)
700
+ })
701
+ }
702
+
703
+ if (outIds.size >= candidateIds.size) break
704
+ }
705
+ return outIds
706
+ }
707
+
708
+ let allowed = new Set<string>(candidateIds)
709
+ if (hasFirstTime && windowStartIso) {
710
+ const hadBefore = await fetchOutside('before', windowStartIso)
711
+ allowed = diff(allowed, hadBefore)
712
+ }
713
+ if (hasLastTime && windowEndIso) {
714
+ const hadAfter = await fetchOutside('after', windowEndIso)
715
+ allowed = diff(allowed, hadAfter)
716
+ }
717
+
718
+ if (allowed.size === 0) {
719
+ return new Set<string>()
720
+ }
721
+
722
+ rows = rows.filter((row) => {
723
+ const id = resolveEventContactId(row)
724
+ return id ? allowed.has(id) : false
725
+ })
726
+ }
727
+
728
+ // User Interests: predominantly / at least X% of the time
729
+ // rule.interest?: { key, op, value, occurrenceType, occurrencePercentage }
730
+ if (interest && interest.key) {
731
+ const key = String(interest.key || '').trim()
732
+ const op = String(interest.op || 'equals').trim()
733
+ const expected = interest.value
734
+ const occType = String(interest.occurrenceType || 'predominantly').trim()
735
+ const pct = Number(interest.occurrencePercentage ?? 0)
736
+ const threshold = Number.isFinite(pct) ? Math.max(0, Math.min(100, pct)) / 100 : 0
737
+
738
+ // total events por contato dentro da janela (já filtrada por time/event_name)
739
+ const totals = new Map<string, number>()
740
+ // matches (para at_least) e counts por valor (para predominantly)
741
+ const matchCounts = new Map<string, number>()
742
+ const valueCounts = new Map<string, Map<string, number>>()
743
+
744
+ for (const row of rows) {
745
+ const id = resolveEventContactId(row)
746
+ if (!id) continue
747
+ totals.set(id, (totals.get(id) || 0) + 1)
748
+
749
+ const rawVal = getEventPropertyValue(row?.event_data, key)
750
+ const strVal = rawVal === null || rawVal === undefined ? '' : String(rawVal)
751
+
752
+ // para predominantemente: contar por valor bruto
753
+ if (!valueCounts.has(id)) valueCounts.set(id, new Map<string, number>())
754
+ const per = valueCounts.get(id)!
755
+ per.set(strVal, (per.get(strVal) || 0) + 1)
756
+
757
+ if (matchesInterest(rawVal, op === 'contains' ? 'contains' : op, expected)) {
758
+ matchCounts.set(id, (matchCounts.get(id) || 0) + 1)
759
+ }
760
+ }
761
+
762
+ const res = new Set<string>()
763
+ totals.forEach((total, id) => {
764
+ if (total <= 0) return
765
+
766
+ const match = matchCounts.get(id) || 0
767
+ if (occType === 'at_least') {
768
+ if (match > 0 && match / total >= threshold) res.add(id)
769
+ return
770
+ }
771
+
772
+ // predominantly
773
+ const per = valueCounts.get(id)
774
+ if (!per) return
775
+ let maxOther = 0
776
+ // "predominantly": o grupo que casa o operador/valor deve ser >= qualquer outro valor individual.
777
+ per.forEach((cnt, valStr) => {
778
+ if (!matchesInterest(valStr, op, expected)) {
779
+ if (cnt > maxOther) maxOther = cnt
780
+ }
781
+ })
782
+
783
+ if (match > 0 && match >= maxOther) res.add(id)
784
+ })
785
+
786
+ return res
787
+ }
788
+
293
789
  // Frequência / agregação
294
790
  if (rule.frequency && rule.frequency.value != null) {
295
- const { op, value, type = 'count', field = 'value' } = rule.frequency
791
+ const { op, value, value2, type = 'count', field = 'value' } = rule.frequency
296
792
  const counts = new Map<string, number>()
793
+ const denoms = new Map<string, number>() // usado para avg (ignora eventos sem número)
297
794
 
298
- for (const row of data || []) {
795
+ for (const row of rows) {
299
796
  const id = resolveEventContactId(row)
300
797
  if (!id) continue
301
798
 
302
799
  if (type === 'count') {
303
800
  counts.set(id, (counts.get(id) || 0) + 1)
304
801
  } else if (type === 'sum' || type === 'avg') {
305
- let numVal = 0
306
802
  const eventData = row.event_data || {}
307
- if (eventData[field] !== undefined) numVal = Number(eventData[field])
308
- else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
309
- else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
310
-
311
- if (!Number.isNaN(numVal)) {
803
+ const raw =
804
+ getEventPropertyValue(eventData, field) ??
805
+ (eventData?.[field] !== undefined ? eventData[field] : undefined) ??
806
+ eventData?.value ??
807
+ eventData?.amount
808
+ const numVal = Number(raw)
809
+
810
+ if (Number.isFinite(numVal)) {
312
811
  counts.set(id, (counts.get(id) || 0) + numVal)
812
+ if (type === 'avg') denoms.set(id, (denoms.get(id) || 0) + 1)
313
813
  }
314
814
  }
315
815
  }
@@ -318,21 +818,28 @@ export class V2AudienceEngine {
318
818
  counts.forEach((aggregatedValue, id) => {
319
819
  let finalValue = aggregatedValue
320
820
  if (type === 'avg') {
321
- let eventCount = 0
322
- for (const row of data || []) {
323
- const rid = resolveEventContactId(row)
324
- if (rid === id) eventCount++
325
- }
326
- finalValue = eventCount > 0 ? aggregatedValue / eventCount : 0
821
+ const denom = denoms.get(id) || 0
822
+ finalValue = denom > 0 ? aggregatedValue / denom : 0
327
823
  }
328
824
 
825
+ const betweenOk = (() => {
826
+ if (op !== 'between') return false
827
+ const a = Number(value)
828
+ const b = Number(value2)
829
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return false
830
+ // Compatível com CleverTap: se o usuário inverter (min > max), não deve casar.
831
+ if (a > b) return false
832
+ return finalValue >= a && finalValue <= b
833
+ })()
834
+
329
835
  if (
330
836
  (op === '>=' && finalValue >= value) ||
331
837
  (op === '>' && finalValue > value) ||
332
838
  (op === '=' && finalValue === value) ||
333
839
  (op === '!=' && finalValue !== value) ||
334
840
  (op === '<=' && finalValue <= value) ||
335
- (op === '<' && finalValue < value)
841
+ (op === '<' && finalValue < value) ||
842
+ betweenOk
336
843
  ) {
337
844
  res.add(id)
338
845
  }
@@ -344,7 +851,7 @@ export class V2AudienceEngine {
344
851
  if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
345
852
  const threshold = Number(cfg.pageCount)
346
853
  const counts = new Map<string, number>()
347
- for (const row of data || []) {
854
+ for (const row of rows) {
348
855
  const id = resolveEventContactId(row)
349
856
  if (!id) continue
350
857
  counts.set(id, (counts.get(id) || 0) + 1)
@@ -355,7 +862,7 @@ export class V2AudienceEngine {
355
862
  }
356
863
 
357
864
  const res = new Set<string>()
358
- for (const row of data || []) {
865
+ for (const row of rows) {
359
866
  const id = resolveEventContactId(row)
360
867
  if (id) res.add(id)
361
868
  }
@@ -614,6 +1121,23 @@ export class V2AudienceEngine {
614
1121
 
615
1122
  const typeId = String((criteria as any)?.type || '')
616
1123
 
1124
+ // Timezone do projeto (para filtros de tempo em eventos)
1125
+ let projectTimezone = 'UTC'
1126
+ try {
1127
+ const { data } = await this.supabase
1128
+ .from('projects')
1129
+ .select('settings')
1130
+ .eq('organization_id', organizationId)
1131
+ .eq('id', projectId)
1132
+ .single()
1133
+ const tzCandidate = (data as any)?.settings?.timezone
1134
+ if (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate)) {
1135
+ projectTimezone = tzCandidate.trim()
1136
+ }
1137
+ } catch {
1138
+ // fallback silencioso
1139
+ }
1140
+
617
1141
  // Carregar identidade do contato (para reachy_id)
618
1142
  const { data: contactRow } = await this.supabase
619
1143
  .from('contacts')
@@ -737,8 +1261,13 @@ export class V2AudienceEngine {
737
1261
  }
738
1262
 
739
1263
  // Event attributes
1264
+ const interest = (rule as any)?.interest
740
1265
  try {
741
- const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
1266
+ let attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
1267
+ const interestKey = interest?.key ? String(interest.key).trim() : ''
1268
+ if (interestKey) {
1269
+ attributes = attributes.filter((a: any) => String(a?.key || '').trim() !== interestKey)
1270
+ }
742
1271
  const resolveDbFieldForAttrKey = (keyRaw: string) => {
743
1272
  const key = String(keyRaw || '').trim()
744
1273
  if (!key) return null
@@ -801,23 +1330,180 @@ export class V2AudienceEngine {
801
1330
  const { data, error } = await query
802
1331
  if (error) return false
803
1332
 
1333
+ // Event advanced filters (ex.: event_property dentro do DID)
1334
+ let rows = (data || []) as any[]
1335
+ const ruleFiltersAll = Array.isArray((rule as any).filters) ? (rule as any).filters : []
1336
+ const hasFirstTime = ruleFiltersAll.some((f: any) => String(f?.type || '').trim() === 'first_time')
1337
+ const hasLastTime = ruleFiltersAll.some((f: any) => String(f?.type || '').trim() === 'last_time')
1338
+ const ruleFilters = ruleFiltersAll.filter((f: any) => {
1339
+ const t = String(f?.type || '').trim()
1340
+ return t && t !== 'first_time' && t !== 'last_time'
1341
+ })
1342
+
1343
+ if (ruleFilters.length > 0) {
1344
+ rows = applyEventRuleFilters(rows, ruleFilters, { timezone: projectTimezone })
1345
+ }
1346
+
1347
+ if ((hasFirstTime || hasLastTime) && rows.length > 0) {
1348
+ const now = new Date()
1349
+ let windowStartIso: string | undefined
1350
+ let windowEndIso: string | undefined
1351
+
1352
+ if (rule.time) {
1353
+ if (rule.time.unit && rule.time.value != null) {
1354
+ type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'
1355
+ const unit = String(rule.time.unit) as TimeUnit
1356
+ const valueNum = Number(rule.time.value)
1357
+ const units: Record<TimeUnit, number> = {
1358
+ minutes: 60 * 1000,
1359
+ hours: 60 * 60 * 1000,
1360
+ days: 24 * 60 * 60 * 1000,
1361
+ weeks: 7 * 24 * 60 * 60 * 1000,
1362
+ months: 30 * 24 * 60 * 60 * 1000,
1363
+ }
1364
+ const ms = units[unit] ?? units['days']
1365
+ if (Number.isFinite(valueNum) && valueNum >= 0) {
1366
+ windowStartIso = new Date(now.getTime() - valueNum * ms).toISOString()
1367
+ windowEndIso = now.toISOString()
1368
+ }
1369
+ } else {
1370
+ if (rule.time.from) windowStartIso = String(rule.time.from)
1371
+ if (rule.time.to) windowEndIso = String(rule.time.to)
1372
+ }
1373
+ } else if (cfg && cfg.timeFrame) {
1374
+ const tf = String(cfg.timeFrame).trim()
1375
+ let unit: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' = 'days'
1376
+ let value = 7
1377
+ if (/^\d+$/.test(tf)) { value = Number(tf); unit = 'days' }
1378
+ else if (/^\d+\s*m$/.test(tf)) { value = Number(tf.replace(/m$/, '')); unit = 'minutes' }
1379
+ else if (/^\d+\s*h$/.test(tf)) { value = Number(tf.replace(/h$/, '')); unit = 'hours' }
1380
+ else if (/^\d+\s*d$/.test(tf)) { value = Number(tf.replace(/d$/, '')); unit = 'days' }
1381
+ else if (/^\d+\s*w$/.test(tf)) { value = Number(tf.replace(/w$/, '')); unit = 'weeks' }
1382
+ else if (/^\d+\s*mo$/.test(tf)) { value = Number(tf.replace(/mo$/, '')); unit = 'months' }
1383
+ const units: Record<typeof unit, number> = {
1384
+ minutes: 60 * 1000,
1385
+ hours: 60 * 60 * 1000,
1386
+ days: 24 * 60 * 60 * 1000,
1387
+ weeks: 7 * 24 * 60 * 60 * 1000,
1388
+ months: 30 * 24 * 60 * 60 * 1000,
1389
+ }
1390
+ if (Number.isFinite(value) && value >= 0) {
1391
+ const from = new Date(now.getTime() - value * units[unit])
1392
+ windowStartIso = from.toISOString()
1393
+ windowEndIso = now.toISOString()
1394
+ }
1395
+ }
1396
+
1397
+ if (!windowEndIso) windowEndIso = now.toISOString()
1398
+
1399
+ if (hasFirstTime && windowStartIso) {
1400
+ let q = this.supabase
1401
+ .from('contact_events')
1402
+ .select('id')
1403
+ .eq('organization_id', organizationId)
1404
+ .eq('project_id', projectId)
1405
+ .eq('event_name', effectiveEventName)
1406
+ .lt('event_timestamp', windowStartIso)
1407
+ .limit(1)
1408
+
1409
+ // mesmo narrow de identidade que usamos acima
1410
+ const ors2: string[] = []
1411
+ if (contactId) ors2.push(`contact_id.eq.${contactId}`)
1412
+ if (reachyId) ors2.push(`reachy_id.eq.${reachyId}`)
1413
+ if (ors2.length > 0) q = q.or(ors2.join(','))
1414
+
1415
+ const { data: beforeRows } = await q
1416
+ if (Array.isArray(beforeRows) && beforeRows.length > 0) return false
1417
+ }
1418
+
1419
+ if (hasLastTime && windowEndIso) {
1420
+ let q = this.supabase
1421
+ .from('contact_events')
1422
+ .select('id')
1423
+ .eq('organization_id', organizationId)
1424
+ .eq('project_id', projectId)
1425
+ .eq('event_name', effectiveEventName)
1426
+ .gt('event_timestamp', windowEndIso)
1427
+ .limit(1)
1428
+
1429
+ const ors2: string[] = []
1430
+ if (contactId) ors2.push(`contact_id.eq.${contactId}`)
1431
+ if (reachyId) ors2.push(`reachy_id.eq.${reachyId}`)
1432
+ if (ors2.length > 0) q = q.or(ors2.join(','))
1433
+
1434
+ const { data: afterRows } = await q
1435
+ if (Array.isArray(afterRows) && afterRows.length > 0) return false
1436
+ }
1437
+ }
1438
+
1439
+ // User Interests: predominantly / at least X% of the time (para 1 contato)
1440
+ if (interest && interest.key) {
1441
+ const key = String(interest.key || '').trim()
1442
+ const op = String(interest.op || 'equals').trim()
1443
+ const expected = interest.value
1444
+ const occType = String(interest.occurrenceType || 'predominantly').trim()
1445
+ const pct = Number(interest.occurrencePercentage ?? 0)
1446
+ const threshold = Number.isFinite(pct) ? Math.max(0, Math.min(100, pct)) / 100 : 0
1447
+
1448
+ if (rows.length === 0) return false
1449
+
1450
+ let total = 0
1451
+ let match = 0
1452
+ const counts = new Map<string, number>()
1453
+
1454
+ for (const row of rows) {
1455
+ total++
1456
+ const rawVal = getEventPropertyValue(row?.event_data, key)
1457
+ const strVal = rawVal === null || rawVal === undefined ? '' : String(rawVal)
1458
+ counts.set(strVal, (counts.get(strVal) || 0) + 1)
1459
+ if (matchesInterest(rawVal, op === 'contains' ? 'contains' : op, expected)) match++
1460
+ }
1461
+
1462
+ if (occType === 'at_least') {
1463
+ return match > 0 && match / total >= threshold
1464
+ }
1465
+
1466
+ let maxOther = 0
1467
+ counts.forEach((cnt, valStr) => {
1468
+ if (!matchesInterest(valStr, op, expected)) {
1469
+ if (cnt > maxOther) maxOther = cnt
1470
+ }
1471
+ })
1472
+
1473
+ return match > 0 && match >= maxOther
1474
+ }
1475
+
804
1476
  // Frequência / agregação (para 1 contato)
805
- const rows = data || []
806
1477
  if (rule.frequency && rule.frequency.value != null) {
807
- const { op, value, type = 'count', field = 'value' } = rule.frequency
1478
+ const { op, value, value2, type = 'count', field = 'value' } = rule.frequency
808
1479
  let aggregatedValue = 0
1480
+ let denom = 0
809
1481
  if (type === 'count') {
810
1482
  aggregatedValue = rows.length
811
1483
  } else {
812
1484
  for (const row of rows) {
813
1485
  const eventData = row.event_data || {}
814
- let numVal = 0
815
- if (eventData[field] !== undefined) numVal = Number(eventData[field])
816
- else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
817
- else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
818
- if (!Number.isNaN(numVal)) aggregatedValue += numVal
1486
+ const raw =
1487
+ getEventPropertyValue(eventData, field) ??
1488
+ (eventData?.[field] !== undefined ? eventData[field] : undefined) ??
1489
+ eventData?.value ??
1490
+ eventData?.amount
1491
+ const numVal = Number(raw)
1492
+ if (Number.isFinite(numVal)) {
1493
+ aggregatedValue += numVal
1494
+ denom += 1
1495
+ }
819
1496
  }
820
- if (type === 'avg') aggregatedValue = rows.length > 0 ? aggregatedValue / rows.length : 0
1497
+ if (type === 'avg') aggregatedValue = denom > 0 ? aggregatedValue / denom : 0
1498
+ }
1499
+
1500
+ if (op === 'between') {
1501
+ const a = Number(value)
1502
+ const b = Number(value2)
1503
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return false
1504
+ // Compatível com CleverTap: se o usuário inverter (min > max), não deve casar.
1505
+ if (a > b) return false
1506
+ return aggregatedValue >= a && aggregatedValue <= b
821
1507
  }
822
1508
 
823
1509
  return (