@reachy/audience-module 1.0.20 → 1.0.22
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/dist/AudienceModule.d.ts +7 -1
- package/dist/AudienceModule.d.ts.map +1 -1
- package/dist/AudienceModule.js +2 -2
- package/dist/AudienceModule.js.map +1 -1
- package/dist/builders/QueryBuilder.d.ts +3 -0
- package/dist/builders/QueryBuilder.d.ts.map +1 -1
- package/dist/builders/QueryBuilder.js +60 -0
- package/dist/builders/QueryBuilder.js.map +1 -1
- package/dist/engine/V2AudienceEngine.d.ts +5 -1
- package/dist/engine/V2AudienceEngine.d.ts.map +1 -1
- package/dist/engine/V2AudienceEngine.js +163 -1
- package/dist/engine/V2AudienceEngine.js.map +1 -1
- package/dist/repositories/SupabaseContactRepository.d.ts +5 -1
- package/dist/repositories/SupabaseContactRepository.d.ts.map +1 -1
- package/dist/repositories/SupabaseContactRepository.js +2 -2
- package/dist/repositories/SupabaseContactRepository.js.map +1 -1
- package/package.json +1 -1
- package/src/AudienceModule.ts +6 -2
- package/src/__tests__/AudienceModule.test.ts +22 -0
- package/src/builders/QueryBuilder.ts +59 -0
- package/src/engine/V2AudienceEngine.ts +167 -1
- package/src/repositories/SupabaseContactRepository.ts +9 -2
|
@@ -131,6 +131,10 @@ export class QueryBuilder {
|
|
|
131
131
|
|
|
132
132
|
const dbField = this.mapFieldToColumn(field, condition.customFieldKey)
|
|
133
133
|
|
|
134
|
+
if (dbField === 'tags') {
|
|
135
|
+
return this.applyTagCondition(query, operator, value)
|
|
136
|
+
}
|
|
137
|
+
|
|
134
138
|
switch (operator) {
|
|
135
139
|
case 'equals':
|
|
136
140
|
case 'eq':
|
|
@@ -163,6 +167,39 @@ export class QueryBuilder {
|
|
|
163
167
|
}
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
private static applyTagCondition(query: any, operator: string, value: any): any {
|
|
171
|
+
const op = String(operator || '').trim().toLowerCase()
|
|
172
|
+
const values = QueryBuilder.normalizeTagValues(value)
|
|
173
|
+
|
|
174
|
+
if (op === 'has_all' || op === 'equals' || op === 'eq' || op === '=') {
|
|
175
|
+
if (values.length === 0) return query.eq('id', '__never__')
|
|
176
|
+
return query.contains('tags', values)
|
|
177
|
+
}
|
|
178
|
+
if (op === 'has_any' || op === 'contains' || op === 'in') {
|
|
179
|
+
if (values.length === 0) return query.eq('id', '__never__')
|
|
180
|
+
return query.overlaps('tags', values)
|
|
181
|
+
}
|
|
182
|
+
if (op === 'is_empty') {
|
|
183
|
+
return query.or('tags.is.null,tags.eq.{}')
|
|
184
|
+
}
|
|
185
|
+
if (op === 'is_not_empty') {
|
|
186
|
+
return query.not('tags', 'is', null).neq('tags', '{}')
|
|
187
|
+
}
|
|
188
|
+
console.warn(`⚠️ Operador de tag desconhecido: ${operator}`)
|
|
189
|
+
return query.eq('id', '__never__')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private static normalizeTagValues(value: any): string[] {
|
|
193
|
+
if (value === null || value === undefined) return []
|
|
194
|
+
const raw = Array.isArray(value) ? value : String(value).split(',')
|
|
195
|
+
const out: string[] = []
|
|
196
|
+
for (const item of raw) {
|
|
197
|
+
const v = String(item ?? '').trim()
|
|
198
|
+
if (v.length > 0) out.push(v)
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
166
203
|
/**
|
|
167
204
|
* Constrói string de condição para uso em .or()
|
|
168
205
|
*/
|
|
@@ -173,6 +210,10 @@ export class QueryBuilder {
|
|
|
173
210
|
|
|
174
211
|
const dbField = this.mapFieldToColumn(field, condition.customFieldKey)
|
|
175
212
|
|
|
213
|
+
if (dbField === 'tags') {
|
|
214
|
+
return QueryBuilder.buildTagConditionString(operator, value)
|
|
215
|
+
}
|
|
216
|
+
|
|
176
217
|
switch (operator) {
|
|
177
218
|
case 'equals':
|
|
178
219
|
case 'eq':
|
|
@@ -204,6 +245,24 @@ export class QueryBuilder {
|
|
|
204
245
|
}
|
|
205
246
|
}
|
|
206
247
|
|
|
248
|
+
private static buildTagConditionString(operator: string, value: any): string | null {
|
|
249
|
+
const op = String(operator || '').trim().toLowerCase()
|
|
250
|
+
const values = QueryBuilder.normalizeTagValues(value)
|
|
251
|
+
const arrLiteral = `{${values.map((v) => v.includes(',') ? `"${v.replace(/"/g, '\\"')}"` : v).join(',')}}`
|
|
252
|
+
|
|
253
|
+
if (op === 'has_all' || op === 'equals' || op === 'eq' || op === '=') {
|
|
254
|
+
if (values.length === 0) return null
|
|
255
|
+
return `tags.cs.${arrLiteral}`
|
|
256
|
+
}
|
|
257
|
+
if (op === 'has_any' || op === 'contains' || op === 'in') {
|
|
258
|
+
if (values.length === 0) return null
|
|
259
|
+
return `tags.ov.${arrLiteral}`
|
|
260
|
+
}
|
|
261
|
+
if (op === 'is_empty') return `tags.is.null`
|
|
262
|
+
if (op === 'is_not_empty') return `tags.not.is.null`
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
207
266
|
/**
|
|
208
267
|
* Mapeia campo da audiência para coluna do banco
|
|
209
268
|
*/
|
|
@@ -163,6 +163,31 @@ const normalizePropertyOp = (opRaw: any): string => {
|
|
|
163
163
|
return op
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
const normalizeTagValues = (value: any): string[] => {
|
|
167
|
+
if (value === null || value === undefined) return []
|
|
168
|
+
const raw = Array.isArray(value) ? value : String(value).split(',')
|
|
169
|
+
const out: string[] = []
|
|
170
|
+
for (const item of raw) {
|
|
171
|
+
const v = String(item ?? '').trim()
|
|
172
|
+
if (v.length > 0) out.push(v)
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const normalizeTagOp = (opRaw: any): string => {
|
|
178
|
+
const op = String(opRaw ?? '').trim().toLowerCase()
|
|
179
|
+
// UI principal: espelha os operadores dos campos texto.
|
|
180
|
+
if (op === 'equals' || op === 'eq' || op === '=' || op === 'has_all' || op === 'has') return 'equals'
|
|
181
|
+
if (op === 'not_equals' || op === 'neq' || op === '!=' || op === '<>') return 'not_equals'
|
|
182
|
+
if (op === 'contains') return 'contains'
|
|
183
|
+
if (op === 'not_contains') return 'not_contains'
|
|
184
|
+
// Array-specific (forward-compat / API direta)
|
|
185
|
+
if (op === 'has_any' || op === 'in') return 'has_any'
|
|
186
|
+
if (op === 'is_empty') return 'is_empty'
|
|
187
|
+
if (op === 'is_not_empty') return 'is_not_empty'
|
|
188
|
+
return op
|
|
189
|
+
}
|
|
190
|
+
|
|
166
191
|
const isNumericLike = (value: any): boolean => {
|
|
167
192
|
if (typeof value === 'number') return Number.isFinite(value)
|
|
168
193
|
if (typeof value !== 'string') return false
|
|
@@ -1034,6 +1059,63 @@ export class V2AudienceEngine {
|
|
|
1034
1059
|
}
|
|
1035
1060
|
|
|
1036
1061
|
const mappedField = mapAudienceFieldToContactField(field)
|
|
1062
|
+
|
|
1063
|
+
if (field === 'tags' || mappedField === 'tags') {
|
|
1064
|
+
const tagOp = normalizeTagOp(opRaw)
|
|
1065
|
+
|
|
1066
|
+
if (tagOp === 'contains' || tagOp === 'not_contains') {
|
|
1067
|
+
const expected = String(value ?? '').trim().toLowerCase()
|
|
1068
|
+
if (!expected) return new Set<string>()
|
|
1069
|
+
const fetchQuery = this.supabase
|
|
1070
|
+
.from('contacts')
|
|
1071
|
+
.select('id, tags')
|
|
1072
|
+
.eq('organization_id', organizationId)
|
|
1073
|
+
.eq('project_id', projectId)
|
|
1074
|
+
const data = await fetchAll(fetchQuery, 1000)
|
|
1075
|
+
if (!data || data.length === 0) return new Set<string>()
|
|
1076
|
+
const result = new Set<string>()
|
|
1077
|
+
for (const row of data as any[]) {
|
|
1078
|
+
const id = row?.id as string | undefined
|
|
1079
|
+
if (!id) continue
|
|
1080
|
+
const tags = Array.isArray(row.tags) ? row.tags : []
|
|
1081
|
+
const hasMatch = tags.some((t: any) => String(t ?? '').toLowerCase().includes(expected))
|
|
1082
|
+
if (tagOp === 'contains' && hasMatch) result.add(id)
|
|
1083
|
+
else if (tagOp === 'not_contains' && !hasMatch) result.add(id)
|
|
1084
|
+
}
|
|
1085
|
+
return result
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const tagValues = normalizeTagValues(value)
|
|
1089
|
+
let tagQuery = this.supabase
|
|
1090
|
+
.from('contacts')
|
|
1091
|
+
.select('id')
|
|
1092
|
+
.eq('organization_id', organizationId)
|
|
1093
|
+
.eq('project_id', projectId)
|
|
1094
|
+
|
|
1095
|
+
if (tagOp === 'equals') {
|
|
1096
|
+
if (tagValues.length === 0) return new Set<string>()
|
|
1097
|
+
tagQuery = tagQuery.contains('tags', tagValues)
|
|
1098
|
+
} else if (tagOp === 'not_equals') {
|
|
1099
|
+
if (tagValues.length === 0) return new Set<string>()
|
|
1100
|
+
const arrLiteral = `{${tagValues.join(',')}}`
|
|
1101
|
+
tagQuery = tagQuery.or(`tags.is.null,tags.not.cs.${arrLiteral}`)
|
|
1102
|
+
} else if (tagOp === 'has_any') {
|
|
1103
|
+
if (tagValues.length === 0) return new Set<string>()
|
|
1104
|
+
tagQuery = tagQuery.overlaps('tags', tagValues)
|
|
1105
|
+
} else if (tagOp === 'is_empty') {
|
|
1106
|
+
tagQuery = tagQuery.or('tags.is.null,tags.eq.{}')
|
|
1107
|
+
} else if (tagOp === 'is_not_empty') {
|
|
1108
|
+
tagQuery = tagQuery.not('tags', 'is', null).neq('tags', '{}')
|
|
1109
|
+
} else {
|
|
1110
|
+
this.dlog('tags rule with unsupported op, returning empty', { opRaw, tagOp })
|
|
1111
|
+
return new Set<string>()
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const tagData = await fetchAll(tagQuery, 1000)
|
|
1115
|
+
if (!tagData || tagData.length === 0) return new Set<string>()
|
|
1116
|
+
return new Set<string>(tagData.map((r: any) => r.id as string))
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1037
1119
|
const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
|
|
1038
1120
|
const isJsonb = !(known.includes(field) || known.includes(mappedField))
|
|
1039
1121
|
const dbField = isJsonb ? `properties->>${field}` : mappedField
|
|
@@ -1341,12 +1423,25 @@ export class V2AudienceEngine {
|
|
|
1341
1423
|
/**
|
|
1342
1424
|
* Variante otimizada para avaliar apenas UM contato (useful para Journeys/Live).
|
|
1343
1425
|
* Retorna true se o contact_id atende ao critério.
|
|
1426
|
+
*
|
|
1427
|
+
* @param triggerEvent (opcional) Restringe a avaliação de regras de evento
|
|
1428
|
+
* ao evento específico que disparou o fluxo (ex.: split de Journey).
|
|
1429
|
+
* Quando informado, a query em `contact_events` é limitada a este registro
|
|
1430
|
+
* pelo `event_id` (preferencial) OU pelo par (event_name, event_timestamp).
|
|
1431
|
+
* Sem isso, o engine percorre TODO o histórico do contato — esse é o
|
|
1432
|
+
* comportamento correto para audiências/segmentos, mas é um BUG quando o
|
|
1433
|
+
* uso é "avaliar a propriedade do evento de gatilho atual" em journeys.
|
|
1344
1434
|
*/
|
|
1345
1435
|
async matchesContactByAudienceCriteriaV2(
|
|
1346
1436
|
organizationId: string,
|
|
1347
1437
|
projectId: string,
|
|
1348
1438
|
criteriaRaw: any,
|
|
1349
|
-
contactId: string
|
|
1439
|
+
contactId: string,
|
|
1440
|
+
triggerEvent?: {
|
|
1441
|
+
event_id?: string
|
|
1442
|
+
event_name?: string
|
|
1443
|
+
event_timestamp?: string
|
|
1444
|
+
}
|
|
1350
1445
|
): Promise<boolean> {
|
|
1351
1446
|
let criteria: AudienceCriteria = CriteriaParser.parse(criteriaRaw as any) as any
|
|
1352
1447
|
|
|
@@ -1419,6 +1514,24 @@ export class V2AudienceEngine {
|
|
|
1419
1514
|
if (reachyId) ors.push(`reachy_id.eq.${reachyId}`)
|
|
1420
1515
|
if (ors.length > 0) query = query.or(ors.join(','))
|
|
1421
1516
|
|
|
1517
|
+
// 🎯 NARROW para o evento que disparou a journey (split com event_property).
|
|
1518
|
+
// Sem isso, a avaliação considera TODO o histórico do contato, fazendo a
|
|
1519
|
+
// condição cair sempre em "yes" se algum evento histórico bater.
|
|
1520
|
+
// Só restringe quando o nome do evento de gatilho casa com a regra avaliada
|
|
1521
|
+
// (caso contrário, manteria o comportamento correto p/ outras regras).
|
|
1522
|
+
const triggerEventNameMatches =
|
|
1523
|
+
!!triggerEvent &&
|
|
1524
|
+
typeof triggerEvent.event_name === 'string' &&
|
|
1525
|
+
String(triggerEvent.event_name).trim() === String(effectiveEventName).trim()
|
|
1526
|
+
|
|
1527
|
+
if (triggerEvent && triggerEventNameMatches) {
|
|
1528
|
+
if (triggerEvent.event_id) {
|
|
1529
|
+
query = query.eq('id', triggerEvent.event_id)
|
|
1530
|
+
} else if (triggerEvent.event_timestamp) {
|
|
1531
|
+
query = query.eq('event_timestamp', triggerEvent.event_timestamp)
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1422
1535
|
// live presets helpers (paridade)
|
|
1423
1536
|
if (typeId === 'live-page-visit') {
|
|
1424
1537
|
const v = cfg.pageUrl
|
|
@@ -1816,6 +1929,59 @@ export class V2AudienceEngine {
|
|
|
1816
1929
|
}
|
|
1817
1930
|
|
|
1818
1931
|
const mappedField = mapAudienceFieldToContactField(field)
|
|
1932
|
+
|
|
1933
|
+
// Coluna `tags` é text[] — desvia para operadores de array antes do path scalar/JSONB.
|
|
1934
|
+
if (field === 'tags' || mappedField === 'tags') {
|
|
1935
|
+
const tagOp = normalizeTagOp(opRaw)
|
|
1936
|
+
|
|
1937
|
+
// contains/not_contains: substring — fetch + filtro em memória.
|
|
1938
|
+
if (tagOp === 'contains' || tagOp === 'not_contains') {
|
|
1939
|
+
const expected = String(value ?? '').trim().toLowerCase()
|
|
1940
|
+
if (!expected) return false
|
|
1941
|
+
const { data: tagsRow, error: tagsErr } = await this.supabase
|
|
1942
|
+
.from('contacts')
|
|
1943
|
+
.select('tags')
|
|
1944
|
+
.eq('organization_id', organizationId)
|
|
1945
|
+
.eq('project_id', projectId)
|
|
1946
|
+
.eq('id', contactId)
|
|
1947
|
+
.maybeSingle()
|
|
1948
|
+
if (tagsErr) return false
|
|
1949
|
+
const tags = Array.isArray((tagsRow as any)?.tags) ? (tagsRow as any).tags : []
|
|
1950
|
+
const hasMatch = tags.some((t: any) => String(t ?? '').toLowerCase().includes(expected))
|
|
1951
|
+
return tagOp === 'contains' ? hasMatch : !hasMatch
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const tagValues = normalizeTagValues(value)
|
|
1955
|
+
let tagQuery = this.supabase
|
|
1956
|
+
.from('contacts')
|
|
1957
|
+
.select('id')
|
|
1958
|
+
.eq('organization_id', organizationId)
|
|
1959
|
+
.eq('project_id', projectId)
|
|
1960
|
+
.eq('id', contactId)
|
|
1961
|
+
|
|
1962
|
+
if (tagOp === 'equals') {
|
|
1963
|
+
if (tagValues.length === 0) return false
|
|
1964
|
+
tagQuery = tagQuery.contains('tags', tagValues)
|
|
1965
|
+
} else if (tagOp === 'not_equals') {
|
|
1966
|
+
if (tagValues.length === 0) return false
|
|
1967
|
+
const arrLiteral = `{${tagValues.join(',')}}`
|
|
1968
|
+
tagQuery = tagQuery.or(`tags.is.null,tags.not.cs.${arrLiteral}`)
|
|
1969
|
+
} else if (tagOp === 'has_any') {
|
|
1970
|
+
if (tagValues.length === 0) return false
|
|
1971
|
+
tagQuery = tagQuery.overlaps('tags', tagValues)
|
|
1972
|
+
} else if (tagOp === 'is_empty') {
|
|
1973
|
+
tagQuery = tagQuery.or('tags.is.null,tags.eq.{}')
|
|
1974
|
+
} else if (tagOp === 'is_not_empty') {
|
|
1975
|
+
tagQuery = tagQuery.not('tags', 'is', null).neq('tags', '{}')
|
|
1976
|
+
} else {
|
|
1977
|
+
return false
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const { data: tagData, error: tagErr } = await tagQuery
|
|
1981
|
+
if (tagErr) return false
|
|
1982
|
+
return Array.isArray(tagData) && tagData.length > 0
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1819
1985
|
const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
|
|
1820
1986
|
const isJsonb = !(known.includes(field) || known.includes(mappedField))
|
|
1821
1987
|
const dbField = isJsonb ? `properties->>${field}` : mappedField
|
|
@@ -30,9 +30,16 @@ export class SupabaseContactRepository {
|
|
|
30
30
|
organizationId: string,
|
|
31
31
|
projectId: string,
|
|
32
32
|
criteria: any,
|
|
33
|
-
contactId: string
|
|
33
|
+
contactId: string,
|
|
34
|
+
triggerEvent?: { event_id?: string; event_name?: string; event_timestamp?: string }
|
|
34
35
|
): Promise<boolean> {
|
|
35
|
-
return this.engine.matchesContactByAudienceCriteriaV2(
|
|
36
|
+
return this.engine.matchesContactByAudienceCriteriaV2(
|
|
37
|
+
organizationId,
|
|
38
|
+
projectId,
|
|
39
|
+
criteria,
|
|
40
|
+
contactId,
|
|
41
|
+
triggerEvent
|
|
42
|
+
)
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
async findByIds(organizationId: string, projectId: string, ids: string[]) {
|