@reachy/audience-module 1.0.18 → 1.0.20

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.
Files changed (34) hide show
  1. package/.gitlab/merge_request_templates/Default.md +31 -0
  2. package/.gitlab-ci.yml +59 -49
  3. package/CLAUDE.md +134 -0
  4. package/dist/AudienceModule.d.ts.map +1 -1
  5. package/dist/AudienceModule.js +1 -0
  6. package/dist/AudienceModule.js.map +1 -1
  7. package/dist/engine/V2AudienceEngine.d.ts +5 -0
  8. package/dist/engine/V2AudienceEngine.d.ts.map +1 -1
  9. package/dist/engine/V2AudienceEngine.js +210 -72
  10. package/dist/engine/V2AudienceEngine.js.map +1 -1
  11. package/dist/executors/ClickHouseEventQueryExecutor.d.ts +23 -0
  12. package/dist/executors/ClickHouseEventQueryExecutor.d.ts.map +1 -0
  13. package/dist/executors/ClickHouseEventQueryExecutor.js +803 -0
  14. package/dist/executors/ClickHouseEventQueryExecutor.js.map +1 -0
  15. package/dist/repositories/SupabaseContactRepository.d.ts +1 -0
  16. package/dist/repositories/SupabaseContactRepository.d.ts.map +1 -1
  17. package/dist/repositories/SupabaseContactRepository.js +1 -0
  18. package/dist/repositories/SupabaseContactRepository.js.map +1 -1
  19. package/dist/types/index.d.ts +1 -0
  20. package/dist/types/index.d.ts.map +1 -1
  21. package/jest.config.js +8 -0
  22. package/package.json +7 -2
  23. package/src/AudienceModule.ts +1 -0
  24. package/src/__tests__/AudienceModule.test.ts +382 -0
  25. package/src/__tests__/CriteriaParser.test.ts +130 -0
  26. package/src/__tests__/QueryBuilder.test.ts +198 -0
  27. package/src/__tests__/RfmEngine.test.ts +284 -0
  28. package/src/__tests__/RfmSegmentBuilder.test.ts +210 -0
  29. package/src/__tests__/StaticAudienceExecutor.test.ts +134 -0
  30. package/src/__tests__/SupabaseContactRepository.test.ts +81 -0
  31. package/src/engine/V2AudienceEngine.ts +240 -85
  32. package/src/executors/ClickHouseEventQueryExecutor.ts +853 -0
  33. package/src/repositories/SupabaseContactRepository.ts +2 -1
  34. package/src/types/index.ts +6 -0
