@reachy/audience-module 1.0.19 → 1.0.21

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.
@@ -0,0 +1,134 @@
1
+ import { StaticAudienceExecutor } from '../executors/StaticAudienceExecutor'
2
+ import { AudienceCriteria } from '../types'
3
+
4
+ const validCriteria: AudienceCriteria = {
5
+ groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'email', op: 'contains', value: '@' }] }]
6
+ }
7
+
8
+ function mockRepo(ids: string[] = ['c1', 'c2', 'c3']) {
9
+ return {
10
+ getContactIdsByAudienceCriteriaV2: jest.fn().mockResolvedValue(new Set(ids)),
11
+ findByIds: jest.fn().mockResolvedValue({
12
+ data: ids.map(id => ({ id, email: `${id}@test.com` })),
13
+ error: null,
14
+ }),
15
+ }
16
+ }
17
+
18
+ describe('StaticAudienceExecutor', () => {
19
+ describe('execute', () => {
20
+ it('returns contactIds and count', async () => {
21
+ const executor = new StaticAudienceExecutor(mockRepo())
22
+ const result = await executor.execute(validCriteria, {
23
+ organizationId: 'org-1',
24
+ projectId: 'proj-1',
25
+ })
26
+
27
+ expect(result.contactIds).toBeInstanceOf(Set)
28
+ expect(result.contactIds.size).toBe(3)
29
+ expect(result.count).toBe(3)
30
+ expect(result.metadata?.criteriaType).toBe('static')
31
+ expect(result.metadata?.executionTime).toBeGreaterThanOrEqual(0)
32
+ })
33
+
34
+ it('fetches contact details when pagination is set', async () => {
35
+ const repo = mockRepo(['c1', 'c2', 'c3', 'c4', 'c5'])
36
+ const executor = new StaticAudienceExecutor(repo)
37
+
38
+ await executor.execute(validCriteria, {
39
+ organizationId: 'org-1',
40
+ projectId: 'proj-1',
41
+ pagination: { page: 1, limit: 2 },
42
+ })
43
+
44
+ expect(repo.findByIds).toHaveBeenCalled()
45
+ // Should request only first 2 ids
46
+ const calledIds = repo.findByIds.mock.calls[0][2]
47
+ expect(calledIds).toHaveLength(2)
48
+ })
49
+
50
+ it('fetches contact details when includeCount is true', async () => {
51
+ const repo = mockRepo()
52
+ const executor = new StaticAudienceExecutor(repo)
53
+
54
+ const result = await executor.execute(validCriteria, {
55
+ organizationId: 'org-1',
56
+ projectId: 'proj-1',
57
+ includeCount: true,
58
+ })
59
+
60
+ expect(repo.findByIds).toHaveBeenCalled()
61
+ expect(result.contacts).toBeDefined()
62
+ })
63
+
64
+ it('does not fetch contact details without pagination or includeCount', async () => {
65
+ const repo = mockRepo()
66
+ const executor = new StaticAudienceExecutor(repo)
67
+
68
+ const result = await executor.execute(validCriteria, {
69
+ organizationId: 'org-1',
70
+ projectId: 'proj-1',
71
+ })
72
+
73
+ expect(repo.findByIds).not.toHaveBeenCalled()
74
+ expect(result.contacts).toBeUndefined()
75
+ })
76
+
77
+ it('returns empty result for invalid criteria', async () => {
78
+ const executor = new StaticAudienceExecutor(mockRepo())
79
+ const result = await executor.execute({ type: 'static' }, {
80
+ organizationId: 'org-1',
81
+ projectId: 'proj-1',
82
+ })
83
+
84
+ expect(result.contactIds.size).toBe(0)
85
+ expect(result.count).toBe(0)
86
+ })
87
+
88
+ it('throws when contactRepository not set', async () => {
89
+ const executor = new StaticAudienceExecutor()
90
+ await expect(executor.execute(validCriteria, {
91
+ organizationId: 'org-1',
92
+ projectId: 'proj-1',
93
+ })).rejects.toThrow('ContactRepository não foi configurado')
94
+ })
95
+
96
+ it('paginates correctly for page 2', async () => {
97
+ const repo = mockRepo(['c1', 'c2', 'c3', 'c4', 'c5'])
98
+ const executor = new StaticAudienceExecutor(repo)
99
+
100
+ await executor.execute(validCriteria, {
101
+ organizationId: 'org-1',
102
+ projectId: 'proj-1',
103
+ pagination: { page: 2, limit: 2 },
104
+ })
105
+
106
+ const calledIds = repo.findByIds.mock.calls[0][2]
107
+ expect(calledIds).toEqual(['c3', 'c4'])
108
+ })
109
+ })
110
+
111
+ describe('executeCount', () => {
112
+ it('returns count only', async () => {
113
+ const executor = new StaticAudienceExecutor(mockRepo(['c1', 'c2']))
114
+ const count = await executor.executeCount(validCriteria, {
115
+ organizationId: 'org-1',
116
+ projectId: 'proj-1',
117
+ })
118
+ expect(count).toBe(2)
119
+ })
120
+ })
121
+
122
+ describe('setContactRepository', () => {
123
+ it('allows setting repository after construction', async () => {
124
+ const executor = new StaticAudienceExecutor()
125
+ executor.setContactRepository(mockRepo())
126
+
127
+ const result = await executor.execute(validCriteria, {
128
+ organizationId: 'org-1',
129
+ projectId: 'proj-1',
130
+ })
131
+ expect(result.count).toBe(3)
132
+ })
133
+ })
134
+ })
@@ -0,0 +1,81 @@
1
+ import { SupabaseContactRepository } from '../repositories/SupabaseContactRepository'
2
+
3
+ // Mock V2AudienceEngine since it requires real Supabase
4
+ jest.mock('../engine/V2AudienceEngine', () => ({
5
+ V2AudienceEngine: jest.fn().mockImplementation(() => ({
6
+ getContactIdsByAudienceCriteriaV2: jest.fn().mockResolvedValue(new Set(['c1', 'c2'])),
7
+ matchesContactByAudienceCriteriaV2: jest.fn().mockResolvedValue(true),
8
+ })),
9
+ }))
10
+
11
+ function mockSupabase() {
12
+ const chainable = {
13
+ select: jest.fn().mockReturnThis(),
14
+ eq: jest.fn().mockReturnThis(),
15
+ in: jest.fn().mockResolvedValue({
16
+ data: [{ id: 'c1', email: 'a@b.com' }, { id: 'c2', email: 'c@d.com' }],
17
+ error: null,
18
+ }),
19
+ }
20
+ return {
21
+ from: jest.fn().mockReturnValue(chainable),
22
+ _chainable: chainable,
23
+ }
24
+ }
25
+
26
+ describe('SupabaseContactRepository', () => {
27
+ it('creates instance with supabase and clickhouse clients', () => {
28
+ const repo = new SupabaseContactRepository({
29
+ supabaseClient: mockSupabase(),
30
+ clickhouseClient: {},
31
+ debug: false,
32
+ })
33
+ expect(repo).toBeDefined()
34
+ })
35
+
36
+ describe('getContactIdsByAudienceCriteriaV2', () => {
37
+ it('delegates to V2AudienceEngine', async () => {
38
+ const repo = new SupabaseContactRepository({ supabaseClient: mockSupabase() })
39
+ const result = await repo.getContactIdsByAudienceCriteriaV2('org-1', 'proj-1', { groups: [] })
40
+
41
+ expect(result).toBeInstanceOf(Set)
42
+ expect(result.size).toBe(2)
43
+ })
44
+ })
45
+
46
+ describe('matchesContactByAudienceCriteriaV2', () => {
47
+ it('delegates to V2AudienceEngine', async () => {
48
+ const repo = new SupabaseContactRepository({ supabaseClient: mockSupabase() })
49
+ const result = await repo.matchesContactByAudienceCriteriaV2('org-1', 'proj-1', { groups: [] }, 'c1')
50
+
51
+ expect(result).toBe(true)
52
+ })
53
+ })
54
+
55
+ describe('findByIds', () => {
56
+ it('queries Supabase for contacts by IDs', async () => {
57
+ const supabase = mockSupabase()
58
+ const repo = new SupabaseContactRepository({ supabaseClient: supabase })
59
+
60
+ const { data } = await repo.findByIds('org-1', 'proj-1', ['c1', 'c2'])
61
+
62
+ expect(supabase.from).toHaveBeenCalledWith('contacts')
63
+ expect(supabase._chainable.select).toHaveBeenCalledWith('*')
64
+ expect(supabase._chainable.eq).toHaveBeenCalledWith('organization_id', 'org-1')
65
+ expect(supabase._chainable.eq).toHaveBeenCalledWith('project_id', 'proj-1')
66
+ expect(supabase._chainable.in).toHaveBeenCalledWith('id', ['c1', 'c2'])
67
+ expect(data).toHaveLength(2)
68
+ })
69
+
70
+ it('throws when Supabase returns an error', async () => {
71
+ const supabase = mockSupabase()
72
+ supabase._chainable.in = jest.fn().mockResolvedValue({
73
+ data: null,
74
+ error: new Error('DB error'),
75
+ })
76
+ const repo = new SupabaseContactRepository({ supabaseClient: supabase })
77
+
78
+ await expect(repo.findByIds('org-1', 'proj-1', ['c1'])).rejects.toThrow('DB error')
79
+ })
80
+ })
81
+ })
@@ -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
@@ -1816,6 +1898,59 @@ export class V2AudienceEngine {
1816
1898
  }
