@reachy/audience-module 1.0.13 → 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.
@@ -33,6 +33,93 @@ const getEventPropertyValue = (eventData: any, keyRaw: any): any => {
33
33
  return getByPath(ed, key) ?? getByPath(ed?.custom_data, key)
34
34
  }
35
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
+
36
123
  const matchesInterest = (raw: any, op: string, expected: any): boolean => {
37
124
  if (raw === null || raw === undefined) return false
38
125
  const a = String(raw).trim()
@@ -98,8 +185,13 @@ const matchesEventPropertyFilter = (raw: any, opRaw: any, expected: any): boolea
98
185
  return false
99
186
  }
100
187
 
101
- const applyEventRuleFilters = (rows: any[], filters: any[]): any[] => {
188
+ const applyEventRuleFilters = (
189
+ rows: any[],
190
+ filters: any[],
191
+ opts?: { timezone?: string }
192
+ ): any[] => {
102
193
  let out = rows
194
+ const tz = String(opts?.timezone || '').trim()
103
195
  for (const f of filters) {
104
196
  const type = String(f?.type || '').trim()
105
197
  if (type === 'event_property') {
@@ -108,6 +200,49 @@ const applyEventRuleFilters = (rows: any[], filters: any[]): any[] => {
108
200
  const op = f?.op
109
201
  const value = f?.value
110
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
+ })
111
246
  }
112
247
  }
113
248
  return out
@@ -137,6 +272,24 @@ export class V2AudienceEngine {
137
272
  ): Promise<Set<string>> {
138
273
  let criteria: AudienceCriteria = CriteriaParser.parse(criteriaRaw as any) as any
139
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
+
140
293
  // Compat: permitir critérios legados (filters/conditions) sem groups,
141
294
  // convertendo para groups V2 para que o engine avalie sozinho.
142
295
  if (
@@ -169,12 +322,16 @@ export class V2AudienceEngine {
169
322
 
170
323
  const allContactIds = new Set<string>((allContacts || []).map((c: any) => c.id as string))
171
324
  const reachyIdToContactId = new Map<string, string>()
325
+ const contactIdToReachyId = new Map<string, string>()
172
326
  const emailToContactId = new Map<string, string>()
173
327
 
174
328
  for (const c of allContacts || []) {
175
329
  const rid = c?.reachy_id ? String(c.reachy_id).trim() : ''
176
330
  const cid = c?.id ? String(c.id).trim() : ''
177
- if (rid && cid) reachyIdToContactId.set(rid, cid)
331
+ if (rid && cid) {
332
+ reachyIdToContactId.set(rid, cid)
333
+ contactIdToReachyId.set(cid, rid)
334
+ }
178
335
  const email = c?.email ? String(c.email).trim().toLowerCase() : ''
179
336
  if (email && cid) emailToContactId.set(email, cid)
180
337
  }
@@ -405,9 +562,167 @@ export class V2AudienceEngine {
405
562
 
406
563
  // Event advanced filters (ex.: event_property dentro do DID)
407
564
  let rows = (data || []) as any[]
408
- const ruleFilters = Array.isArray((rule as any).filters) ? (rule as any).filters : []
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
+
409
573
  if (ruleFilters.length > 0) {
410
- rows = applyEventRuleFilters(rows, ruleFilters)
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
+ })
411
726
  }
412
727
 
413
728
  // User Interests: predominantly / at least X% of the time
@@ -473,8 +788,9 @@ export class V2AudienceEngine {
473
788
 
474
789
  // Frequência / agregação
475
790
  if (rule.frequency && rule.frequency.value != null) {
476
- const { op, value, type = 'count', field = 'value' } = rule.frequency
791
+ const { op, value, value2, type = 'count', field = 'value' } = rule.frequency
477
792
  const counts = new Map<string, number>()
793
+ const denoms = new Map<string, number>() // usado para avg (ignora eventos sem número)
478
794
 
479
795
  for (const row of rows) {
480
796
  const id = resolveEventContactId(row)
@@ -483,14 +799,17 @@ export class V2AudienceEngine {
483
799
  if (type === 'count') {
484
800
  counts.set(id, (counts.get(id) || 0) + 1)
485
801
  } else if (type === 'sum' || type === 'avg') {
486
- let numVal = 0
487
802
  const eventData = row.event_data || {}
488
- if (eventData[field] !== undefined) numVal = Number(eventData[field])
489
- else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
490
- else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
491
-
492
- 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)) {
493
811
  counts.set(id, (counts.get(id) || 0) + numVal)
812
+ if (type === 'avg') denoms.set(id, (denoms.get(id) || 0) + 1)
494
813
  }
495
814
  }
496
815
  }
@@ -499,21 +818,28 @@ export class V2AudienceEngine {
499
818
  counts.forEach((aggregatedValue, id) => {
500
819
  let finalValue = aggregatedValue
501
820
  if (type === 'avg') {
502
- let eventCount = 0
503
- for (const row of rows) {
504
- const rid = resolveEventContactId(row)
505
- if (rid === id) eventCount++
506
- }
507
- finalValue = eventCount > 0 ? aggregatedValue / eventCount : 0
821
+ const denom = denoms.get(id) || 0
822
+ finalValue = denom > 0 ? aggregatedValue / denom : 0
508
823
  }
509
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
+
510
835
  if (
511
836
  (op === '>=' && finalValue >= value) ||
512
837
  (op === '>' && finalValue > value) ||
513
838
  (op === '=' && finalValue === value) ||
514
839
  (op === '!=' && finalValue !== value) ||
515
840
  (op === '<=' && finalValue <= value) ||
516
- (op === '<' && finalValue < value)
841
+ (op === '<' && finalValue < value) ||
842
+ betweenOk
517
843
  ) {
518
844
  res.add(id)
519
845
  }
@@ -795,6 +1121,23 @@ export class V2AudienceEngine {
795
1121
 
796
1122
  const typeId = String((criteria as any)?.type || '')
797
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
+
798
1141
  // Carregar identidade do contato (para reachy_id)
799
1142
  const { data: contactRow } = await this.supabase
800
1143
  .from('contacts')
@@ -989,9 +1332,108 @@ export class V2AudienceEngine {
989
1332
 
990
1333
  // Event advanced filters (ex.: event_property dentro do DID)
991
1334
  let rows = (data || []) as any[]
992
- const ruleFilters = Array.isArray((rule as any).filters) ? (rule as any).filters : []
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
+
993
1343
  if (ruleFilters.length > 0) {
994
- rows = applyEventRuleFilters(rows, ruleFilters)
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
+ }
995
1437
  }
996
1438
 
997
1439
  // User Interests: predominantly / at least X% of the time (para 1 contato)
@@ -1033,20 +1475,35 @@ export class V2AudienceEngine {
1033
1475
 
1034
1476
  // Frequência / agregação (para 1 contato)
1035
1477
  if (rule.frequency && rule.frequency.value != null) {
1036
- const { op, value, type = 'count', field = 'value' } = rule.frequency
1478
+ const { op, value, value2, type = 'count', field = 'value' } = rule.frequency
1037
1479
  let aggregatedValue = 0
1480
+ let denom = 0
1038
1481
  if (type === 'count') {
1039
1482
  aggregatedValue = rows.length
1040
1483
  } else {
1041
1484
  for (const row of rows) {
1042
1485
  const eventData = row.event_data || {}
1043
- let numVal = 0
1044
- if (eventData[field] !== undefined) numVal = Number(eventData[field])
1045
- else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
1046
- else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
1047
- 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
+ }
1048
1496
  }
1049
- 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
1050
1507
  }
1051
1508
 
1052
1509
  return (