@@ -0,0 +1,210 @@
1
+ import { RfmSegmentBuilder, RfmThresholdRowLike } from '../builders/RfmSegmentBuilder'
2
+
3
+ describe('RfmSegmentBuilder', () => {
4
+ describe('getSegmentScoreRanges', () => {
5
+ const expected: Record<string, { r: { minScore: number; maxScore: number }; f: { minScore: number; maxScore: number } }> = {
6
+ champions: { r: { minScore: 4, maxScore: 5 }, f: { minScore: 4, maxScore: 5 } },
7
+ loyal_users: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 4, maxScore: 5 } },
8
+ potential_loyalists: { r: { minScore: 4, maxScore: 5 }, f: { minScore: 2, maxScore: 3 } },
9
+ new_users: { r: { minScore: 5, maxScore: 5 }, f: { minScore: 1, maxScore: 1 } },
10
+ promising: { r: { minScore: 4, maxScore: 4 }, f: { minScore: 1, maxScore: 1 } },
11
+ needing_attention: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 3, maxScore: 3 } },
12
+ about_to_sleep: { r: { minScore: 3, maxScore: 3 }, f: { minScore: 1, maxScore: 2 } },
13
+ cannot_lose_them: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 5, maxScore: 5 } },
14
+ at_risk: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 3, maxScore: 4 } },
15
+ hibernating: { r: { minScore: 1, maxScore: 2 }, f: { minScore: 1, maxScore: 2 } },
16
+ }
17
+
18
+ it.each(Object.entries(expected))('returns correct ranges for "%s"', (key, ranges) => {
19
+ expect(RfmSegmentBuilder.getSegmentScoreRanges(key)).toEqual(ranges)
20
+ })
21
+
22
+ it('returns null for unknown segment key', () => {
23
+ expect(RfmSegmentBuilder.getSegmentScoreRanges('unknown')).toBeNull()
24
+ })
25
+
26
+ it('returns null for empty string', () => {
27
+ expect(RfmSegmentBuilder.getSegmentScoreRanges('')).toBeNull()
28
+ })
29
+
30
+ it('handles whitespace in key', () => {
31
+ expect(RfmSegmentBuilder.getSegmentScoreRanges(' champions ')).toEqual(expected.champions)
32
+ })
33
+ })
34
+
35
+ describe('getSegmentName', () => {
36
+ it('returns "Champions" for "champions"', () => {
37
+ expect(RfmSegmentBuilder.getSegmentName('champions')).toBe('Champions')
38
+ })
39
+
40
+ it('returns "At Risk" for "at_risk"', () => {
41
+ expect(RfmSegmentBuilder.getSegmentName('at_risk')).toBe('At Risk')
42
+ })
43
+
44
+ it('returns the key itself for unknown segment', () => {
45
+ expect(RfmSegmentBuilder.getSegmentName('custom_segment')).toBe('custom_segment')
46
+ })
47
+
48
+ it('returns "Segment" for empty/null input', () => {
49
+ expect(RfmSegmentBuilder.getSegmentName('')).toBe('Segment')
50
+ expect(RfmSegmentBuilder.getSegmentName(null as any)).toBe('Segment')
51
+ })
52
+ })
53
+
54
+ describe('clampInt', () => {
55
+ it('clamps within range', () => {
56
+ expect(RfmSegmentBuilder.clampInt(5, 1, 10)).toBe(5)
57
+ })
58
+
59
+ it('clamps below min', () => {
60
+ expect(RfmSegmentBuilder.clampInt(-1, 0, 10)).toBe(0)
61
+ })
62
+
63
+ it('clamps above max', () => {
64
+ expect(RfmSegmentBuilder.clampInt(100, 0, 10)).toBe(10)
65
+ })
66
+
67
+ it('truncates decimals', () => {
68
+ expect(RfmSegmentBuilder.clampInt(5.9, 1, 10)).toBe(5)
69
+ })
70
+
71
+ it('returns min for NaN', () => {
72
+ expect(RfmSegmentBuilder.clampInt(NaN, 1, 10)).toBe(1)
73
+ })
74
+
75
+ it('returns min for Infinity', () => {
76
+ expect(RfmSegmentBuilder.clampInt(Infinity, 1, 10)).toBe(1)
77
+ })
78
+ })
79
+
80
+ describe('buildDefinition', () => {
81
+ const thresholds: RfmThresholdRowLike[] = [
82
+ // Recency thresholds (score → max_days)
83
+ { kind: 'r', score: 5, min_value: 0, max_value: 7 },
84
+ { kind: 'r', score: 4, min_value: 8, max_value: 14 },
85
+ { kind: 'r', score: 3, min_value: 15, max_value: 30 },
86
+ { kind: 'r', score: 2, min_value: 31, max_value: 60 },
87
+ { kind: 'r', score: 1, min_value: 61, max_value: 90 },
88
+ // Frequency thresholds (score → min/max count)
89
+ { kind: 'f', score: 1, min_value: 1, max_value: 2 },
90
+ { kind: 'f', score: 2, min_value: 3, max_value: 5 },
91
+ { kind: 'f', score: 3, min_value: 6, max_value: 10 },
92
+ { kind: 'f', score: 4, min_value: 11, max_value: 20 },
93
+ { kind: 'f', score: 5, min_value: 21, max_value: 50 },
94
+ ]
95
+
96
+ it('builds a valid definition for champions', () => {
97
+ const def = RfmSegmentBuilder.buildDefinition({
98
+ rfEventName: 'purchase',
99
+ windowDays: 90,
100
+ segmentKey: 'champions',
101
+ thresholds,
102
+ })
103
+
104
+ expect(def.segment_key).toBe('champions')
105
+ expect(def.segment_name).toBe('Champions')
106
+ expect(def.window_days).toBe(90)
107
+ expect(def.criteria).toBeDefined()
108
+ expect(def.criteria.type).toBe('past-behavior')
109
+ expect(def.criteria.groups).toBeDefined()
110
+ expect(def.criteria.groups.length).toBeGreaterThanOrEqual(1)
111
+
112
+ // Frequency: score 4-5 → op >= minCount
113
+ expect(def.frequency.op).toBe('>=')
114
+ expect(def.frequency.value).toBeGreaterThanOrEqual(1)
115
+ })
116
+
117
+ it('builds a valid definition for hibernating (low R, low F)', () => {
118
+ const def = RfmSegmentBuilder.buildDefinition({
119
+ rfEventName: 'purchase',
120
+ windowDays: 90,
121
+ segmentKey: 'hibernating',
122
+ thresholds,
123
+ })
124
+
125
+ expect(def.segment_key).toBe('hibernating')
126
+ expect(def.frequency.op).toBe('<=')
127
+ })
128
+
129
+ it('builds a definition with between frequency for mid-range segments', () => {
130
+ const def = RfmSegmentBuilder.buildDefinition({
131
+ rfEventName: 'purchase',
132
+ windowDays: 90,
133
+ segmentKey: 'at_risk',
134
+ thresholds,
135
+ })
136
+
137
+ expect(def.frequency.op).toBe('between')
138
+ expect(def.frequency.value2).not.toBeNull()
139
+ })
140
+
141
+ it('throws for invalid segment key', () => {
142
+ expect(() => RfmSegmentBuilder.buildDefinition({
143
+ rfEventName: 'purchase',
144
+ windowDays: 90,
145
+ segmentKey: 'nonexistent',
146
+ thresholds,
147
+ })).toThrow('segment_key inválido')
148
+ })
149
+
150
+ it('throws when no recency thresholds match the range', () => {
151
+ expect(() => RfmSegmentBuilder.buildDefinition({
152
+ rfEventName: 'purchase',
153
+ windowDays: 90,
154
+ segmentKey: 'champions',
155
+ thresholds: thresholds.filter(t => t.kind === 'f'), // no recency
156
+ })).toThrow()
157
+ })
158
+
159
+ it('throws when no frequency thresholds match the range', () => {
160
+ expect(() => RfmSegmentBuilder.buildDefinition({
161
+ rfEventName: 'purchase',
162
+ windowDays: 90,
163
+ segmentKey: 'champions',
164
+ thresholds: thresholds.filter(t => t.kind === 'r'), // no frequency
165
+ })).toThrow()
166
+ })
167
+
168
+ it('includes recency negate rule when rMax < 5', () => {
169
+ const def = RfmSegmentBuilder.buildDefinition({
170
+ rfEventName: 'purchase',
171
+ windowDays: 90,
172
+ segmentKey: 'hibernating', // r: 1-2
173
+ thresholds,
174
+ })
175
+
176
+ const rfmGroup = def.criteria.groups[0]
177
+ const negateRule = rfmGroup.rules.find((r: any) => r.negate === true)
178
+ expect(negateRule).toBeDefined()
179
+ })
180
+
181
+ it('does not include negate rule when rMax >= 5', () => {
182
+ const def = RfmSegmentBuilder.buildDefinition({
183
+ rfEventName: 'purchase',
184
+ windowDays: 90,
185
+ segmentKey: 'champions', // r: 4-5
186
+ thresholds,
187
+ })
188
+
189
+ const rfmGroup = def.criteria.groups[0]
190
+ const negateRule = rfmGroup.rules.find((r: any) => r.negate === true)
191
+ expect(negateRule).toBeUndefined()
192
+ })
193
+
194
+ it('merges filterCriteria groups when provided', () => {
195
+ const filterCriteria = {
196
+ groups: [{ operator: 'AND', rules: [{ kind: 'property', field: 'is_subscribed', op: 'equals', value: true }] }]
197
+ }
198
+ const def = RfmSegmentBuilder.buildDefinition({
199
+ rfEventName: 'purchase',
200
+ windowDays: 90,
201
+ segmentKey: 'champions',
202
+ thresholds,
203
+ filterCriteria,
204
+ })
205
+
206
+ // Should have the filter group + rfm group
207
+ expect(def.criteria.groups.length).toBe(2)
208
+ })
209
+ })
210
+ })
@@ -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
+ })