@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.
@@ -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(organizationId, projectId, criteria, contactId)
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[]) {