@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,198 @@
|
|
|
1
|
+
import { QueryBuilder } from '../builders/QueryBuilder'
|
|
2
|
+
|
|
3
|
+
function createMockQuery() {
|
|
4
|
+
const calls: Array<{ method: string; args: any[] }> = []
|
|
5
|
+
const handler: ProxyHandler<any> = {
|
|
6
|
+
get(_target, prop) {
|
|
7
|
+
if (prop === '_calls') return calls
|
|
8
|
+
return (...args: any[]) => {
|
|
9
|
+
calls.push({ method: String(prop), args })
|
|
10
|
+
return new Proxy({}, handler)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return new Proxy({}, handler)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('QueryBuilder', () => {
|
|
18
|
+
describe('buildFilters', () => {
|
|
19
|
+
it('adds organization_id and project_id to query', () => {
|
|
20
|
+
const query = createMockQuery()
|
|
21
|
+
QueryBuilder.buildFilters(query, { groups: [] }, 'org-1', 'proj-1')
|
|
22
|
+
const calls = (query as any)._calls
|
|
23
|
+
expect(calls[0]).toEqual({ method: 'eq', args: ['organization_id', 'org-1'] })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('applies groups criteria with OR', () => {
|
|
27
|
+
const query = createMockQuery()
|
|
28
|
+
const criteria = {
|
|
29
|
+
groups: [{
|
|
30
|
+
rules: [
|
|
31
|
+
{ field: 'email', operator: 'equals', value: 'a@b.com' },
|
|
32
|
+
{ field: 'name', operator: 'contains', value: 'John' }
|
|
33
|
+
]
|
|
34
|
+
}]
|
|
35
|
+
}
|
|
36
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
37
|
+
// Should end up calling .or() at some point
|
|
38
|
+
const allCalls = (query as any)._calls
|
|
39
|
+
expect(allCalls.length).toBeGreaterThan(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns query unchanged when no criteria format matches', () => {
|
|
43
|
+
const query = createMockQuery()
|
|
44
|
+
QueryBuilder.buildFilters(query, {} as any, 'org', 'proj')
|
|
45
|
+
const calls = (query as any)._calls
|
|
46
|
+
// Only eq calls for org/proj
|
|
47
|
+
expect(calls[0].method).toBe('eq')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('buildConditionString (via groups)', () => {
|
|
52
|
+
function buildCondition(field: string, operator: string, value: any): string | null {
|
|
53
|
+
const query = createMockQuery()
|
|
54
|
+
const criteria = {
|
|
55
|
+
groups: [{ rules: [{ field, operator, value }] }]
|
|
56
|
+
}
|
|
57
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
58
|
+
// The last call should be or() with the condition string
|
|
59
|
+
const allCalls = (query as any)._calls
|
|
60
|
+
const orCall = allCalls.find((c: any) => c.method === 'or')
|
|
61
|
+
return orCall ? orCall.args[0] : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('builds equals condition', () => {
|
|
65
|
+
expect(buildCondition('email', 'equals', 'a@b.com')).toBe('email.eq.a@b.com')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('builds eq alias condition', () => {
|
|
69
|
+
expect(buildCondition('email', 'eq', 'a@b.com')).toBe('email.eq.a@b.com')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('builds not_equals condition', () => {
|
|
73
|
+
expect(buildCondition('email', 'not_equals', 'x')).toBe('email.neq.x')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('builds contains/ilike condition', () => {
|
|
77
|
+
expect(buildCondition('name', 'contains', 'john')).toBe('name.ilike.*john*')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('builds starts_with condition', () => {
|
|
81
|
+
expect(buildCondition('name', 'starts_with', 'Jo')).toBe('name.ilike.Jo*')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('builds ends_with condition', () => {
|
|
85
|
+
expect(buildCondition('name', 'ends_with', 'hn')).toBe('name.ilike.*hn')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('builds greater_than condition', () => {
|
|
89
|
+
expect(buildCondition('age', 'greater_than', 18)).toBe('age.gt.18')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('builds less_than condition', () => {
|
|
93
|
+
expect(buildCondition('age', 'less_than', 65)).toBe('age.lt.65')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('builds is_empty condition', () => {
|
|
97
|
+
expect(buildCondition('phone', 'is_empty', null)).toBe('phone.is.null')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('builds is_not_empty condition', () => {
|
|
101
|
+
expect(buildCondition('phone', 'is_not_empty', null)).toBe('phone.not.is.null')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns null for unknown operator', () => {
|
|
105
|
+
expect(buildCondition('x', 'unknown_op', 'y')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('returns null when field is missing', () => {
|
|
109
|
+
expect(buildCondition('', 'equals', 'x')).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('maps custom_field with customFieldKey', () => {
|
|
113
|
+
const query = createMockQuery()
|
|
114
|
+
const criteria = {
|
|
115
|
+
groups: [{
|
|
116
|
+
rules: [{ field: 'custom_field', operator: 'equals', value: 'vip', customFieldKey: 'plan' }]
|
|
117
|
+
}]
|
|
118
|
+
}
|
|
119
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
120
|
+
const allCalls = (query as any)._calls
|
|
121
|
+
const orCall = allCalls.find((c: any) => c.method === 'or')
|
|
122
|
+
expect(orCall?.args[0]).toContain('properties->>plan')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('filters format (v1)', () => {
|
|
127
|
+
it('applies OR filter groups', () => {
|
|
128
|
+
const query = createMockQuery()
|
|
129
|
+
const criteria = {
|
|
130
|
+
filters: [{
|
|
131
|
+
id: 'f1',
|
|
132
|
+
operator: 'OR',
|
|
133
|
+
conditions: [
|
|
134
|
+
{ id: 'c1', field: 'email', operator: 'equals', value: 'a@b.com', logicalOperator: 'OR' },
|
|
135
|
+
{ id: 'c2', field: 'name', operator: 'contains', value: 'Jo', logicalOperator: 'OR' }
|
|
136
|
+
]
|
|
137
|
+
}]
|
|
138
|
+
}
|
|
139
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
140
|
+
const allCalls = (query as any)._calls
|
|
141
|
+
const orCall = allCalls.find((c: any) => c.method === 'or')
|
|
142
|
+
expect(orCall).toBeDefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('applies AND filter groups as individual conditions', () => {
|
|
146
|
+
const query = createMockQuery()
|
|
147
|
+
const criteria = {
|
|
148
|
+
filters: [{
|
|
149
|
+
id: 'f1',
|
|
150
|
+
operator: 'AND',
|
|
151
|
+
conditions: [
|
|
152
|
+
{ id: 'c1', field: 'email', operator: 'equals', value: 'a@b.com', logicalOperator: 'AND' }
|
|
153
|
+
]
|
|
154
|
+
}]
|
|
155
|
+
}
|
|
156
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
157
|
+
const allCalls = (query as any)._calls
|
|
158
|
+
const eqCalls = allCalls.filter((c: any) => c.method === 'eq')
|
|
159
|
+
expect(eqCalls.length).toBeGreaterThanOrEqual(2) // org + proj + email
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('conditions format', () => {
|
|
164
|
+
it('applies OR conditions using .or()', () => {
|
|
165
|
+
const query = createMockQuery()
|
|
166
|
+
const criteria = {
|
|
167
|
+
conditions: [{
|
|
168
|
+
groupId: 'g1',
|
|
169
|
+
operator: 'OR',
|
|
170
|
+
conditions: [
|
|
171
|
+
{ field: 'email', operator: 'equals', value: 'a@b.com' }
|
|
172
|
+
]
|
|
173
|
+
}]
|
|
174
|
+
}
|
|
175
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
176
|
+
const allCalls = (query as any)._calls
|
|
177
|
+
const orCall = allCalls.find((c: any) => c.method === 'or')
|
|
178
|
+
expect(orCall).toBeDefined()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('applies AND conditions individually', () => {
|
|
182
|
+
const query = createMockQuery()
|
|
183
|
+
const criteria = {
|
|
184
|
+
conditions: [{
|
|
185
|
+
groupId: 'g1',
|
|
186
|
+
operator: 'AND',
|
|
187
|
+
conditions: [
|
|
188
|
+
{ field: 'is_subscribed', operator: 'eq', value: true }
|
|
189
|
+
]
|
|
190
|
+
}]
|
|
191
|
+
}
|
|
192
|
+
QueryBuilder.buildFilters(query, criteria as any, 'org', 'proj')
|
|
193
|
+
const allCalls = (query as any)._calls
|
|
194
|
+
const eqCalls = allCalls.filter((c: any) => c.method === 'eq')
|
|
195
|
+
expect(eqCalls.length).toBeGreaterThanOrEqual(2)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { RfmEngine, RfmContactRow, RfmEventRow, RfmComputeOptions, RfmScoredContact } from '../engine/RfmEngine'
|
|
2
|
+
|
|
3
|
+
const NOW = new Date('2025-03-01T00:00:00Z')
|
|
4
|
+
|
|
5
|
+
function makeContacts(count: number): RfmContactRow[] {
|
|
6
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
7
|
+
id: `c${i + 1}`,
|
|
8
|
+
reachy_id: `r${i + 1}`,
|
|
9
|
+
email: `user${i + 1}@test.com`,
|
|
10
|
+
phone: `+5511${String(i + 1).padStart(9, '0')}`,
|
|
11
|
+
is_subscribed: true,
|
|
12
|
+
}))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeEvents(contactId: string, eventName: string, dates: string[]): RfmEventRow[] {
|
|
16
|
+
return dates.map(d => ({
|
|
17
|
+
contact_id: contactId,
|
|
18
|
+
event_name: eventName,
|
|
19
|
+
event_timestamp: d,
|
|
20
|
+
}))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('RfmEngine', () => {
|
|
24
|
+
describe('aggregateFromEvents', () => {
|
|
25
|
+
const contacts = makeContacts(3)
|
|
26
|
+
const opts: RfmComputeOptions = {
|
|
27
|
+
from: '2025-01-01T00:00:00Z',
|
|
28
|
+
to: '2025-03-01T00:00:00Z',
|
|
29
|
+
rfEventName: 'purchase',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('aggregates events per contact within time window', () => {
|
|
33
|
+
const events: RfmEventRow[] = [
|
|
34
|
+
...makeEvents('c1', 'purchase', ['2025-01-15', '2025-02-20']),
|
|
35
|
+
...makeEvents('c2', 'purchase', ['2025-02-01']),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
|
|
39
|
+
expect(result).toHaveLength(2)
|
|
40
|
+
|
|
41
|
+
const c1 = result.find(r => r.contact_id === 'c1')!
|
|
42
|
+
expect(c1.event_count).toBe(2)
|
|
43
|
+
expect(c1.last_ts).toEqual(new Date('2025-02-20'))
|
|
44
|
+
|
|
45
|
+
const c2 = result.find(r => r.contact_id === 'c2')!
|
|
46
|
+
expect(c2.event_count).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('filters events by rfEventName', () => {
|
|
50
|
+
const events: RfmEventRow[] = [
|
|
51
|
+
...makeEvents('c1', 'purchase', ['2025-01-15']),
|
|
52
|
+
...makeEvents('c1', 'page_view', ['2025-01-16']),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
|
|
56
|
+
expect(result).toHaveLength(1)
|
|
57
|
+
expect(result[0].event_count).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('excludes events outside time window', () => {
|
|
61
|
+
const events: RfmEventRow[] = [
|
|
62
|
+
...makeEvents('c1', 'purchase', ['2024-12-31']), // before from
|
|
63
|
+
...makeEvents('c1', 'purchase', ['2025-03-02']), // after to
|
|
64
|
+
...makeEvents('c1', 'purchase', ['2025-02-01']), // within
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
|
|
68
|
+
expect(result).toHaveLength(1)
|
|
69
|
+
expect(result[0].event_count).toBe(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('resolves contact via reachy_id when contact_id is missing', () => {
|
|
73
|
+
const events: RfmEventRow[] = [
|
|
74
|
+
{ reachy_id: 'r1', event_name: 'purchase', event_timestamp: '2025-02-01' },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
|
|
78
|
+
expect(result).toHaveLength(1)
|
|
79
|
+
expect(result[0].contact_id).toBe('c1')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('ignores events with no resolvable contact', () => {
|
|
83
|
+
const events: RfmEventRow[] = [
|
|
84
|
+
{ contact_id: 'nonexistent', event_name: 'purchase', event_timestamp: '2025-02-01' },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const result = RfmEngine.aggregateFromEvents(contacts, events, opts)
|
|
88
|
+
expect(result).toHaveLength(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('filters by filterContactIds when provided', () => {
|
|
92
|
+
const events: RfmEventRow[] = [
|
|
93
|
+
...makeEvents('c1', 'purchase', ['2025-02-01']),
|
|
94
|
+
...makeEvents('c2', 'purchase', ['2025-02-01']),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const result = RfmEngine.aggregateFromEvents(contacts, events, {
|
|
98
|
+
...opts,
|
|
99
|
+
filterContactIds: new Set(['c1']),
|
|
100
|
+
})
|
|
101
|
+
expect(result).toHaveLength(1)
|
|
102
|
+
expect(result[0].contact_id).toBe('c1')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns empty array for empty events', () => {
|
|
106
|
+
const result = RfmEngine.aggregateFromEvents(contacts, [], opts)
|
|
107
|
+
expect(result).toHaveLength(0)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns empty array for empty contacts', () => {
|
|
111
|
+
const events = makeEvents('c1', 'purchase', ['2025-02-01'])
|
|
112
|
+
const result = RfmEngine.aggregateFromEvents([], events, opts)
|
|
113
|
+
expect(result).toHaveLength(0)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('scoreAggregates', () => {
|
|
118
|
+
const contacts = makeContacts(5)
|
|
119
|
+
|
|
120
|
+
it('assigns R and F scores 1-5 based on percentiles', () => {
|
|
121
|
+
const aggregates = [
|
|
122
|
+
{ contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 50 }, // very recent, high freq
|
|
123
|
+
{ contact_id: 'c2', last_ts: new Date('2025-02-20'), event_count: 30 },
|
|
124
|
+
{ contact_id: 'c3', last_ts: new Date('2025-02-01'), event_count: 10 },
|
|
125
|
+
{ contact_id: 'c4', last_ts: new Date('2025-01-15'), event_count: 5 },
|
|
126
|
+
{ contact_id: 'c5', last_ts: new Date('2025-01-01'), event_count: 1 }, // oldest, lowest freq
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
const { scored, r_days_cuts, f_count_cuts } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW })
|
|
130
|
+
|
|
131
|
+
expect(scored).toHaveLength(5)
|
|
132
|
+
expect(r_days_cuts).toHaveLength(4)
|
|
133
|
+
expect(f_count_cuts).toHaveLength(4)
|
|
134
|
+
|
|
135
|
+
// Most recent contact should have highest R score
|
|
136
|
+
const c1 = scored.find(s => s.contact_id === 'c1')!
|
|
137
|
+
expect(c1.r_score).toBe(5)
|
|
138
|
+
expect(c1.f_score).toBe(5)
|
|
139
|
+
|
|
140
|
+
// Oldest with lowest freq should have lowest scores
|
|
141
|
+
const c5 = scored.find(s => s.contact_id === 'c5')!
|
|
142
|
+
expect(c5.r_score).toBe(1)
|
|
143
|
+
expect(c5.f_score).toBe(1)
|
|
144
|
+
|
|
145
|
+
// All should have a valid segment key
|
|
146
|
+
for (const s of scored) {
|
|
147
|
+
expect(s.segment_key).toBeTruthy()
|
|
148
|
+
expect([1, 2, 3, 4, 5]).toContain(s.r_score)
|
|
149
|
+
expect([1, 2, 3, 4, 5]).toContain(s.f_score)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('computes segment_key as "new_users" for single high-recency low-freq contact', () => {
|
|
154
|
+
// With only 1 contact, percentile puts it at R=5, F=1 → new_users
|
|
155
|
+
const aggregates = [
|
|
156
|
+
{ contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 100 },
|
|
157
|
+
]
|
|
158
|
+
const { scored } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW })
|
|
159
|
+
// Single element: R=5 (most recent), F=1 (single bucket) → R=5,F=1 → new_users
|
|
160
|
+
expect(scored[0].r_score).toBe(5)
|
|
161
|
+
expect(scored[0].f_score).toBe(1)
|
|
162
|
+
expect(scored[0].segment_key).toBe('new_users')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('determines email_ok and sms_ok from contact data', () => {
|
|
166
|
+
const contactsWithPrefs: RfmContactRow[] = [
|
|
167
|
+
{ id: 'c1', email: 'a@b.com', phone: '+5511999', is_subscribed: true },
|
|
168
|
+
{ id: 'c2', email: '', phone: '', is_subscribed: false },
|
|
169
|
+
]
|
|
170
|
+
const aggregates = [
|
|
171
|
+
{ contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 10 },
|
|
172
|
+
{ contact_id: 'c2', last_ts: new Date('2025-02-28'), event_count: 5 },
|
|
173
|
+
]
|
|
174
|
+
const { scored } = RfmEngine.scoreAggregates(contactsWithPrefs, aggregates, { to: NOW })
|
|
175
|
+
|
|
176
|
+
const c1 = scored.find(s => s.contact_id === 'c1')!
|
|
177
|
+
expect(c1.email_ok).toBe(1)
|
|
178
|
+
expect(c1.sms_ok).toBe(1)
|
|
179
|
+
|
|
180
|
+
const c2 = scored.find(s => s.contact_id === 'c2')!
|
|
181
|
+
expect(c2.email_ok).toBe(0) // is_subscribed=false
|
|
182
|
+
expect(c2.sms_ok).toBe(0)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('skips aggregates for contacts not in contact list', () => {
|
|
186
|
+
const aggregates = [
|
|
187
|
+
{ contact_id: 'c1', last_ts: new Date('2025-02-28'), event_count: 10 },
|
|
188
|
+
{ contact_id: 'nonexistent', last_ts: new Date('2025-02-28'), event_count: 5 },
|
|
189
|
+
]
|
|
190
|
+
const { scored } = RfmEngine.scoreAggregates(contacts, aggregates, { to: NOW })
|
|
191
|
+
expect(scored).toHaveLength(1)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('thresholdsFromScored', () => {
|
|
196
|
+
it('extracts min/max per R and F score', () => {
|
|
197
|
+
const scored: RfmScoredContact[] = [
|
|
198
|
+
{ contact_id: 'c1', last_ts: new Date(), event_count: 50, recency_days: 1, r_score: 5, f_score: 5, segment_key: 'champions', email_ok: 1, sms_ok: 1 },
|
|
199
|
+
{ contact_id: 'c2', last_ts: new Date(), event_count: 30, recency_days: 3, r_score: 5, f_score: 4, segment_key: 'champions', email_ok: 1, sms_ok: 1 },
|
|
200
|
+
{ contact_id: 'c3', last_ts: new Date(), event_count: 1, recency_days: 90, r_score: 1, f_score: 1, segment_key: 'hibernating', email_ok: 1, sms_ok: 0 },
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
const thresholds = RfmEngine.thresholdsFromScored(scored)
|
|
204
|
+
|
|
205
|
+
expect(thresholds.length).toBeGreaterThan(0)
|
|
206
|
+
|
|
207
|
+
// Sorted by kind (f before r), then by score
|
|
208
|
+
const first = thresholds[0]
|
|
209
|
+
expect(first.kind).toBe('f')
|
|
210
|
+
|
|
211
|
+
// R score 5 should have min=1, max=3
|
|
212
|
+
const r5 = thresholds.find(t => t.kind === 'r' && t.score === 5)
|
|
213
|
+
expect(r5).toBeDefined()
|
|
214
|
+
expect(r5!.min_value).toBe(1)
|
|
215
|
+
expect(r5!.max_value).toBe(3)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('returns empty array for empty scored', () => {
|
|
219
|
+
expect(RfmEngine.thresholdsFromScored([])).toEqual([])
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('previewFromScored', () => {
|
|
224
|
+
it('returns all 10 segments with counts and percentages', () => {
|
|
225
|
+
const scored: RfmScoredContact[] = [
|
|
226
|
+
{ contact_id: 'c1', last_ts: new Date(), event_count: 50, recency_days: 1, r_score: 5, f_score: 5, segment_key: 'champions', email_ok: 1, sms_ok: 1 },
|
|
227
|
+
{ contact_id: 'c2', last_ts: new Date(), event_count: 1, recency_days: 90, r_score: 1, f_score: 1, segment_key: 'hibernating', email_ok: 0, sms_ok: 0 },
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
const preview = RfmEngine.previewFromScored(scored)
|
|
231
|
+
expect(preview).toHaveLength(10)
|
|
232
|
+
|
|
233
|
+
const champions = preview.find(p => p.segment_key === 'champions')!
|
|
234
|
+
expect(champions.users).toBe(1)
|
|
235
|
+
expect(champions.percent).toBe(50)
|
|
236
|
+
expect(champions.email_reachability).toBe(1)
|
|
237
|
+
|
|
238
|
+
const hibernating = preview.find(p => p.segment_key === 'hibernating')!
|
|
239
|
+
expect(hibernating.users).toBe(1)
|
|
240
|
+
expect(hibernating.email_reachability).toBe(0)
|
|
241
|
+
|
|
242
|
+
// All other segments should have 0 users
|
|
243
|
+
const empties = preview.filter(p => !['champions', 'hibernating'].includes(p.segment_key))
|
|
244
|
+
for (const seg of empties) {
|
|
245
|
+
expect(seg.users).toBe(0)
|
|
246
|
+
expect(seg.percent).toBe(0)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('returns all zeros for empty scored', () => {
|
|
251
|
+
const preview = RfmEngine.previewFromScored([])
|
|
252
|
+
expect(preview).toHaveLength(10)
|
|
253
|
+
for (const seg of preview) {
|
|
254
|
+
expect(seg.users).toBe(0)
|
|
255
|
+
expect(seg.percent).toBe(0)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('computeFromEvents', () => {
|
|
261
|
+
it('orchestrates aggregate → score → thresholds → preview', () => {
|
|
262
|
+
const contacts = makeContacts(3)
|
|
263
|
+
const events: RfmEventRow[] = [
|
|
264
|
+
...makeEvents('c1', 'purchase', ['2025-02-28', '2025-02-25', '2025-02-20']),
|
|
265
|
+
...makeEvents('c2', 'purchase', ['2025-01-15']),
|
|
266
|
+
...makeEvents('c3', 'purchase', ['2025-01-01', '2025-01-02']),
|
|
267
|
+
]
|
|
268
|
+
const opts: RfmComputeOptions = {
|
|
269
|
+
from: '2025-01-01',
|
|
270
|
+
to: '2025-03-01',
|
|
271
|
+
rfEventName: 'purchase',
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { segments, thresholds, scored } = RfmEngine.computeFromEvents(contacts, events, opts)
|
|
275
|
+
|
|
276
|
+
expect(scored).toHaveLength(3)
|
|
277
|
+
expect(segments).toHaveLength(10)
|
|
278
|
+
expect(thresholds.length).toBeGreaterThan(0)
|
|
279
|
+
|
|
280
|
+
const totalUsers = segments.reduce((sum, s) => sum + s.users, 0)
|
|
281
|
+
expect(totalUsers).toBe(3)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
})
|
|
@@ -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
|
+
})
|