@reachy/audience-module 1.0.3 → 1.0.5
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.
- package/.gitlab-ci.yml +2 -2
- package/dist/AudienceModule.d.ts +1 -0
- package/dist/AudienceModule.d.ts.map +1 -1
- package/dist/AudienceModule.js +24 -2
- package/dist/AudienceModule.js.map +1 -1
- package/dist/builders/CriteriaParser.d.ts.map +1 -1
- package/dist/builders/CriteriaParser.js +0 -1
- package/dist/builders/CriteriaParser.js.map +1 -1
- package/dist/engine/V2AudienceEngine.d.ts +20 -0
- package/dist/engine/V2AudienceEngine.d.ts.map +1 -0
- package/dist/engine/V2AudienceEngine.js +1211 -0
- package/dist/engine/V2AudienceEngine.js.map +1 -0
- package/dist/executors/StaticAudienceExecutor.d.ts.map +1 -1
- package/dist/executors/StaticAudienceExecutor.js +0 -2
- package/dist/executors/StaticAudienceExecutor.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/repositories/SupabaseContactRepository.d.ts +16 -0
- package/dist/repositories/SupabaseContactRepository.d.ts.map +1 -0
- package/dist/repositories/SupabaseContactRepository.js +33 -0
- package/dist/repositories/SupabaseContactRepository.js.map +1 -0
- package/dist/types/index.d.ts +36 -4
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/AudienceModule.ts +50 -2
- package/src/builders/CriteriaParser.ts +0 -1
- package/src/engine/V2AudienceEngine.ts +1200 -0
- package/src/executors/StaticAudienceExecutor.ts +0 -2
- package/src/index.ts +2 -0
- package/src/repositories/SupabaseContactRepository.ts +50 -0
- package/src/types/index.ts +40 -4
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
import { CriteriaParser } from '../builders/CriteriaParser'
|
|
2
|
+
import { AudienceCriteria } from '../types'
|
|
3
|
+
|
|
4
|
+
type Logger = {
|
|
5
|
+
log: (...args: any[]) => void
|
|
6
|
+
warn: (...args: any[]) => void
|
|
7
|
+
error: (...args: any[]) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Engine V2 (groups/rules) portado do reachy-api para o audience-module.
|
|
12
|
+
*
|
|
13
|
+
* Objetivo: permitir que o módulo execute a filtragem sozinho (sem depender do ContactRepository do reachy-api),
|
|
14
|
+
* precisando apenas de um client Supabase compatível (com .from/.select/.eq/.gte/.lte/.lt/.gt/.in/.or/.filter).
|
|
15
|
+
*/
|
|
16
|
+
export class V2AudienceEngine {
|
|
17
|
+
private supabase: any
|
|
18
|
+
private debug: boolean
|
|
19
|
+
private logger: Logger
|
|
20
|
+
|
|
21
|
+
constructor(params: { supabaseClient: any; debug?: boolean; logger?: Logger }) {
|
|
22
|
+
this.supabase = params.supabaseClient
|
|
23
|
+
this.debug = !!params.debug
|
|
24
|
+
this.logger = params.logger || console
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getContactIdsByAudienceCriteriaV2(
|
|
28
|
+
organizationId: string,
|
|
29
|
+
projectId: string,
|
|
30
|
+
criteriaRaw: any
|
|
31
|
+
): Promise<Set<string>> {
|
|
32
|
+
let criteria: AudienceCriteria = CriteriaParser.parse(criteriaRaw as any) as any
|
|
33
|
+
|
|
34
|
+
// Compat: permitir critérios legados (filters/conditions) sem groups,
|
|
35
|
+
// convertendo para groups V2 para que o engine avalie sozinho.
|
|
36
|
+
if (
|
|
37
|
+
(!Array.isArray((criteria as any).groups) || (criteria as any).groups.length === 0) &&
|
|
38
|
+
(Array.isArray((criteria as any).filters) || Array.isArray((criteria as any).conditions))
|
|
39
|
+
) {
|
|
40
|
+
criteria = coerceCriteriaToGroups(criteria)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.dlog('start', {
|
|
44
|
+
organizationId,
|
|
45
|
+
projectId,
|
|
46
|
+
type: String((criteria as any)?.type || ''),
|
|
47
|
+
groups: Array.isArray((criteria as any)?.groups) ? (criteria as any).groups.length : 0
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!criteria || !Array.isArray((criteria as any).groups) || (criteria as any).groups.length === 0) {
|
|
51
|
+
this.dlog('V2 Engine: no groups; returning empty set')
|
|
52
|
+
return new Set<string>()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const typeId = String((criteria as any)?.type || '')
|
|
56
|
+
const isPastBehavior = typeId === 'past-behavior'
|
|
57
|
+
const rawAsOf = ((criteria as any)?.asOf ?? (criteria as any)?.as_of ?? (criteria as any)?.frozenAt ?? (criteria as any)?.frozen_at) as any
|
|
58
|
+
const asOfDate = isPastBehavior && typeof rawAsOf === 'string' && rawAsOf.trim() ? new Date(rawAsOf) : null
|
|
59
|
+
const asOf =
|
|
60
|
+
isPastBehavior && asOfDate && !Number.isNaN(asOfDate.getTime())
|
|
61
|
+
? asOfDate
|
|
62
|
+
: null
|
|
63
|
+
const asOfIso = asOf ? asOf.toISOString() : null
|
|
64
|
+
|
|
65
|
+
// Carregar contatos do projeto (necessário para negate e mapeamento de identidade)
|
|
66
|
+
const { data: allContacts } = await this.supabase
|
|
67
|
+
.from('contacts')
|
|
68
|
+
.select('id, reachy_id, email')
|
|
69
|
+
.eq('organization_id', organizationId)
|
|
70
|
+
.eq('project_id', projectId)
|
|
71
|
+
|
|
72
|
+
const allContactIds = new Set<string>((allContacts || []).map((c: any) => c.id as string))
|
|
73
|
+
const reachyIdToContactId = new Map<string, string>()
|
|
74
|
+
const emailToContactId = new Map<string, string>()
|
|
75
|
+
|
|
76
|
+
for (const c of allContacts || []) {
|
|
77
|
+
const rid = c?.reachy_id ? String(c.reachy_id).trim() : ''
|
|
78
|
+
const cid = c?.id ? String(c.id).trim() : ''
|
|
79
|
+
if (rid && cid) reachyIdToContactId.set(rid, cid)
|
|
80
|
+
const email = c?.email ? String(c.email).trim().toLowerCase() : ''
|
|
81
|
+
if (email && cid) emailToContactId.set(email, cid)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const union = (a: Set<string>, b: Set<string>) => new Set([...a, ...b])
|
|
85
|
+
const intersect = (a: Set<string>, b: Set<string>) => new Set([...a].filter(x => b.has(x)))
|
|
86
|
+
const diff = (a: Set<string>, b: Set<string>) => new Set([...a].filter(x => !b.has(x)))
|
|
87
|
+
|
|
88
|
+
const resolveEventContactId = (row: any): string | null => {
|
|
89
|
+
const rawReachyId = row?.reachy_id ? String(row.reachy_id).trim() : ''
|
|
90
|
+
if (rawReachyId) {
|
|
91
|
+
const mapped = reachyIdToContactId.get(rawReachyId)
|
|
92
|
+
if (mapped) return mapped
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rawContactId = row?.contact_id ? String(row.contact_id).trim() : ''
|
|
96
|
+
if (rawContactId && allContactIds.has(rawContactId)) return rawContactId
|
|
97
|
+
|
|
98
|
+
const ed = row?.event_data || {}
|
|
99
|
+
const emailCandidates = [ed.email, ed.user_email, ed.userEmail, ed.contact_email, ed.contactEmail]
|
|
100
|
+
for (const e of emailCandidates) {
|
|
101
|
+
if (typeof e === 'string') {
|
|
102
|
+
const normalized = e.trim().toLowerCase()
|
|
103
|
+
if (!normalized) continue
|
|
104
|
+
const mapped = emailToContactId.get(normalized)
|
|
105
|
+
if (mapped) return mapped
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const evalEventRule = async (rule: any): Promise<Set<string>> => {
|
|
112
|
+
const cfg = (criteria as any)?.config || {}
|
|
113
|
+
const effectiveEventName =
|
|
114
|
+
cfg.eventType && String(cfg.eventType).trim() !== ''
|
|
115
|
+
? String(cfg.eventType)
|
|
116
|
+
: String(rule.eventName)
|
|
117
|
+
|
|
118
|
+
let query = this.supabase
|
|
119
|
+
.from('contact_events')
|
|
120
|
+
.select('contact_id, reachy_id, event_data, event_timestamp, event_name')
|
|
121
|
+
.eq('organization_id', organizationId)
|
|
122
|
+
.eq('project_id', projectId)
|
|
123
|
+
.eq('event_name', effectiveEventName)
|
|
124
|
+
|
|
125
|
+
if (isPastBehavior && asOfIso) {
|
|
126
|
+
query = query.lte('event_timestamp', asOfIso)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// live presets helpers (mantido por paridade com reachy-api)
|
|
130
|
+
if (typeId === 'live-page-visit') {
|
|
131
|
+
const v = cfg.pageUrl
|
|
132
|
+
const d = cfg.domain
|
|
133
|
+
const ors: string[] = []
|
|
134
|
+
if (v && String(v).trim() !== '') {
|
|
135
|
+
const like = `%${String(v)}%`
|
|
136
|
+
ors.push(`path.ilike.${like}`)
|
|
137
|
+
ors.push(`current_url.ilike.${like}`)
|
|
138
|
+
}
|
|
139
|
+
if (d && String(d).trim() !== '') {
|
|
140
|
+
const dlike = `%${String(d)}%`
|
|
141
|
+
ors.push(`domain.ilike.${dlike}`)
|
|
142
|
+
ors.push(`event_data->>domain.ilike.${dlike}`)
|
|
143
|
+
}
|
|
144
|
+
if (ors.length > 0) query = query.or(ors.join(','))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeId === 'live-referrer') {
|
|
148
|
+
query = query.eq('session_is_new', true)
|
|
149
|
+
const ors: string[] = []
|
|
150
|
+
const v = cfg.referrerUrl
|
|
151
|
+
if (v && String(v).trim() !== '') ors.push(`referrer.ilike.%${String(v)}%`)
|
|
152
|
+
if (cfg.utm_source && String(cfg.utm_source).trim() !== '') ors.push(`event_data->>utm_source.ilike.%${String(cfg.utm_source)}%`)
|
|
153
|
+
if (cfg.utm_medium && String(cfg.utm_medium).trim() !== '') ors.push(`event_data->>utm_medium.ilike.%${String(cfg.utm_medium)}%`)
|
|
154
|
+
if (cfg.utm_campaign && String(cfg.utm_campaign).trim() !== '') ors.push(`event_data->>utm_campaign.ilike.%${String(cfg.utm_campaign)}%`)
|
|
155
|
+
if (cfg.utm_term && String(cfg.utm_term).trim() !== '') ors.push(`event_data->>utm_term.ilike.%${String(cfg.utm_term)}%`)
|
|
156
|
+
if (cfg.utm_content && String(cfg.utm_content).trim() !== '') ors.push(`event_data->>utm_content.ilike.%${String(cfg.utm_content)}%`)
|
|
157
|
+
if (ors.length > 0) query = query.or(ors.join(','))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// TimeFrame via config (fallback)
|
|
161
|
+
if (!rule.time && cfg && cfg.timeFrame) {
|
|
162
|
+
const tf = String(cfg.timeFrame).trim()
|
|
163
|
+
const now = asOf ?? new Date()
|
|
164
|
+
let unit: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' = 'days'
|
|
165
|
+
let value = 7
|
|
166
|
+
if (/^\d+$/.test(tf)) { value = Number(tf); unit = 'days' }
|
|
167
|
+
else if (/^\d+\s*m$/.test(tf)) { value = Number(tf.replace(/m$/, '')); unit = 'minutes' }
|
|
168
|
+
else if (/^\d+\s*h$/.test(tf)) { value = Number(tf.replace(/h$/, '')); unit = 'hours' }
|
|
169
|
+
else if (/^\d+\s*d$/.test(tf)) { value = Number(tf.replace(/d$/, '')); unit = 'days' }
|
|
170
|
+
else if (/^\d+\s*w$/.test(tf)) { value = Number(tf.replace(/w$/, '')); unit = 'weeks' }
|
|
171
|
+
else if (/^\d+\s*mo$/.test(tf)) { value = Number(tf.replace(/mo$/, '')); unit = 'months' }
|
|
172
|
+
const units: Record<typeof unit, number> = {
|
|
173
|
+
minutes: 60 * 1000,
|
|
174
|
+
hours: 60 * 60 * 1000,
|
|
175
|
+
days: 24 * 60 * 60 * 1000,
|
|
176
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
177
|
+
months: 30 * 24 * 60 * 60 * 1000
|
|
178
|
+
}
|
|
179
|
+
const from = new Date(now.getTime() - value * units[unit])
|
|
180
|
+
query = query.gte('event_timestamp', from.toISOString())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Janela temporal explícita (relativa ou absoluta)
|
|
184
|
+
if (rule.time) {
|
|
185
|
+
if (rule.time.unit && rule.time.value) {
|
|
186
|
+
type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'
|
|
187
|
+
const unit = String(rule.time.unit) as TimeUnit
|
|
188
|
+
const value = Number(rule.time.value)
|
|
189
|
+
const now = asOf ?? new Date()
|
|
190
|
+
const units: Record<TimeUnit, number> = {
|
|
191
|
+
minutes: 60 * 1000,
|
|
192
|
+
hours: 60 * 60 * 1000,
|
|
193
|
+
days: 24 * 60 * 60 * 1000,
|
|
194
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
195
|
+
months: 30 * 24 * 60 * 60 * 1000
|
|
196
|
+
}
|
|
197
|
+
const ms = units[unit] ?? units['days']
|
|
198
|
+
const from = new Date(now.getTime() - value * ms)
|
|
199
|
+
query = query.gte('event_timestamp', from.toISOString())
|
|
200
|
+
} else {
|
|
201
|
+
if (rule.time.from) {
|
|
202
|
+
const rawFrom = rule.time.from
|
|
203
|
+
const fromStr = typeof rawFrom === 'string' ? rawFrom : String(rawFrom)
|
|
204
|
+
const isDateOnly = typeof rawFrom === 'string' && !fromStr.includes('T')
|
|
205
|
+
const startIso = isDateOnly ? normalizeDateBoundary(fromStr, 'start') : undefined
|
|
206
|
+
const finalFrom = startIso || fromStr
|
|
207
|
+
query = query.gte('event_timestamp', finalFrom)
|
|
208
|
+
}
|
|
209
|
+
if (rule.time.to) {
|
|
210
|
+
const rawTo = rule.time.to
|
|
211
|
+
const toStr = typeof rawTo === 'string' ? rawTo : String(rawTo)
|
|
212
|
+
const isDateOnly = typeof rawTo === 'string' && !toStr.includes('T')
|
|
213
|
+
const endIsoExclusive = isDateOnly ? normalizeDateBoundary(toStr, 'end') : undefined
|
|
214
|
+
const finalTo = endIsoExclusive || toStr
|
|
215
|
+
query = isDateOnly ? query.lt('event_timestamp', finalTo) : query.lte('event_timestamp', finalTo)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Event attributes: attributes: [{ key, op, value }]
|
|
221
|
+
try {
|
|
222
|
+
const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
|
|
223
|
+
|
|
224
|
+
const trackerEvents = new Set([
|
|
225
|
+
'click',
|
|
226
|
+
'page_view',
|
|
227
|
+
'form_submit',
|
|
228
|
+
'time_on_page',
|
|
229
|
+
'heartbeat',
|
|
230
|
+
'scroll_depth',
|
|
231
|
+
'scroll_depth_snapshot'
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
const resolveDbFieldForAttrKey = (keyRaw: string) => {
|
|
235
|
+
const key = String(keyRaw || '').trim()
|
|
236
|
+
if (!key) return null
|
|
237
|
+
|
|
238
|
+
const columnKeys = new Set([
|
|
239
|
+
'current_url',
|
|
240
|
+
'domain',
|
|
241
|
+
'path',
|
|
242
|
+
'referrer',
|
|
243
|
+
'page_title',
|
|
244
|
+
'utm_source',
|
|
245
|
+
'utm_medium',
|
|
246
|
+
'utm_campaign',
|
|
247
|
+
'utm_term',
|
|
248
|
+
'utm_content',
|
|
249
|
+
'session_id',
|
|
250
|
+
'session_is_new'
|
|
251
|
+
])
|
|
252
|
+
if (columnKeys.has(key)) return { kind: 'column', field: key }
|
|
253
|
+
|
|
254
|
+
const buildNested = (root: string, dottedPath: string) => jsonNestedTextAccessor(root, dottedPath)
|
|
255
|
+
|
|
256
|
+
if (key.startsWith('event_data.')) return { kind: 'json', field: buildNested('event_data', key.replace(/^event_data\./, '')) }
|
|
257
|
+
if (key.startsWith('custom_data.')) return { kind: 'json', field: buildNested('event_data->custom_data', key.replace(/^custom_data\./, '')) }
|
|
258
|
+
if (key.startsWith('url_data.')) return { kind: 'json', field: buildNested('event_data->url_data', key.replace(/^url_data\./, '')) }
|
|
259
|
+
if (key.startsWith('utm_data.')) return { kind: 'json', field: buildNested('event_data->utm_data', key.replace(/^utm_data\./, '')) }
|
|
260
|
+
if (key.startsWith('session_data.')) return { kind: 'json', field: buildNested('event_data->session_data', key.replace(/^session_data\./, '')) }
|
|
261
|
+
|
|
262
|
+
if (key.includes('.')) return { kind: 'json', field: buildNested('event_data->custom_data', key) }
|
|
263
|
+
|
|
264
|
+
if (trackerEvents.has(effectiveEventName)) return { kind: 'json', field: jsonTextAccessor('event_data->custom_data', key) }
|
|
265
|
+
return { kind: 'json', field: jsonTextAccessor('event_data', key) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const attr of attributes) {
|
|
269
|
+
const key = String(attr.key || '').trim()
|
|
270
|
+
const op = String(attr.op || 'contains')
|
|
271
|
+
const value = attr.value
|
|
272
|
+
if (!key || value == null) continue
|
|
273
|
+
|
|
274
|
+
const resolved = resolveDbFieldForAttrKey(key)
|
|
275
|
+
if (!resolved) continue
|
|
276
|
+
const dbField = resolved.field
|
|
277
|
+
|
|
278
|
+
switch (op) {
|
|
279
|
+
case 'equals':
|
|
280
|
+
query = query.filter(dbField, 'eq', value)
|
|
281
|
+
break
|
|
282
|
+
case 'not_equals':
|
|
283
|
+
query = query.filter(dbField, 'neq', value)
|
|
284
|
+
break
|
|
285
|
+
case 'starts_with':
|
|
286
|
+
query = query.filter(dbField, 'ilike', `${value}%`)
|
|
287
|
+
break
|
|
288
|
+
case 'ends_with':
|
|
289
|
+
query = query.filter(dbField, 'ilike', `%${value}`)
|
|
290
|
+
break
|
|
291
|
+
case 'contains':
|
|
292
|
+
default:
|
|
293
|
+
query = query.filter(dbField, 'ilike', `%${value}%`)
|
|
294
|
+
break
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch (e) {
|
|
298
|
+
// Evitar logar objetos/valores (podem conter PII). Se debug estiver ligado, logar apenas um aviso genérico.
|
|
299
|
+
if (this.debug) this.logger.warn('[AUDIENCE_MODULE_ENGINE] event attributes filter error')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { data, error } = await query
|
|
303
|
+
if (error) return new Set<string>()
|
|
304
|
+
|
|
305
|
+
// Frequência / agregação
|
|
306
|
+
if (rule.frequency && rule.frequency.value != null) {
|
|
307
|
+
const { op, value, type = 'count', field = 'value' } = rule.frequency
|
|
308
|
+
const counts = new Map<string, number>()
|
|
309
|
+
|
|
310
|
+
for (const row of data || []) {
|
|
311
|
+
const id = resolveEventContactId(row)
|
|
312
|
+
if (!id) continue
|
|
313
|
+
|
|
314
|
+
if (type === 'count') {
|
|
315
|
+
counts.set(id, (counts.get(id) || 0) + 1)
|
|
316
|
+
} else if (type === 'sum' || type === 'avg') {
|
|
317
|
+
let numVal = 0
|
|
318
|
+
const eventData = row.event_data || {}
|
|
319
|
+
if (eventData[field] !== undefined) numVal = Number(eventData[field])
|
|
320
|
+
else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
|
|
321
|
+
else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
|
|
322
|
+
|
|
323
|
+
if (!Number.isNaN(numVal)) {
|
|
324
|
+
counts.set(id, (counts.get(id) || 0) + numVal)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const res = new Set<string>()
|
|
330
|
+
counts.forEach((aggregatedValue, id) => {
|
|
331
|
+
let finalValue = aggregatedValue
|
|
332
|
+
if (type === 'avg') {
|
|
333
|
+
let eventCount = 0
|
|
334
|
+
for (const row of data || []) {
|
|
335
|
+
const rid = resolveEventContactId(row)
|
|
336
|
+
if (rid === id) eventCount++
|
|
337
|
+
}
|
|
338
|
+
finalValue = eventCount > 0 ? aggregatedValue / eventCount : 0
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
(op === '>=' && finalValue >= value) ||
|
|
343
|
+
(op === '>' && finalValue > value) ||
|
|
344
|
+
(op === '=' && finalValue === value) ||
|
|
345
|
+
(op === '<=' && finalValue <= value) ||
|
|
346
|
+
(op === '<' && finalValue < value)
|
|
347
|
+
) {
|
|
348
|
+
res.add(id)
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
return res
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// live-page-count: threshold
|
|
355
|
+
if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
|
|
356
|
+
const threshold = Number(cfg.pageCount)
|
|
357
|
+
const counts = new Map<string, number>()
|
|
358
|
+
for (const row of data || []) {
|
|
359
|
+
const id = resolveEventContactId(row)
|
|
360
|
+
if (!id) continue
|
|
361
|
+
counts.set(id, (counts.get(id) || 0) + 1)
|
|
362
|
+
}
|
|
363
|
+
const res = new Set<string>()
|
|
364
|
+
counts.forEach((cnt, id) => { if (cnt >= threshold) res.add(id) })
|
|
365
|
+
return res
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const res = new Set<string>()
|
|
369
|
+
for (const row of data || []) {
|
|
370
|
+
const id = resolveEventContactId(row)
|
|
371
|
+
if (id) res.add(id)
|
|
372
|
+
}
|
|
373
|
+
return res
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const evalPropertyRule = async (rule: any): Promise<Set<string>> => {
|
|
377
|
+
let query = this.supabase
|
|
378
|
+
.from('contacts')
|
|
379
|
+
.select('id')
|
|
380
|
+
.eq('organization_id', organizationId)
|
|
381
|
+
.eq('project_id', projectId)
|
|
382
|
+
|
|
383
|
+
if (isPastBehavior && asOfIso) {
|
|
384
|
+
query = query.lte('created_at', asOfIso)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const field = rule.field as string
|
|
388
|
+
const op = rule.op as string
|
|
389
|
+
const value = rule.value
|
|
390
|
+
const value2 = rule.value2
|
|
391
|
+
|
|
392
|
+
const computeRelativeRange = (direction: 'past' | 'future' = 'past'): { start: string; end: string } | null => {
|
|
393
|
+
const unitSource = rule.timeUnit ?? rule.unit ?? 'days'
|
|
394
|
+
const rawUnit = String(unitSource).toLowerCase()
|
|
395
|
+
const numericValue = Number(rule.timeValue ?? rule.value ?? NaN)
|
|
396
|
+
if (!Number.isFinite(numericValue) || numericValue < 0) return null
|
|
397
|
+
|
|
398
|
+
const units: Record<string, number> = {
|
|
399
|
+
minutes: 60 * 1000,
|
|
400
|
+
hours: 60 * 60 * 1000,
|
|
401
|
+
days: 24 * 60 * 60 * 1000,
|
|
402
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
403
|
+
months: 30 * 24 * 60 * 60 * 1000,
|
|
404
|
+
years: 365 * 24 * 60 * 60 * 1000
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const now = asOf ?? new Date()
|
|
408
|
+
const baseUnitMs: number = units[rawUnit] ?? units['days'] ?? 24 * 60 * 60 * 1000
|
|
409
|
+
const delta = baseUnitMs * numericValue
|
|
410
|
+
const start = direction === 'past' ? new Date(now.getTime() - delta) : now
|
|
411
|
+
const end = direction === 'past' ? now : new Date(now.getTime() + delta)
|
|
412
|
+
return { start: start.toISOString(), end: end.toISOString() }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const apply = (dbField: string, isJsonb: boolean = false) => {
|
|
416
|
+
switch (op) {
|
|
417
|
+
case 'equals':
|
|
418
|
+
if (field === 'created_at') {
|
|
419
|
+
const startIso = normalizeDateBoundary(value, 'start')
|
|
420
|
+
const endIsoExclusive = normalizeDateBoundary(value, 'end')
|
|
421
|
+
if (startIso) query = query.gte(dbField, startIso)
|
|
422
|
+
if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
|
|
423
|
+
} else {
|
|
424
|
+
query = query.eq(dbField, value)
|
|
425
|
+
}
|
|
426
|
+
break
|
|
427
|
+
case 'not_equals':
|
|
428
|
+
if (isJsonb && value === '') {
|
|
429
|
+
query = query.not(dbField, 'is', null).neq(dbField, '')
|
|
430
|
+
} else {
|
|
431
|
+
query = query.neq(dbField, value)
|
|
432
|
+
}
|
|
433
|
+
break
|
|
434
|
+
case 'contains':
|
|
435
|
+
query = query.ilike(dbField, `%${value}%`)
|
|
436
|
+
break
|
|
437
|
+
case 'not_contains':
|
|
438
|
+
query = query.not(dbField, 'ilike', `%${value}%`)
|
|
439
|
+
break
|
|
440
|
+
case 'starts_with':
|
|
441
|
+
query = query.ilike(dbField, `${value}%`)
|
|
442
|
+
break
|
|
443
|
+
case 'ends_with':
|
|
444
|
+
query = query.ilike(dbField, `%${value}`)
|
|
445
|
+
break
|
|
446
|
+
case '>':
|
|
447
|
+
query = query.gt(dbField, value)
|
|
448
|
+
break
|
|
449
|
+
case '>=':
|
|
450
|
+
query = query.gte(dbField, value)
|
|
451
|
+
break
|
|
452
|
+
case '<':
|
|
453
|
+
query = query.lt(dbField, value)
|
|
454
|
+
break
|
|
455
|
+
case '<=':
|
|
456
|
+
query = query.lte(dbField, value)
|
|
457
|
+
break
|
|
458
|
+
case 'between':
|
|
459
|
+
if (field === 'created_at') {
|
|
460
|
+
const startIso = normalizeDateBoundary(value, 'start')
|
|
461
|
+
const endIsoExclusive = normalizeDateBoundary(value2 ?? value, 'end')
|
|
462
|
+
if (startIso) query = query.gte(dbField, startIso)
|
|
463
|
+
if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
|
|
464
|
+
} else {
|
|
465
|
+
if (value != null) query = query.gte(dbField, value)
|
|
466
|
+
if (value2 != null) query = query.lte(dbField, value2)
|
|
467
|
+
}
|
|
468
|
+
break
|
|
469
|
+
case 'after':
|
|
470
|
+
if (field === 'created_at') {
|
|
471
|
+
const startIso = normalizeDateBoundary(value, 'start')
|
|
472
|
+
if (startIso) query = query.gte(dbField, startIso)
|
|
473
|
+
} else {
|
|
474
|
+
query = query.gt(dbField, value)
|
|
475
|
+
}
|
|
476
|
+
break
|
|
477
|
+
case 'before':
|
|
478
|
+
if (field === 'created_at') {
|
|
479
|
+
const endIsoExclusive = normalizeDateBoundary(value, 'end')
|
|
480
|
+
if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
|
|
481
|
+
} else {
|
|
482
|
+
query = query.lt(dbField, value)
|
|
483
|
+
}
|
|
484
|
+
break
|
|
485
|
+
case 'is_empty':
|
|
486
|
+
if (isJsonb) query = query.or(`${dbField}.is.null,${dbField}.eq.`)
|
|
487
|
+
else query = query.is(dbField, null)
|
|
488
|
+
break
|
|
489
|
+
case 'is_not_empty':
|
|
490
|
+
if (isJsonb) {
|
|
491
|
+
const parts = dbField.split('->>')
|
|
492
|
+
const propertyObj = (parts[0] || '').trim()
|
|
493
|
+
const keyName = field
|
|
494
|
+
if (propertyObj) {
|
|
495
|
+
query = query
|
|
496
|
+
.filter(`${propertyObj}`, 'cs', `{"${keyName}":`)
|
|
497
|
+
.not(dbField, 'is', null)
|
|
498
|
+
.neq(dbField, '')
|
|
499
|
+
} else {
|
|
500
|
+
query = query.not(dbField, 'is', null).neq(dbField, '')
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
query = query.not(dbField, 'is', null)
|
|
504
|
+
}
|
|
505
|
+
break
|
|
506
|
+
case 'is_true':
|
|
507
|
+
query = query.eq(dbField, true)
|
|
508
|
+
break
|
|
509
|
+
case 'is_false':
|
|
510
|
+
query = query.eq(dbField, false)
|
|
511
|
+
break
|
|
512
|
+
case 'in_the_last': {
|
|
513
|
+
const range = computeRelativeRange('past')
|
|
514
|
+
if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
|
|
515
|
+
break
|
|
516
|
+
}
|
|
517
|
+
case 'in_the_next': {
|
|
518
|
+
const range = computeRelativeRange('future')
|
|
519
|
+
if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const mappedField = mapAudienceFieldToContactField(field)
|
|
526
|
+
const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
|
|
527
|
+
|
|
528
|
+
if (known.includes(field) || known.includes(mappedField)) {
|
|
529
|
+
apply(mappedField, false)
|
|
530
|
+
} else {
|
|
531
|
+
const dbField = `properties->>${field}`
|
|
532
|
+
apply(dbField, true)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const { data, error } = await query
|
|
536
|
+
if (error) return new Set<string>()
|
|
537
|
+
return new Set<string>((data || []).map((r: any) => r.id as string))
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const evalGroup = async (group: any): Promise<Set<string>> => {
|
|
541
|
+
let acc: Set<string> | null = null
|
|
542
|
+
for (const rule of group.rules || []) {
|
|
543
|
+
const set = rule.kind === 'event' ? await evalEventRule(rule) : await evalPropertyRule(rule)
|
|
544
|
+
const shouldNegate = rule.negate
|
|
545
|
+
const s = shouldNegate ? diff(allContactIds, set) : set
|
|
546
|
+
|
|
547
|
+
if (acc == null) {
|
|
548
|
+
acc = s
|
|
549
|
+
} else {
|
|
550
|
+
if (group.operator === 'AND') acc = intersect(acc, s)
|
|
551
|
+
else if (group.operator === 'OR') acc = union(acc, s)
|
|
552
|
+
else if (group.operator === 'NOT') acc = diff(acc, s)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return acc || new Set<string>()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let result: Set<string> | null = null
|
|
559
|
+
for (let i = 0; i < (criteria as any).groups.length; i++) {
|
|
560
|
+
const group = (criteria as any).groups[i]
|
|
561
|
+
const gset = await evalGroup(group)
|
|
562
|
+
if (result == null) {
|
|
563
|
+
result = gset
|
|
564
|
+
} else {
|
|
565
|
+
if (group.operator === 'AND') result = intersect(result, gset)
|
|
566
|
+
else if (group.operator === 'OR') result = union(result, gset)
|
|
567
|
+
else if (group.operator === 'NOT') result = diff(result, gset)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const finalSet = result || new Set<string>()
|
|
572
|
+
this.dlog('done', { total: finalSet.size })
|
|
573
|
+
return finalSet
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Variante otimizada para avaliar apenas UM contato (useful para Journeys/Live).
|
|
578
|
+
* Retorna true se o contact_id atende ao critério.
|
|
579
|
+
*/
|
|
580
|
+
async matchesContactByAudienceCriteriaV2(
|
|
581
|
+
organizationId: string,
|
|
582
|
+
projectId: string,
|
|
583
|
+
criteriaRaw: any,
|
|
584
|
+
contactId: string
|
|
585
|
+
): Promise<boolean> {
|
|
586
|
+
let criteria: AudienceCriteria = CriteriaParser.parse(criteriaRaw as any) as any
|
|
587
|
+
|
|
588
|
+
if (
|
|
589
|
+
(!Array.isArray((criteria as any).groups) || (criteria as any).groups.length === 0) &&
|
|
590
|
+
(Array.isArray((criteria as any).filters) || Array.isArray((criteria as any).conditions))
|
|
591
|
+
) {
|
|
592
|
+
criteria = coerceCriteriaToGroups(criteria)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!criteria || !Array.isArray((criteria as any).groups) || (criteria as any).groups.length === 0) {
|
|
596
|
+
return false
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const typeId = String((criteria as any)?.type || '')
|
|
600
|
+
const isPastBehavior = typeId === 'past-behavior'
|
|
601
|
+
const rawAsOf = ((criteria as any)?.asOf ?? (criteria as any)?.as_of ?? (criteria as any)?.frozenAt ?? (criteria as any)?.frozen_at) as any
|
|
602
|
+
const asOfDate = isPastBehavior && typeof rawAsOf === 'string' && rawAsOf.trim() ? new Date(rawAsOf) : null
|
|
603
|
+
const asOf =
|
|
604
|
+
isPastBehavior && asOfDate && !Number.isNaN(asOfDate.getTime())
|
|
605
|
+
? asOfDate
|
|
606
|
+
: null
|
|
607
|
+
const asOfIso = asOf ? asOf.toISOString() : null
|
|
608
|
+
|
|
609
|
+
// Carregar identidade do contato (para reachy_id)
|
|
610
|
+
const { data: contactRow } = await this.supabase
|
|
611
|
+
.from('contacts')
|
|
612
|
+
.select('id, reachy_id, email')
|
|
613
|
+
.eq('organization_id', organizationId)
|
|
614
|
+
.eq('project_id', projectId)
|
|
615
|
+
.eq('id', contactId)
|
|
616
|
+
.maybeSingle()
|
|
617
|
+
|
|
618
|
+
const reachyId = contactRow?.reachy_id ? String(contactRow.reachy_id).trim() : ''
|
|
619
|
+
|
|
620
|
+
const evalEventRuleForContact = async (rule: any): Promise<boolean> => {
|
|
621
|
+
const cfg = (criteria as any)?.config || {}
|
|
622
|
+
const effectiveEventName =
|
|
623
|
+
cfg.eventType && String(cfg.eventType).trim() !== ''
|
|
624
|
+
? String(cfg.eventType)
|
|
625
|
+
: String(rule.eventName)
|
|
626
|
+
|
|
627
|
+
let query = this.supabase
|
|
628
|
+
.from('contact_events')
|
|
629
|
+
.select('contact_id, reachy_id, event_data, event_timestamp, event_name')
|
|
630
|
+
.eq('organization_id', organizationId)
|
|
631
|
+
.eq('project_id', projectId)
|
|
632
|
+
.eq('event_name', effectiveEventName)
|
|
633
|
+
|
|
634
|
+
if (isPastBehavior && asOfIso) {
|
|
635
|
+
query = query.lte('event_timestamp', asOfIso)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// NARROW para 1 contato (sempre que possível)
|
|
639
|
+
const ors: string[] = []
|
|
640
|
+
if (contactId) ors.push(`contact_id.eq.${contactId}`)
|
|
641
|
+
if (reachyId) ors.push(`reachy_id.eq.${reachyId}`)
|
|
642
|
+
if (ors.length > 0) query = query.or(ors.join(','))
|
|
643
|
+
|
|
644
|
+
// live presets helpers (paridade)
|
|
645
|
+
if (typeId === 'live-page-visit') {
|
|
646
|
+
const v = cfg.pageUrl
|
|
647
|
+
const d = cfg.domain
|
|
648
|
+
const ors2: string[] = []
|
|
649
|
+
if (v && String(v).trim() !== '') {
|
|
650
|
+
const like = `%${String(v)}%`
|
|
651
|
+
ors2.push(`path.ilike.${like}`)
|
|
652
|
+
ors2.push(`current_url.ilike.${like}`)
|
|
653
|
+
}
|
|
654
|
+
if (d && String(d).trim() !== '') {
|
|
655
|
+
const dlike = `%${String(d)}%`
|
|
656
|
+
ors2.push(`domain.ilike.${dlike}`)
|
|
657
|
+
ors2.push(`event_data->>domain.ilike.${dlike}`)
|
|
658
|
+
}
|
|
659
|
+
if (ors2.length > 0) query = query.or(ors2.join(','))
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (typeId === 'live-referrer') {
|
|
663
|
+
query = query.eq('session_is_new', true)
|
|
664
|
+
const ors2: string[] = []
|
|
665
|
+
const v = cfg.referrerUrl
|
|
666
|
+
if (v && String(v).trim() !== '') ors2.push(`referrer.ilike.%${String(v)}%`)
|
|
667
|
+
if (cfg.utm_source && String(cfg.utm_source).trim() !== '') ors2.push(`event_data->>utm_source.ilike.%${String(cfg.utm_source)}%`)
|
|
668
|
+
if (cfg.utm_medium && String(cfg.utm_medium).trim() !== '') ors2.push(`event_data->>utm_medium.ilike.%${String(cfg.utm_medium)}%`)
|
|
669
|
+
if (cfg.utm_campaign && String(cfg.utm_campaign).trim() !== '') ors2.push(`event_data->>utm_campaign.ilike.%${String(cfg.utm_campaign)}%`)
|
|
670
|
+
if (cfg.utm_term && String(cfg.utm_term).trim() !== '') ors2.push(`event_data->>utm_term.ilike.%${String(cfg.utm_term)}%`)
|
|
671
|
+
if (cfg.utm_content && String(cfg.utm_content).trim() !== '') ors2.push(`event_data->>utm_content.ilike.%${String(cfg.utm_content)}%`)
|
|
672
|
+
if (ors2.length > 0) query = query.or(ors2.join(','))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// TimeFrame via config (fallback)
|
|
676
|
+
if (!rule.time && cfg && cfg.timeFrame) {
|
|
677
|
+
const tf = String(cfg.timeFrame).trim()
|
|
678
|
+
const now = asOf ?? new Date()
|
|
679
|
+
let unit: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' = 'days'
|
|
680
|
+
let value = 7
|
|
681
|
+
if (/^\d+$/.test(tf)) { value = Number(tf); unit = 'days' }
|
|
682
|
+
else if (/^\d+\s*m$/.test(tf)) { value = Number(tf.replace(/m$/, '')); unit = 'minutes' }
|
|
683
|
+
else if (/^\d+\s*h$/.test(tf)) { value = Number(tf.replace(/h$/, '')); unit = 'hours' }
|
|
684
|
+
else if (/^\d+\s*d$/.test(tf)) { value = Number(tf.replace(/d$/, '')); unit = 'days' }
|
|
685
|
+
else if (/^\d+\s*w$/.test(tf)) { value = Number(tf.replace(/w$/, '')); unit = 'weeks' }
|
|
686
|
+
else if (/^\d+\s*mo$/.test(tf)) { value = Number(tf.replace(/mo$/, '')); unit = 'months' }
|
|
687
|
+
const units: Record<typeof unit, number> = {
|
|
688
|
+
minutes: 60 * 1000,
|
|
689
|
+
hours: 60 * 60 * 1000,
|
|
690
|
+
days: 24 * 60 * 60 * 1000,
|
|
691
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
692
|
+
months: 30 * 24 * 60 * 60 * 1000
|
|
693
|
+
}
|
|
694
|
+
const from = new Date(now.getTime() - value * units[unit])
|
|
695
|
+
query = query.gte('event_timestamp', from.toISOString())
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Janela temporal explícita (relativa ou absoluta)
|
|
699
|
+
if (rule.time) {
|
|
700
|
+
if (rule.time.unit && rule.time.value) {
|
|
701
|
+
type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months'
|
|
702
|
+
const unit = String(rule.time.unit) as TimeUnit
|
|
703
|
+
const value = Number(rule.time.value)
|
|
704
|
+
const now = asOf ?? new Date()
|
|
705
|
+
const units: Record<TimeUnit, number> = {
|
|
706
|
+
minutes: 60 * 1000,
|
|
707
|
+
hours: 60 * 60 * 1000,
|
|
708
|
+
days: 24 * 60 * 60 * 1000,
|
|
709
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
710
|
+
months: 30 * 24 * 60 * 60 * 1000
|
|
711
|
+
}
|
|
712
|
+
const ms = units[unit] ?? units['days']
|
|
713
|
+
const from = new Date(now.getTime() - value * ms)
|
|
714
|
+
query = query.gte('event_timestamp', from.toISOString())
|
|
715
|
+
} else {
|
|
716
|
+
if (rule.time.from) {
|
|
717
|
+
const rawFrom = rule.time.from
|
|
718
|
+
const fromStr = typeof rawFrom === 'string' ? rawFrom : String(rawFrom)
|
|
719
|
+
const isDateOnly = typeof rawFrom === 'string' && !fromStr.includes('T')
|
|
720
|
+
const startIso = isDateOnly ? normalizeDateBoundary(fromStr, 'start') : undefined
|
|
721
|
+
const finalFrom = startIso || fromStr
|
|
722
|
+
query = query.gte('event_timestamp', finalFrom)
|
|
723
|
+
}
|
|
724
|
+
if (rule.time.to) {
|
|
725
|
+
const rawTo = rule.time.to
|
|
726
|
+
const toStr = typeof rawTo === 'string' ? rawTo : String(rawTo)
|
|
727
|
+
const isDateOnly = typeof rawTo === 'string' && !toStr.includes('T')
|
|
728
|
+
const endIsoExclusive = isDateOnly ? normalizeDateBoundary(toStr, 'end') : undefined
|
|
729
|
+
const finalTo = endIsoExclusive || toStr
|
|
730
|
+
query = isDateOnly ? query.lt('event_timestamp', finalTo) : query.lte('event_timestamp', finalTo)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Event attributes
|
|
736
|
+
try {
|
|
737
|
+
const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : [])
|
|
738
|
+
const resolveDbFieldForAttrKey = (keyRaw: string) => {
|
|
739
|
+
const key = String(keyRaw || '').trim()
|
|
740
|
+
if (!key) return null
|
|
741
|
+
const columnKeys = new Set([
|
|
742
|
+
'current_url',
|
|
743
|
+
'domain',
|
|
744
|
+
'path',
|
|
745
|
+
'referrer',
|
|
746
|
+
'page_title',
|
|
747
|
+
'utm_source',
|
|
748
|
+
'utm_medium',
|
|
749
|
+
'utm_campaign',
|
|
750
|
+
'utm_term',
|
|
751
|
+
'utm_content',
|
|
752
|
+
'session_id',
|
|
753
|
+
'session_is_new'
|
|
754
|
+
])
|
|
755
|
+
if (columnKeys.has(key)) return { kind: 'column', field: key }
|
|
756
|
+
const buildNested = (root: string, dottedPath: string) => jsonNestedTextAccessor(root, dottedPath)
|
|
757
|
+
if (key.startsWith('event_data.')) return { kind: 'json', field: buildNested('event_data', key.replace(/^event_data\./, '')) }
|
|
758
|
+
if (key.startsWith('custom_data.')) return { kind: 'json', field: buildNested('event_data->custom_data', key.replace(/^custom_data\./, '')) }
|
|
759
|
+
if (key.startsWith('url_data.')) return { kind: 'json', field: buildNested('event_data->url_data', key.replace(/^url_data\./, '')) }
|
|
760
|
+
if (key.startsWith('utm_data.')) return { kind: 'json', field: buildNested('event_data->utm_data', key.replace(/^utm_data\./, '')) }
|
|
761
|
+
if (key.startsWith('session_data.')) return { kind: 'json', field: buildNested('event_data->session_data', key.replace(/^session_data\./, '')) }
|
|
762
|
+
if (key.includes('.')) return { kind: 'json', field: buildNested('event_data->custom_data', key) }
|
|
763
|
+
return { kind: 'json', field: jsonTextAccessor('event_data', key) }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
for (const attr of attributes) {
|
|
767
|
+
const key = String(attr.key || '').trim()
|
|
768
|
+
const op = String(attr.op || 'contains')
|
|
769
|
+
const value = attr.value
|
|
770
|
+
if (!key || value == null) continue
|
|
771
|
+
const resolved = resolveDbFieldForAttrKey(key)
|
|
772
|
+
if (!resolved) continue
|
|
773
|
+
const dbField = resolved.field
|
|
774
|
+
|
|
775
|
+
switch (op) {
|
|
776
|
+
case 'equals':
|
|
777
|
+
query = query.filter(dbField, 'eq', value)
|
|
778
|
+
break
|
|
779
|
+
case 'not_equals':
|
|
780
|
+
query = query.filter(dbField, 'neq', value)
|
|
781
|
+
break
|
|
782
|
+
case 'starts_with':
|
|
783
|
+
query = query.filter(dbField, 'ilike', `${value}%`)
|
|
784
|
+
break
|
|
785
|
+
case 'ends_with':
|
|
786
|
+
query = query.filter(dbField, 'ilike', `%${value}`)
|
|
787
|
+
break
|
|
788
|
+
case 'contains':
|
|
789
|
+
default:
|
|
790
|
+
query = query.filter(dbField, 'ilike', `%${value}%`)
|
|
791
|
+
break
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
} catch {
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const { data, error } = await query
|
|
798
|
+
if (error) return false
|
|
799
|
+
|
|
800
|
+
// Frequência / agregação (para 1 contato)
|
|
801
|
+
const rows = data || []
|
|
802
|
+
if (rule.frequency && rule.frequency.value != null) {
|
|
803
|
+
const { op, value, type = 'count', field = 'value' } = rule.frequency
|
|
804
|
+
let aggregatedValue = 0
|
|
805
|
+
if (type === 'count') {
|
|
806
|
+
aggregatedValue = rows.length
|
|
807
|
+
} else {
|
|
808
|
+
for (const row of rows) {
|
|
809
|
+
const eventData = row.event_data || {}
|
|
810
|
+
let numVal = 0
|
|
811
|
+
if (eventData[field] !== undefined) numVal = Number(eventData[field])
|
|
812
|
+
else if (eventData['value'] !== undefined) numVal = Number(eventData['value'])
|
|
813
|
+
else if (eventData['amount'] !== undefined) numVal = Number(eventData['amount'])
|
|
814
|
+
if (!Number.isNaN(numVal)) aggregatedValue += numVal
|
|
815
|
+
}
|
|
816
|
+
if (type === 'avg') aggregatedValue = rows.length > 0 ? aggregatedValue / rows.length : 0
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return (
|
|
820
|
+
(op === '>=' && aggregatedValue >= value) ||
|
|
821
|
+
(op === '>' && aggregatedValue > value) ||
|
|
822
|
+
(op === '=' && aggregatedValue === value) ||
|
|
823
|
+
(op === '<=' && aggregatedValue <= value) ||
|
|
824
|
+
(op === '<' && aggregatedValue < value)
|
|
825
|
+
)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
|
|
829
|
+
const threshold = Number(cfg.pageCount)
|
|
830
|
+
return rows.length >= threshold
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return rows.length > 0
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const evalPropertyRuleForContact = async (rule: any): Promise<boolean> => {
|
|
837
|
+
let query = this.supabase
|
|
838
|
+
.from('contacts')
|
|
839
|
+
.select('id')
|
|
840
|
+
.eq('organization_id', organizationId)
|
|
841
|
+
.eq('project_id', projectId)
|
|
842
|
+
.eq('id', contactId)
|
|
843
|
+
|
|
844
|
+
if (isPastBehavior && asOfIso) {
|
|
845
|
+
query = query.lte('created_at', asOfIso)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const field = rule.field as string
|
|
849
|
+
const op = rule.op as string
|
|
850
|
+
const value = rule.value
|
|
851
|
+
const value2 = rule.value2
|
|
852
|
+
|
|
853
|
+
const computeRelativeRange = (direction: 'past' | 'future' = 'past'): { start: string; end: string } | null => {
|
|
854
|
+
const unitSource = rule.timeUnit ?? rule.unit ?? 'days'
|
|
855
|
+
const rawUnit = String(unitSource).toLowerCase()
|
|
856
|
+
const numericValue = Number(rule.timeValue ?? rule.value ?? NaN)
|
|
857
|
+
if (!Number.isFinite(numericValue) || numericValue < 0) return null
|
|
858
|
+
const units: Record<string, number> = {
|
|
859
|
+
minutes: 60 * 1000,
|
|
860
|
+
hours: 60 * 60 * 1000,
|
|
861
|
+
days: 24 * 60 * 60 * 1000,
|
|
862
|
+
weeks: 7 * 24 * 60 * 60 * 1000,
|
|
863
|
+
months: 30 * 24 * 60 * 60 * 1000,
|
|
864
|
+
years: 365 * 24 * 60 * 60 * 1000
|
|
865
|
+
}
|
|
866
|
+
const now = asOf ?? new Date()
|
|
867
|
+
const baseUnitMs: number = units[rawUnit] ?? units['days'] ?? 24 * 60 * 60 * 1000
|
|
868
|
+
const delta = baseUnitMs * numericValue
|
|
869
|
+
const start = direction === 'past' ? new Date(now.getTime() - delta) : now
|
|
870
|
+
const end = direction === 'past' ? now : new Date(now.getTime() + delta)
|
|
871
|
+
return { start: start.toISOString(), end: end.toISOString() }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const apply = (dbField: string, isJsonb: boolean = false) => {
|
|
875
|
+
switch (op) {
|
|
876
|
+
case 'equals':
|
|
877
|
+
if (field === 'created_at') {
|
|
878
|
+
const startIso = normalizeDateBoundary(value, 'start')
|
|
879
|
+
const endIsoExclusive = normalizeDateBoundary(value, 'end')
|
|
880
|
+
if (startIso) query = query.gte(dbField, startIso)
|
|
881
|
+
if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
|
|
882
|
+
} else {
|
|
883
|
+
query = query.eq(dbField, value)
|
|
884
|
+
}
|
|
885
|
+
break
|
|
886
|
+
case 'not_equals':
|
|
887
|
+
if (isJsonb && value === '') query = query.not(dbField, 'is', null).neq(dbField, '')
|
|
888
|
+
else query = query.neq(dbField, value)
|
|
889
|
+
break
|
|
890
|
+
case 'contains':
|
|
891
|
+
query = query.ilike(dbField, `%${value}%`)
|
|
892
|
+
break
|
|
893
|
+
case 'not_contains':
|
|
894
|
+
query = query.not(dbField, 'ilike', `%${value}%`)
|
|
895
|
+
break
|
|
896
|
+
case 'starts_with':
|
|
897
|
+
query = query.ilike(dbField, `${value}%`)
|
|
898
|
+
break
|
|
899
|
+
case 'ends_with':
|
|
900
|
+
query = query.ilike(dbField, `%${value}`)
|
|
901
|
+
break
|
|
902
|
+
case '>':
|
|
903
|
+
query = query.gt(dbField, value)
|
|
904
|
+
break
|
|
905
|
+
case '>=':
|
|
906
|
+
query = query.gte(dbField, value)
|
|
907
|
+
break
|
|
908
|
+
case '<':
|
|
909
|
+
query = query.lt(dbField, value)
|
|
910
|
+
break
|
|
911
|
+
case '<=':
|
|
912
|
+
query = query.lte(dbField, value)
|
|
913
|
+
break
|
|
914
|
+
case 'between':
|
|
915
|
+
if (field === 'created_at') {
|
|
916
|
+
const startIso = normalizeDateBoundary(value, 'start')
|
|
917
|
+
const endIsoExclusive = normalizeDateBoundary(value2 ?? value, 'end')
|
|
918
|
+
if (startIso) query = query.gte(dbField, startIso)
|
|
919
|
+
if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
|
|
920
|
+
} else {
|
|
921
|
+
if (value != null) query = query.gte(dbField, value)
|
|
922
|
+
if (value2 != null) query = query.lte(dbField, value2)
|
|
923
|
+
}
|
|
924
|
+
break
|
|
925
|
+
case 'after':
|
|
926
|
+
if (field === 'created_at') {
|
|
927
|
+
const startIso = normalizeDateBoundary(value, 'start')
|
|
928
|
+
if (startIso) query = query.gte(dbField, startIso)
|
|
929
|
+
} else query = query.gt(dbField, value)
|
|
930
|
+
break
|
|
931
|
+
case 'before':
|
|
932
|
+
if (field === 'created_at') {
|
|
933
|
+
const endIsoExclusive = normalizeDateBoundary(value, 'end')
|
|
934
|
+
if (endIsoExclusive) query = query.lt(dbField, endIsoExclusive)
|
|
935
|
+
} else query = query.lt(dbField, value)
|
|
936
|
+
break
|
|
937
|
+
case 'is_empty':
|
|
938
|
+
if (isJsonb) query = query.or(`${dbField}.is.null,${dbField}.eq.`)
|
|
939
|
+
else query = query.is(dbField, null)
|
|
940
|
+
break
|
|
941
|
+
case 'is_not_empty':
|
|
942
|
+
if (isJsonb) query = query.not(dbField, 'is', null).neq(dbField, '')
|
|
943
|
+
else query = query.not(dbField, 'is', null)
|
|
944
|
+
break
|
|
945
|
+
case 'is_true':
|
|
946
|
+
query = query.eq(dbField, true)
|
|
947
|
+
break
|
|
948
|
+
case 'is_false':
|
|
949
|
+
query = query.eq(dbField, false)
|
|
950
|
+
break
|
|
951
|
+
case 'in_the_last': {
|
|
952
|
+
const range = computeRelativeRange('past')
|
|
953
|
+
if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
|
|
954
|
+
break
|
|
955
|
+
}
|
|
956
|
+
case 'in_the_next': {
|
|
957
|
+
const range = computeRelativeRange('future')
|
|
958
|
+
if (range) query = query.gte(dbField, range.start).lte(dbField, range.end)
|
|
959
|
+
break
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const mappedField = mapAudienceFieldToContactField(field)
|
|
965
|
+
const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
|
|
966
|
+
|
|
967
|
+
if (known.includes(field) || known.includes(mappedField)) {
|
|
968
|
+
apply(mappedField, false)
|
|
969
|
+
} else {
|
|
970
|
+
const dbField = `properties->>${field}`
|
|
971
|
+
apply(dbField, true)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const { data, error } = await query
|
|
975
|
+
if (error) return false
|
|
976
|
+
return (data || []).length > 0
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const evalRuleForContact = async (rule: any): Promise<boolean> => {
|
|
980
|
+
const base = rule.kind === 'event' ? await evalEventRuleForContact(rule) : await evalPropertyRuleForContact(rule)
|
|
981
|
+
const res = rule.negate ? !base : base
|
|
982
|
+
return res
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const evalGroupForContact = async (group: any): Promise<boolean> => {
|
|
986
|
+
let acc: boolean | null = null
|
|
987
|
+
for (const rule of group.rules || []) {
|
|
988
|
+
const r = await evalRuleForContact(rule)
|
|
989
|
+
if (acc == null) acc = r
|
|
990
|
+
else {
|
|
991
|
+
if (group.operator === 'AND') acc = acc && r
|
|
992
|
+
else if (group.operator === 'OR') acc = acc || r
|
|
993
|
+
else if (group.operator === 'NOT') acc = acc && !r
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return !!acc
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
let result: boolean | null = null
|
|
1000
|
+
for (const group of (criteria as any).groups) {
|
|
1001
|
+
const g = await evalGroupForContact(group)
|
|
1002
|
+
if (result == null) result = g
|
|
1003
|
+
else {
|
|
1004
|
+
if (group.operator === 'AND') result = result && g
|
|
1005
|
+
else if (group.operator === 'OR') result = result || g
|
|
1006
|
+
else if (group.operator === 'NOT') result = result && !g
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
return !!result
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private dlog(...args: any[]) {
|
|
1014
|
+
if (!this.debug) return
|
|
1015
|
+
this.logger.log('[AUDIENCE_MODULE_ENGINE]', ...args)
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function jsonTextAccessor(base: string, key: string): string {
|
|
1020
|
+
const safeKey = String(key || '').replace(/'/g, "''")
|
|
1021
|
+
return `${base}->>'${safeKey}'`
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function jsonNestedTextAccessor(base: string, path: string): string {
|
|
1025
|
+
const parts = String(path || '')
|
|
1026
|
+
.split('.')
|
|
1027
|
+
.map((p) => p.trim())
|
|
1028
|
+
.filter(Boolean)
|
|
1029
|
+
if (parts.length === 0) return jsonTextAccessor(base, 'value')
|
|
1030
|
+
if (parts.length === 1) return jsonTextAccessor(base, parts[0]!)
|
|
1031
|
+
const chain = parts
|
|
1032
|
+
.slice(0, -1)
|
|
1033
|
+
.map((p) => `->'${String(p).replace(/'/g, "''")}'`)
|
|
1034
|
+
.join('')
|
|
1035
|
+
const last = String(parts[parts.length - 1]!).replace(/'/g, "''")
|
|
1036
|
+
return `${base}${chain}->>'${last}'`
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function mapAudienceFieldToContactField(audienceField: string): string {
|
|
1040
|
+
const fieldMapping: { [key: string]: string } = {
|
|
1041
|
+
is_subscribed: 'is_subscribed',
|
|
1042
|
+
email: 'email',
|
|
1043
|
+
name: 'first_name',
|
|
1044
|
+
first_name: 'first_name',
|
|
1045
|
+
last_name: 'last_name',
|
|
1046
|
+
phone: 'phone',
|
|
1047
|
+
created_at: 'created_at',
|
|
1048
|
+
updated_at: 'updated_at',
|
|
1049
|
+
city: 'city',
|
|
1050
|
+
country: 'country',
|
|
1051
|
+
tags: 'tags',
|
|
1052
|
+
status: 'status'
|
|
1053
|
+
}
|
|
1054
|
+
return fieldMapping[audienceField] || audienceField
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function normalizeDateBoundary(value: any, boundary: 'start' | 'end'): string | undefined {
|
|
1058
|
+
if (!value || typeof value !== 'string') return undefined
|
|
1059
|
+
|
|
1060
|
+
const hasTimeComponent = value.includes('T')
|
|
1061
|
+
|
|
1062
|
+
const toUtcStartOfDay = (y: number, mZeroBased: number, d: number): Date => {
|
|
1063
|
+
return new Date(Date.UTC(y, mZeroBased, d, 0, 0, 0, 0))
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
let baseDate: Date | null = null
|
|
1067
|
+
|
|
1068
|
+
if (hasTimeComponent) {
|
|
1069
|
+
const dt = new Date(value)
|
|
1070
|
+
baseDate = Number.isNaN(dt.getTime()) ? null : dt
|
|
1071
|
+
} else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
1072
|
+
const parts = value.split('-')
|
|
1073
|
+
if (parts.length === 3) {
|
|
1074
|
+
const y = parseInt(parts[0]!, 10)
|
|
1075
|
+
const mm = parseInt(parts[1]!, 10)
|
|
1076
|
+
const d = parseInt(parts[2]!, 10)
|
|
1077
|
+
if (Number.isFinite(y) && Number.isFinite(mm) && Number.isFinite(d)) {
|
|
1078
|
+
baseDate = toUtcStartOfDay(y, mm - 1, d)
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
} else if (/^\d{2}\/\d{2}\/\d{4}$/.test(value)) {
|
|
1082
|
+
const parts = value.split('/')
|
|
1083
|
+
if (parts.length === 3) {
|
|
1084
|
+
const d = parseInt(parts[0]!, 10)
|
|
1085
|
+
const mm = parseInt(parts[1]!, 10)
|
|
1086
|
+
const y = parseInt(parts[2]!, 10)
|
|
1087
|
+
if (Number.isFinite(y) && Number.isFinite(mm) && Number.isFinite(d)) {
|
|
1088
|
+
baseDate = toUtcStartOfDay(y, mm - 1, d)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
const dt = new Date(`${value}T00:00:00Z`)
|
|
1093
|
+
baseDate = Number.isNaN(dt.getTime()) ? null : dt
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (!baseDate) return undefined
|
|
1097
|
+
|
|
1098
|
+
const utcYear = baseDate.getUTCFullYear()
|
|
1099
|
+
const utcMonth = baseDate.getUTCMonth()
|
|
1100
|
+
const utcDay = baseDate.getUTCDate()
|
|
1101
|
+
|
|
1102
|
+
if (boundary === 'start') {
|
|
1103
|
+
const startOfDay = new Date(Date.UTC(utcYear, utcMonth, utcDay, 0, 0, 0, 0))
|
|
1104
|
+
return startOfDay.toISOString()
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const nextDay = new Date(Date.UTC(utcYear, utcMonth, utcDay + 1, 0, 0, 0, 0))
|
|
1108
|
+
return nextDay.toISOString()
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function coerceCriteriaToGroups(criteria: AudienceCriteria): AudienceCriteria {
|
|
1112
|
+
const groups: any[] = []
|
|
1113
|
+
|
|
1114
|
+
const pushGroup = (operator: any, rules: any[]) => {
|
|
1115
|
+
if (!rules || rules.length === 0) return
|
|
1116
|
+
const op = operator === 'OR' || operator === 'NOT' ? operator : 'AND'
|
|
1117
|
+
groups.push({ operator: op, rules })
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// From `filters` (legado)
|
|
1121
|
+
if (Array.isArray((criteria as any).filters)) {
|
|
1122
|
+
for (const fg of (criteria as any).filters) {
|
|
1123
|
+
const rules: any[] = []
|
|
1124
|
+
for (const c of fg?.conditions || []) {
|
|
1125
|
+
const fieldRaw = String(c?.field || '')
|
|
1126
|
+
const operator = c?.operator
|
|
1127
|
+
const value = c?.value
|
|
1128
|
+
const value2 = c?.value2
|
|
1129
|
+
|
|
1130
|
+
if (fieldRaw === 'event') {
|
|
1131
|
+
rules.push({
|
|
1132
|
+
kind: 'event',
|
|
1133
|
+
eventName: value,
|
|
1134
|
+
negate: operator === 'not_equals',
|
|
1135
|
+
...(c?.dateFrom || c?.dateTo ? { time: { from: c?.dateFrom, to: c?.dateTo } } : {}),
|
|
1136
|
+
...(c?.timeValue != null ? { time: { unit: c?.timeUnit || 'days', value: Number(c?.timeValue) } } : {})
|
|
1137
|
+
})
|
|
1138
|
+
continue
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Custom field: usar a chave real para cair em properties->>key
|
|
1142
|
+
const field = fieldRaw === 'custom_field' && c?.customFieldKey ? String(c.customFieldKey) : fieldRaw
|
|
1143
|
+
const rule: any = {
|
|
1144
|
+
kind: 'property',
|
|
1145
|
+
field,
|
|
1146
|
+
op: operator
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (operator === 'in_the_last' || operator === 'in_the_next') {
|
|
1150
|
+
rule.timeValue = c?.timeValue ?? value
|
|
1151
|
+
rule.timeUnit = c?.timeUnit || 'days'
|
|
1152
|
+
} else if (operator === 'between') {
|
|
1153
|
+
rule.value = value
|
|
1154
|
+
rule.value2 = value2
|
|
1155
|
+
} else if (operator === 'is_empty' || operator === 'is_not_empty') {
|
|
1156
|
+
// sem value
|
|
1157
|
+
} else {
|
|
1158
|
+
rule.value = value
|
|
1159
|
+
if (value2 != null) rule.value2 = value2
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
rules.push(rule)
|
|
1163
|
+
}
|
|
1164
|
+
pushGroup(fg?.operator, rules)
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// From `conditions` (normalizado/legado)
|
|
1169
|
+
if (Array.isArray((criteria as any).conditions)) {
|
|
1170
|
+
for (const cg of (criteria as any).conditions) {
|
|
1171
|
+
const rules: any[] = []
|
|
1172
|
+
for (const c of cg?.conditions || []) {
|
|
1173
|
+
const fieldRaw = String(c?.field || '')
|
|
1174
|
+
const operator = c?.operator
|
|
1175
|
+
const value = c?.value
|
|
1176
|
+
const value2 = c?.value2
|
|
1177
|
+
const field = fieldRaw === 'custom_field' && c?.customFieldKey ? String(c.customFieldKey) : fieldRaw
|
|
1178
|
+
|
|
1179
|
+
const rule: any = { kind: 'property', field, op: operator }
|
|
1180
|
+
if (operator === 'between') {
|
|
1181
|
+
rule.value = value
|
|
1182
|
+
rule.value2 = value2
|
|
1183
|
+
} else if (operator === 'in_the_last' || operator === 'in_the_next') {
|
|
1184
|
+
rule.timeValue = (c as any)?.timeValue ?? value
|
|
1185
|
+
rule.timeUnit = (c as any)?.timeUnit || 'days'
|
|
1186
|
+
} else if (operator === 'is_empty' || operator === 'is_not_empty') {
|
|
1187
|
+
} else {
|
|
1188
|
+
rule.value = value
|
|
1189
|
+
if (value2 != null) rule.value2 = value2
|
|
1190
|
+
}
|
|
1191
|
+
rules.push(rule)
|
|
1192
|
+
}
|
|
1193
|
+
pushGroup(cg?.operator, rules)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return { ...(criteria as any), groups }
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
|