1817
1899
 
1818
1900
  const mappedField = mapAudienceFieldToContactField(field)
1901
+
1902
+ // Coluna `tags` é text[] — desvia para operadores de array antes do path scalar/JSONB.
1903
+ if (field === 'tags' || mappedField === 'tags') {
1904
+ const tagOp = normalizeTagOp(opRaw)
1905
+
1906
+ // contains/not_contains: substring — fetch + filtro em memória.
1907
+ if (tagOp === 'contains' || tagOp === 'not_contains') {
1908
+ const expected = String(value ?? '').trim().toLowerCase()
1909
+ if (!expected) return false
1910
+ const { data: tagsRow, error: tagsErr } = await this.supabase
1911
+ .from('contacts')
1912
+ .select('tags')
1913
+ .eq('organization_id', organizationId)
1914
+ .eq('project_id', projectId)
1915
+ .eq('id', contactId)
1916
+ .maybeSingle()
1917
+ if (tagsErr) return false
1918
+ const tags = Array.isArray((tagsRow as any)?.tags) ? (tagsRow as any).tags : []
1919
+ const hasMatch = tags.some((t: any) => String(t ?? '').toLowerCase().includes(expected))
1920
+ return tagOp === 'contains' ? hasMatch : !hasMatch
1921
+ }
1922
+
1923
+ const tagValues = normalizeTagValues(value)
1924
+ let tagQuery = this.supabase
1925
+ .from('contacts')
1926
+ .select('id')
1927
+ .eq('organization_id', organizationId)
1928
+ .eq('project_id', projectId)
1929
+ .eq('id', contactId)
1930
+
1931
+ if (tagOp === 'equals') {
1932
+ if (tagValues.length === 0) return false
1933
+ tagQuery = tagQuery.contains('tags', tagValues)
1934
+ } else if (tagOp === 'not_equals') {
1935
+ if (tagValues.length === 0) return false
1936
+ const arrLiteral = `{${tagValues.join(',')}}`
1937
+ tagQuery = tagQuery.or(`tags.is.null,tags.not.cs.${arrLiteral}`)
1938
+ } else if (tagOp === 'has_any') {
1939
+ if (tagValues.length === 0) return false
1940
+ tagQuery = tagQuery.overlaps('tags', tagValues)
1941
+ } else if (tagOp === 'is_empty') {
1942
+ tagQuery = tagQuery.or('tags.is.null,tags.eq.{}')
1943
+ } else if (tagOp === 'is_not_empty') {
1944
+ tagQuery = tagQuery.not('tags', 'is', null).neq('tags', '{}')
1945
+ } else {
1946
+ return false
1947
+ }
1948
+
1949
+ const { data: tagData, error: tagErr } = await tagQuery
1950
+ if (tagErr) return false
1951
+ return Array.isArray(tagData) && tagData.length > 0
1952
+ }
1953
+
1819
1954
  const known = ['email', 'first_name', 'last_name', 'phone', 'is_subscribed', 'created_at', 'updated_at', 'name']
1820
1955
  const isJsonb = !(known.includes(field) || known.includes(mappedField))
1821
1956
  const dbField = isJsonb ? `properties->>${field}` : mappedField