@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.
- package/.gitlab/merge_request_templates/Default.md +31 -0
- package/.gitlab-ci.yml +59 -49
- 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.map +1 -1
- package/dist/engine/V2AudienceEngine.js +151 -0
- package/dist/engine/V2AudienceEngine.js.map +1 -1
- package/jest.config.js +8 -0
- package/package.json +7 -2
- package/src/__tests__/AudienceModule.test.ts +382 -0
- package/src/__tests__/CriteriaParser.test.ts +130 -0
- package/src/__tests__/QueryBuilder.test.ts +198 -0
- package/src/__tests__/RfmEngine.test.ts +284 -0
- package/src/__tests__/RfmSegmentBuilder.test.ts +210 -0
- package/src/__tests__/StaticAudienceExecutor.test.ts +134 -0
- package/src/__tests__/SupabaseContactRepository.test.ts +81 -0
- package/src/builders/QueryBuilder.ts +59 -0
- package/src/engine/V2AudienceEngine.ts +135 -0
|
@@ -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
|