@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 = (
|
|
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)
|
|
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
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
|
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
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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 =
|
|
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 (
|