@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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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 =
|
|
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 (
|