@reachy/audience-module 1.0.16 → 1.0.18
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/CODEOWNERS +1 -0
- package/dist/builders/RfmSegmentBuilder.d.ts +44 -0
- package/dist/builders/RfmSegmentBuilder.d.ts.map +1 -0
- package/dist/builders/RfmSegmentBuilder.js +232 -0
- package/dist/builders/RfmSegmentBuilder.js.map +1 -0
- package/dist/engine/RfmEngine.d.ts +67 -0
- package/dist/engine/RfmEngine.d.ts.map +1 -0
- package/dist/engine/RfmEngine.js +335 -0
- package/dist/engine/RfmEngine.js.map +1 -0
- package/dist/engine/V2AudienceEngine.d.ts.map +1 -1
- package/dist/engine/V2AudienceEngine.js +30 -14
- package/dist/engine/V2AudienceEngine.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/builders/RfmSegmentBuilder.ts +279 -0
- package/src/engine/RfmEngine.ts +418 -0
- package/src/engine/V2AudienceEngine.ts +34 -17
- package/src/index.ts +2 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
export type RfmThresholdRowLike = {
|
|
2
|
+
kind: 'r' | 'f' | string
|
|
3
|
+
score: number
|
|
4
|
+
min_value: number
|
|
5
|
+
max_value: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RfmRecencyDefinition = {
|
|
9
|
+
within_days: number
|
|
10
|
+
exclude_within_days?: number | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RfmFrequencyDefinition = {
|
|
14
|
+
op: string
|
|
15
|
+
value: number
|
|
16
|
+
value2?: number | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RfmSegmentDefinition = {
|
|
20
|
+
segment_key: string
|
|
21
|
+
segment_name: string
|
|
22
|
+
window_days: number
|
|
23
|
+
recency: RfmRecencyDefinition
|
|
24
|
+
frequency: RfmFrequencyDefinition
|
|
25
|
+
criteria: any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ScoreRange = { minScore: number; maxScore: number }
|
|
29
|
+
|
|
30
|
+
export class RfmSegmentBuilder {
|
|
31
|
+
static getSegmentScoreRanges(segmentKey: string): { r: ScoreRange; f: ScoreRange } | null {
|
|
32
|
+
const k = String(segmentKey || '').trim()
|
|
33
|
+
switch (k) {
|
|
34
|
+
case 'champions':
|
|
35
|
+
return { r: { minScore: 4, maxScore: 5 }, f: { minScore: 4, maxScore: 5 } }
|
|
36
|
+
case 'loyal_users':
|
|
37
|
+
return { r: { minScore: 3, maxScore: 3 }, f: { minScore: 4, maxScore: 5 } }
|
|
38
|
+
case 'potential_loyalists':
|
|
39
|
+
return { r: { minScore: 4, maxScore: 5 }, f: { minScore: 2, maxScore: 3 } }
|
|
40
|
+
case 'new_users':
|
|
41
|
+
return { r: { minScore: 5, maxScore: 5 }, f: { minScore: 1, maxScore: 1 } }
|
|
42
|
+
case 'promising':
|
|
43
|
+
return { r: { minScore: 4, maxScore: 4 }, f: { minScore: 1, maxScore: 1 } }
|
|
44
|
+
case 'needing_attention':
|
|
45
|
+
return { r: { minScore: 3, maxScore: 3 }, f: { minScore: 3, maxScore: 3 } }
|
|
46
|
+
case 'about_to_sleep':
|
|
47
|
+
return { r: { minScore: 3, maxScore: 3 }, f: { minScore: 1, maxScore: 2 } }
|
|
48
|
+
case 'cannot_lose_them':
|
|
49
|
+
return { r: { minScore: 1, maxScore: 2 }, f: { minScore: 5, maxScore: 5 } }
|
|
50
|
+
case 'at_risk':
|
|
51
|
+
return { r: { minScore: 1, maxScore: 2 }, f: { minScore: 3, maxScore: 4 } }
|
|
52
|
+
case 'hibernating':
|
|
53
|
+
return { r: { minScore: 1, maxScore: 2 }, f: { minScore: 1, maxScore: 2 } }
|
|
54
|
+
default:
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static getSegmentName(segmentKey: string): string {
|
|
60
|
+
const map: Record<string, string> = {
|
|
61
|
+
champions: 'Champions',
|
|
62
|
+
loyal_users: 'Loyal Users',
|
|
63
|
+
potential_loyalists: 'Potential Loyalists',
|
|
64
|
+
new_users: 'New Users',
|
|
65
|
+
promising: 'Promising',
|
|
66
|
+
needing_attention: 'Needing Attention',
|
|
67
|
+
about_to_sleep: 'About to Sleep',
|
|
68
|
+
at_risk: 'At Risk',
|
|
69
|
+
cannot_lose_them: 'Cannot Lose Them',
|
|
70
|
+
hibernating: 'Hibernating',
|
|
71
|
+
}
|
|
72
|
+
return map[String(segmentKey || '').trim()] || String(segmentKey || '').trim() || 'Segment'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static clampInt(n: number, min: number, max: number): number {
|
|
76
|
+
const x = Number.isFinite(n) ? Math.trunc(n) : min
|
|
77
|
+
return Math.max(min, Math.min(max, x))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static buildDefinition(params: {
|
|
81
|
+
rfEventName: string
|
|
82
|
+
windowDays: number
|
|
83
|
+
segmentKey: string
|
|
84
|
+
thresholds: RfmThresholdRowLike[]
|
|
85
|
+
filterCriteria?: any | null
|
|
86
|
+
}): RfmSegmentDefinition {
|
|
87
|
+
const { rfEventName, windowDays, segmentKey, thresholds, filterCriteria } = params
|
|
88
|
+
const ranges = this.getSegmentScoreRanges(segmentKey)
|
|
89
|
+
if (!ranges) throw new Error('segment_key inválido')
|
|
90
|
+
|
|
91
|
+
const rMaxDays = new Map<number, number>()
|
|
92
|
+
const fMinCount = new Map<number, number>()
|
|
93
|
+
const fMaxCount = new Map<number, number>()
|
|
94
|
+
|
|
95
|
+
for (const row of thresholds || []) {
|
|
96
|
+
const score = Number(row.score)
|
|
97
|
+
if (!Number.isFinite(score)) continue
|
|
98
|
+
if (row.kind === 'r') {
|
|
99
|
+
const maxV = Number(row.max_value)
|
|
100
|
+
if (Number.isFinite(maxV)) rMaxDays.set(score, Math.max(0, Math.trunc(maxV)))
|
|
101
|
+
} else if (row.kind === 'f') {
|
|
102
|
+
const minV = Number(row.min_value)
|
|
103
|
+
const maxV = Number(row.max_value)
|
|
104
|
+
if (Number.isFinite(minV)) fMinCount.set(score, Math.max(0, Math.trunc(minV)))
|
|
105
|
+
if (Number.isFinite(maxV)) fMaxCount.set(score, Math.max(0, Math.trunc(maxV)))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Recency bounds
|
|
110
|
+
const rMin = ranges.r.minScore
|
|
111
|
+
const rMax = ranges.r.maxScore
|
|
112
|
+
|
|
113
|
+
const recencyUpperRaw = (() => {
|
|
114
|
+
// Para os piores scores (1..2), a regra de recência é dominada pelo "NOT within",
|
|
115
|
+
// então o upper bound pode ser a própria janela.
|
|
116
|
+
if (rMin <= 1) return windowDays
|
|
117
|
+
|
|
118
|
+
// Para ranges como 4..5, pode acontecer de um score intermediário não existir
|
|
119
|
+
// (por empates/quantis). Nesse caso, usar o primeiro score disponível no range.
|
|
120
|
+
for (let s = rMin; s <= rMax; s++) {
|
|
121
|
+
const v = rMaxDays.get(s)
|
|
122
|
+
if (v != null) return v
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`Sem thresholds para recency score ${rMin}..${rMax}`)
|
|
125
|
+
})()
|
|
126
|
+
const recencyUpper = this.clampInt(recencyUpperRaw, 1, Math.max(1, windowDays))
|
|
127
|
+
|
|
128
|
+
const recencyLowerRaw = (() => {
|
|
129
|
+
if (rMax >= 5) return null
|
|
130
|
+
for (let s = rMax + 1; s <= 5; s++) {
|
|
131
|
+
const v = rMaxDays.get(s)
|
|
132
|
+
if (v != null) return v
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
})()
|
|
136
|
+
const recencyLower =
|
|
137
|
+
recencyLowerRaw == null ? null : this.clampInt(recencyLowerRaw, 1, Math.max(1, windowDays))
|
|
138
|
+
|
|
139
|
+
// Frequency bounds
|
|
140
|
+
const fMin = ranges.f.minScore
|
|
141
|
+
const fMax = ranges.f.maxScore
|
|
142
|
+
|
|
143
|
+
let hasAnyFreqThreshold = false
|
|
144
|
+
for (let s = fMin; s <= fMax; s++) {
|
|
145
|
+
if (fMinCount.has(s) || fMaxCount.has(s)) {
|
|
146
|
+
hasAnyFreqThreshold = true
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!hasAnyFreqThreshold) {
|
|
151
|
+
throw new Error(`Sem thresholds para frequency score ${fMin}..${fMax}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const collectMin = () => {
|
|
155
|
+
let min = Number.POSITIVE_INFINITY
|
|
156
|
+
for (let s = fMin; s <= fMax; s++) {
|
|
157
|
+
const v = fMinCount.get(s) ?? fMaxCount.get(s)
|
|
158
|
+
if (v == null) continue
|
|
159
|
+
if (v < min) min = v
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isFinite(min)) {
|
|
162
|
+
throw new Error(`Sem thresholds mínimos para frequency score ${fMin}..${fMax}`)
|
|
163
|
+
}
|
|
164
|
+
return min
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const collectMax = () => {
|
|
168
|
+
let max = 0
|
|
169
|
+
for (let s = fMin; s <= fMax; s++) {
|
|
170
|
+
const v = fMaxCount.get(s) ?? fMinCount.get(s)
|
|
171
|
+
if (v == null) continue
|
|
172
|
+
if (v > max) max = v
|
|
173
|
+
}
|
|
174
|
+
if (!(max > 0)) {
|
|
175
|
+
throw new Error(`Sem thresholds máximos para frequency score ${fMin}..${fMax}`)
|
|
176
|
+
}
|
|
177
|
+
return max
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const minCount = Math.max(1, Math.trunc(collectMin()))
|
|
181
|
+
const maxCount = Math.max(minCount, Math.trunc(collectMax()))
|
|
182
|
+
|
|
183
|
+
const frequency: { op: string; value: number; value2: number | null } = (() => {
|
|
184
|
+
if (fMax === 5 && fMin >= 4) return { op: '>=', value: minCount, value2: null }
|
|
185
|
+
if (fMin === 5 && fMax === 5) return { op: '>=', value: minCount, value2: null }
|
|
186
|
+
if (fMin === 1 && fMax <= 2) return { op: '<=', value: maxCount, value2: null }
|
|
187
|
+
if (fMin === 1 && fMax === 1) return { op: '<=', value: maxCount, value2: null }
|
|
188
|
+
return { op: 'between', value: minCount, value2: maxCount }
|
|
189
|
+
})()
|
|
190
|
+
|
|
191
|
+
const rfmGroup: any = {
|
|
192
|
+
operator: 'AND',
|
|
193
|
+
rules: [
|
|
194
|
+
// Frequency bucket in the analysis window
|
|
195
|
+
{
|
|
196
|
+
kind: 'event',
|
|
197
|
+
eventName: rfEventName,
|
|
198
|
+
time: { unit: 'days', value: windowDays },
|
|
199
|
+
frequency: {
|
|
200
|
+
type: 'count',
|
|
201
|
+
op: frequency.op,
|
|
202
|
+
value: frequency.value,
|
|
203
|
+
...(frequency.op === 'between' ? { value2: frequency.value2 } : {}),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
// Recency upper bound (must have done within this window)
|
|
207
|
+
{
|
|
208
|
+
kind: 'event',
|
|
209
|
+
eventName: rfEventName,
|
|
210
|
+
time: { unit: 'days', value: recencyUpper },
|
|
211
|
+
},
|
|
212
|
+
// Recency lower bound (exclude too-recent users)
|
|
213
|
+
...(recencyLower
|
|
214
|
+
? [
|
|
215
|
+
{
|
|
216
|
+
kind: 'event',
|
|
217
|
+
eventName: rfEventName,
|
|
218
|
+
time: { unit: 'days', value: recencyLower },
|
|
219
|
+
negate: true,
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
223
|
+
],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const filterGroupsRaw =
|
|
227
|
+
filterCriteria &&
|
|
228
|
+
typeof filterCriteria === 'object' &&
|
|
229
|
+
Array.isArray((filterCriteria as any).groups)
|
|
230
|
+
? (filterCriteria as any).groups
|
|
231
|
+
: []
|
|
232
|
+
|
|
233
|
+
const mergedGroups = (() => {
|
|
234
|
+
const base = Array.isArray(filterGroupsRaw) ? [...filterGroupsRaw] : []
|
|
235
|
+
if (base.length === 0) return [rfmGroup]
|
|
236
|
+
return [...base, { ...rfmGroup, combineOperator: 'AND' }]
|
|
237
|
+
})()
|
|
238
|
+
|
|
239
|
+
const baseConfig =
|
|
240
|
+
filterCriteria &&
|
|
241
|
+
typeof filterCriteria === 'object' &&
|
|
242
|
+
(filterCriteria as any).config &&
|
|
243
|
+
typeof (filterCriteria as any).config === 'object'
|
|
244
|
+
? (filterCriteria as any).config
|
|
245
|
+
: {}
|
|
246
|
+
|
|
247
|
+
const criteria = {
|
|
248
|
+
type: 'past-behavior',
|
|
249
|
+
groups: mergedGroups,
|
|
250
|
+
config: {
|
|
251
|
+
...baseConfig,
|
|
252
|
+
rfm: {
|
|
253
|
+
segment_key: segmentKey,
|
|
254
|
+
rf_event_name: rfEventName,
|
|
255
|
+
window_days: windowDays,
|
|
256
|
+
recency: { within_days: recencyUpper, exclude_within_days: recencyLower },
|
|
257
|
+
frequency: { ...frequency },
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
...(
|
|
261
|
+
filterCriteria &&
|
|
262
|
+
typeof filterCriteria === 'object' &&
|
|
263
|
+
(filterCriteria as any).live_options
|
|
264
|
+
? { live_options: (filterCriteria as any).live_options }
|
|
265
|
+
: {}
|
|
266
|
+
),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
segment_key: segmentKey,
|
|
271
|
+
segment_name: this.getSegmentName(segmentKey),
|
|
272
|
+
window_days: windowDays,
|
|
273
|
+
recency: { within_days: recencyUpper, exclude_within_days: recencyLower },
|
|
274
|
+
frequency: { op: frequency.op, value: frequency.value, value2: frequency.value2 ?? null },
|
|
275
|
+
criteria,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
export type RfmEventRow = {
|
|
4
|
+
contact_id?: string | null
|
|
5
|
+
reachy_id?: string | null
|
|
6
|
+
event_name?: string | null
|
|
7
|
+
event_timestamp: string | Date
|
|
8
|
+
event_data?: any
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RfmContactRow = {
|
|
12
|
+
id: string
|
|
13
|
+
reachy_id?: string | null
|
|
14
|
+
email?: string | null
|
|
15
|
+
phone?: string | null
|
|
16
|
+
is_subscribed?: boolean | null
|
|
17
|
+
communication_preferences?: any
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RfmAggregateRow = {
|
|
21
|
+
contact_id: string
|
|
22
|
+
last_ts: Date
|
|
23
|
+
event_count: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RfmScoredContact = RfmAggregateRow & {
|
|
27
|
+
recency_days: number
|
|
28
|
+
r_score: 1 | 2 | 3 | 4 | 5
|
|
29
|
+
f_score: 1 | 2 | 3 | 4 | 5
|
|
30
|
+
segment_key: string
|
|
31
|
+
email_ok: 0 | 1
|
|
32
|
+
sms_ok: 0 | 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RfmPreviewSegment = {
|
|
36
|
+
segment_key: string
|
|
37
|
+
segment_name: string
|
|
38
|
+
users: number
|
|
39
|
+
percent: number
|
|
40
|
+
avm: number
|
|
41
|
+
email_reachability: number
|
|
42
|
+
sms_reachability: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RfmThresholdRow = {
|
|
46
|
+
kind: 'r' | 'f'
|
|
47
|
+
score: 1 | 2 | 3 | 4 | 5
|
|
48
|
+
min_value: number
|
|
49
|
+
max_value: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type RfmComputeOptions = {
|
|
53
|
+
from: string | Date
|
|
54
|
+
to: string | Date
|
|
55
|
+
rfEventName: string
|
|
56
|
+
filterContactIds?: Set<string> | string[] | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type PercentileCuts = [number, number, number, number]
|
|
60
|
+
|
|
61
|
+
type SegmentRef = {
|
|
62
|
+
segment_key: string
|
|
63
|
+
segment_name: string
|
|
64
|
+
segment_order: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SEGMENTS_REF: SegmentRef[] = [
|
|
68
|
+
{ segment_key: 'cannot_lose_them', segment_name: 'Cannot Lose Them', segment_order: 1 },
|
|
69
|
+
{ segment_key: 'at_risk', segment_name: 'At Risk', segment_order: 2 },
|
|
70
|
+
{ segment_key: 'hibernating', segment_name: 'Hibernating', segment_order: 3 },
|
|
71
|
+
{ segment_key: 'about_to_sleep', segment_name: 'About to Sleep', segment_order: 4 },
|
|
72
|
+
{ segment_key: 'needing_attention', segment_name: 'Needing Attention', segment_order: 5 },
|
|
73
|
+
{ segment_key: 'promising', segment_name: 'Promising', segment_order: 6 },
|
|
74
|
+
{ segment_key: 'new_users', segment_name: 'New Users', segment_order: 7 },
|
|
75
|
+
{ segment_key: 'potential_loyalists', segment_name: 'Potential Loyalists', segment_order: 8 },
|
|
76
|
+
{ segment_key: 'loyal_users', segment_name: 'Loyal Users', segment_order: 9 },
|
|
77
|
+
{ segment_key: 'champions', segment_name: 'Champions', segment_order: 10 },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
function toDate(input: string | Date): Date {
|
|
81
|
+
if (input instanceof Date) return input
|
|
82
|
+
const d = new Date(String(input || ''))
|
|
83
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Data inválida: ${String(input)}`)
|
|
84
|
+
return d
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function utcDay(d: Date): Date {
|
|
88
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function recencyDays(to: Date, last: Date): number {
|
|
92
|
+
const a = utcDay(to).getTime()
|
|
93
|
+
const b = utcDay(last).getTime()
|
|
94
|
+
return Math.max(0, Math.floor((a - b) / DAY_MS))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSet(ids?: Set<string> | string[] | null): Set<string> | null {
|
|
98
|
+
if (!ids) return null
|
|
99
|
+
if (ids instanceof Set) return new Set(Array.from(ids).map((s) => String(s)))
|
|
100
|
+
return new Set((ids || []).map((s) => String(s)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function percentileDisc(values: number[], p: number): number {
|
|
104
|
+
const n = values.length
|
|
105
|
+
if (n <= 0) return Number.NaN
|
|
106
|
+
const pp = Math.max(0, Math.min(1, p))
|
|
107
|
+
const k = Math.ceil(pp * n) // 1..n
|
|
108
|
+
const idx = Math.min(n - 1, Math.max(0, k - 1))
|
|
109
|
+
return values[idx]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function percentileDiscCuts(valuesRaw: number[]): PercentileCuts {
|
|
113
|
+
const values = [...valuesRaw].sort((a, b) => a - b)
|
|
114
|
+
return [
|
|
115
|
+
percentileDisc(values, 0.2),
|
|
116
|
+
percentileDisc(values, 0.4),
|
|
117
|
+
percentileDisc(values, 0.6),
|
|
118
|
+
percentileDisc(values, 0.8),
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scoreRecency(days: number, cuts: PercentileCuts): 1 | 2 | 3 | 4 | 5 {
|
|
123
|
+
// Menor "days" = mais recente = score maior
|
|
124
|
+
if (days <= cuts[0]) return 5
|
|
125
|
+
if (days <= cuts[1]) return 4
|
|
126
|
+
if (days <= cuts[2]) return 3
|
|
127
|
+
if (days <= cuts[3]) return 2
|
|
128
|
+
return 1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scoreFrequency(count: number, cuts: PercentileCuts): 1 | 2 | 3 | 4 | 5 {
|
|
132
|
+
// Menor contagem = score menor
|
|
133
|
+
if (count <= cuts[0]) return 1
|
|
134
|
+
if (count <= cuts[1]) return 2
|
|
135
|
+
if (count <= cuts[2]) return 3
|
|
136
|
+
if (count <= cuts[3]) return 4
|
|
137
|
+
return 5
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function computeSegmentKey(r: number, f: number): string {
|
|
141
|
+
if (r >= 4 && f >= 4) return 'champions'
|
|
142
|
+
if (r === 3 && f >= 4) return 'loyal_users'
|
|
143
|
+
if (r >= 4 && f >= 2 && f <= 3) return 'potential_loyalists'
|
|
144
|
+
if (r === 5 && f === 1) return 'new_users'
|
|
145
|
+
if (r === 4 && f === 1) return 'promising'
|
|
146
|
+
if (r === 3 && f === 3) return 'needing_attention'
|
|
147
|
+
if (r === 3 && f <= 2) return 'about_to_sleep'
|
|
148
|
+
if (r <= 2 && f === 5) return 'cannot_lose_them'
|
|
149
|
+
if (r <= 2 && f >= 3 && f <= 4) return 'at_risk'
|
|
150
|
+
return 'hibernating'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function channelsArray(prefs: any): any[] {
|
|
154
|
+
const ch = prefs && typeof prefs === 'object' ? prefs?.channels : undefined
|
|
155
|
+
return Array.isArray(ch) ? ch : []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasChannel(prefs: any, channel: 'email' | 'sms'): boolean {
|
|
159
|
+
const arr = channelsArray(prefs)
|
|
160
|
+
return arr.some((c: any) => String(c?.channel || '').trim().toLowerCase() === channel)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isChannelSubscribed(prefs: any, channel: 'email' | 'sms'): boolean {
|
|
164
|
+
const arr = channelsArray(prefs)
|
|
165
|
+
return arr.some((c: any) => {
|
|
166
|
+
if (String(c?.channel || '').trim().toLowerCase() !== channel) return false
|
|
167
|
+
const sub = c?.subscribed
|
|
168
|
+
return String(sub ?? 'false').trim().toLowerCase() === 'true'
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function reachableBy(prefs: any, channel: 'email' | 'sms'): boolean {
|
|
173
|
+
const rb = prefs && typeof prefs === 'object' ? prefs?.reachable_by : undefined
|
|
174
|
+
const raw = rb && typeof rb === 'object' ? rb?.[channel] : undefined
|
|
175
|
+
return String(raw ?? '').trim().toLowerCase() === 'true'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function emailOk(contact: RfmContactRow): 0 | 1 {
|
|
179
|
+
const prefs = contact.communication_preferences || {}
|
|
180
|
+
if (contact.is_subscribed === false) return 0
|
|
181
|
+
if (hasChannel(prefs, 'email')) return isChannelSubscribed(prefs, 'email') ? 1 : 0
|
|
182
|
+
if (reachableBy(prefs, 'email')) return 1
|
|
183
|
+
const email = contact.email ? String(contact.email).trim() : ''
|
|
184
|
+
return email ? 1 : 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function smsOk(contact: RfmContactRow): 0 | 1 {
|
|
188
|
+
const prefs = contact.communication_preferences || {}
|
|
189
|
+
if (contact.is_subscribed === false) return 0
|
|
190
|
+
if (hasChannel(prefs, 'sms')) return isChannelSubscribed(prefs, 'sms') ? 1 : 0
|
|
191
|
+
if (reachableBy(prefs, 'sms')) return 1
|
|
192
|
+
const phone = contact.phone ? String(contact.phone).trim() : ''
|
|
193
|
+
return phone ? 1 : 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class RfmEngine {
|
|
197
|
+
/**
|
|
198
|
+
* Agrega (last_ts + event_count) por contato, replicando:
|
|
199
|
+
* - filtro de event_name e janela [from..to]
|
|
200
|
+
* - COALESCE(contact_id, contacts.id por reachy_id)
|
|
201
|
+
* - filtro opcional por contact_ids
|
|
202
|
+
*/
|
|
203
|
+
static aggregateFromEvents(
|
|
204
|
+
contacts: RfmContactRow[],
|
|
205
|
+
events: RfmEventRow[],
|
|
206
|
+
opts: RfmComputeOptions
|
|
207
|
+
): RfmAggregateRow[] {
|
|
208
|
+
const from = toDate(opts.from).getTime()
|
|
209
|
+
const to = toDate(opts.to).getTime()
|
|
210
|
+
const evName = String(opts.rfEventName || '').trim()
|
|
211
|
+
const filterSet = normalizeSet(opts.filterContactIds)
|
|
212
|
+
|
|
213
|
+
const contactById = new Map<string, RfmContactRow>()
|
|
214
|
+
const reachyToContactId = new Map<string, string>()
|
|
215
|
+
for (const c of contacts || []) {
|
|
216
|
+
const cid = c?.id ? String(c.id).trim() : ''
|
|
217
|
+
if (cid) contactById.set(cid, c)
|
|
218
|
+
const rid = c?.reachy_id ? String(c.reachy_id).trim() : ''
|
|
219
|
+
if (rid && cid && !reachyToContactId.has(rid)) reachyToContactId.set(rid, cid)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const acc = new Map<string, { last: number; count: number }>()
|
|
223
|
+
|
|
224
|
+
for (const e of events || []) {
|
|
225
|
+
const name = e?.event_name != null ? String(e.event_name) : ''
|
|
226
|
+
if (evName && name !== evName) continue
|
|
227
|
+
|
|
228
|
+
const ts = toDate(e.event_timestamp).getTime()
|
|
229
|
+
if (ts < from || ts > to) continue
|
|
230
|
+
|
|
231
|
+
const rawCid = e?.contact_id ? String(e.contact_id).trim() : ''
|
|
232
|
+
const rawRid = e?.reachy_id ? String(e.reachy_id).trim() : ''
|
|
233
|
+
if (!rawCid && !rawRid) continue
|
|
234
|
+
|
|
235
|
+
const mappedCid = (() => {
|
|
236
|
+
if (rawCid && contactById.has(rawCid)) return rawCid
|
|
237
|
+
if (rawRid) {
|
|
238
|
+
const mapped = reachyToContactId.get(rawRid)
|
|
239
|
+
if (mapped && contactById.has(mapped)) return mapped
|
|
240
|
+
}
|
|
241
|
+
return ''
|
|
242
|
+
})()
|
|
243
|
+
|
|
244
|
+
if (!mappedCid) continue
|
|
245
|
+
if (filterSet && !filterSet.has(mappedCid)) continue
|
|
246
|
+
|
|
247
|
+
const cur = acc.get(mappedCid)
|
|
248
|
+
if (!cur) {
|
|
249
|
+
acc.set(mappedCid, { last: ts, count: 1 })
|
|
250
|
+
} else {
|
|
251
|
+
cur.count += 1
|
|
252
|
+
if (ts > cur.last) cur.last = ts
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const out: RfmAggregateRow[] = []
|
|
257
|
+
acc.forEach((v, contactId) => {
|
|
258
|
+
out.push({
|
|
259
|
+
contact_id: contactId,
|
|
260
|
+
last_ts: new Date(v.last),
|
|
261
|
+
event_count: Math.max(0, Math.trunc(v.count)),
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
return out
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
static scoreAggregates(
|
|
268
|
+
contacts: RfmContactRow[],
|
|
269
|
+
aggregates: RfmAggregateRow[],
|
|
270
|
+
opts: Pick<RfmComputeOptions, 'to'>
|
|
271
|
+
): { scored: RfmScoredContact[]; r_days_cuts: PercentileCuts; f_count_cuts: PercentileCuts } {
|
|
272
|
+
const to = toDate(opts.to)
|
|
273
|
+
|
|
274
|
+
const contactById = new Map<string, RfmContactRow>()
|
|
275
|
+
for (const c of contacts || []) {
|
|
276
|
+
const cid = c?.id ? String(c.id).trim() : ''
|
|
277
|
+
if (cid) contactById.set(cid, c)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rows: Array<{ agg: RfmAggregateRow; days: number }> = []
|
|
281
|
+
const recencyValues: number[] = []
|
|
282
|
+
const freqValues: number[] = []
|
|
283
|
+
|
|
284
|
+
for (const a of aggregates || []) {
|
|
285
|
+
const cid = a?.contact_id ? String(a.contact_id).trim() : ''
|
|
286
|
+
if (!cid) continue
|
|
287
|
+
if (!contactById.has(cid)) continue // paridade com JOIN contacts
|
|
288
|
+
const days = recencyDays(to, a.last_ts)
|
|
289
|
+
rows.push({ agg: a, days })
|
|
290
|
+
recencyValues.push(days)
|
|
291
|
+
freqValues.push(Math.max(0, Math.trunc(a.event_count)))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rCuts = percentileDiscCuts(recencyValues)
|
|
295
|
+
const fCuts = percentileDiscCuts(freqValues)
|
|
296
|
+
|
|
297
|
+
const scored: RfmScoredContact[] = []
|
|
298
|
+
for (const r of rows) {
|
|
299
|
+
const c = contactById.get(r.agg.contact_id)!
|
|
300
|
+
const rScore = scoreRecency(r.days, rCuts)
|
|
301
|
+
const fScore = scoreFrequency(Math.max(0, Math.trunc(r.agg.event_count)), fCuts)
|
|
302
|
+
scored.push({
|
|
303
|
+
contact_id: r.agg.contact_id,
|
|
304
|
+
last_ts: r.agg.last_ts,
|
|
305
|
+
event_count: Math.max(0, Math.trunc(r.agg.event_count)),
|
|
306
|
+
recency_days: r.days,
|
|
307
|
+
r_score: rScore,
|
|
308
|
+
f_score: fScore,
|
|
309
|
+
segment_key: computeSegmentKey(rScore, fScore),
|
|
310
|
+
email_ok: emailOk(c),
|
|
311
|
+
sms_ok: smsOk(c),
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { scored, r_days_cuts: rCuts, f_count_cuts: fCuts }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static thresholdsFromScored(scored: RfmScoredContact[]): RfmThresholdRow[] {
|
|
319
|
+
const rMin = new Map<number, number>()
|
|
320
|
+
const rMax = new Map<number, number>()
|
|
321
|
+
const fMin = new Map<number, number>()
|
|
322
|
+
const fMax = new Map<number, number>()
|
|
323
|
+
|
|
324
|
+
for (const s of scored || []) {
|
|
325
|
+
const rScore = s.r_score
|
|
326
|
+
const fScore = s.f_score
|
|
327
|
+
const rVal = Math.max(0, Math.trunc(s.recency_days))
|
|
328
|
+
const fVal = Math.max(0, Math.trunc(s.event_count))
|
|
329
|
+
|
|
330
|
+
const rMinPrev = rMin.get(rScore)
|
|
331
|
+
if (rMinPrev == null || rVal < rMinPrev) rMin.set(rScore, rVal)
|
|
332
|
+
const rMaxPrev = rMax.get(rScore)
|
|
333
|
+
if (rMaxPrev == null || rVal > rMaxPrev) rMax.set(rScore, rVal)
|
|
334
|
+
|
|
335
|
+
const fMinPrev = fMin.get(fScore)
|
|
336
|
+
if (fMinPrev == null || fVal < fMinPrev) fMin.set(fScore, fVal)
|
|
337
|
+
const fMaxPrev = fMax.get(fScore)
|
|
338
|
+
if (fMaxPrev == null || fVal > fMaxPrev) fMax.set(fScore, fVal)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const out: RfmThresholdRow[] = []
|
|
342
|
+
const scores: Array<1 | 2 | 3 | 4 | 5> = [1, 2, 3, 4, 5]
|
|
343
|
+
|
|
344
|
+
for (const score of scores) {
|
|
345
|
+
if (rMin.has(score) && rMax.has(score)) {
|
|
346
|
+
out.push({
|
|
347
|
+
kind: 'r',
|
|
348
|
+
score,
|
|
349
|
+
min_value: rMin.get(score)!,
|
|
350
|
+
max_value: rMax.get(score)!,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const score of scores) {
|
|
356
|
+
if (fMin.has(score) && fMax.has(score)) {
|
|
357
|
+
out.push({
|
|
358
|
+
kind: 'f',
|
|
359
|
+
score,
|
|
360
|
+
min_value: fMin.get(score)!,
|
|
361
|
+
max_value: fMax.get(score)!,
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// mesmo ORDER BY kind, score do SQL (texto): 'f' vem antes de 'r'
|
|
367
|
+
out.sort((a, b) => (a.kind === b.kind ? a.score - b.score : a.kind.localeCompare(b.kind)))
|
|
368
|
+
return out
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
static previewFromScored(scored: RfmScoredContact[]): RfmPreviewSegment[] {
|
|
372
|
+
const totalUsers = scored.length
|
|
373
|
+
const agg = new Map<
|
|
374
|
+
string,
|
|
375
|
+
{ users: number; email_reachability: number; sms_reachability: number; segment_spend: number }
|
|
376
|
+
>()
|
|
377
|
+
|
|
378
|
+
for (const s of scored || []) {
|
|
379
|
+
const key = String(s.segment_key || '').trim() || 'hibernating'
|
|
380
|
+
const cur = agg.get(key) || { users: 0, email_reachability: 0, sms_reachability: 0, segment_spend: 0 }
|
|
381
|
+
cur.users += 1
|
|
382
|
+
cur.email_reachability += s.email_ok
|
|
383
|
+
cur.sms_reachability += s.sms_ok
|
|
384
|
+
// spend/avm não é implementado nas funções atuais; manter 0
|
|
385
|
+
agg.set(key, cur)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const out: RfmPreviewSegment[] = []
|
|
389
|
+
for (const seg of SEGMENTS_REF) {
|
|
390
|
+
const a = agg.get(seg.segment_key) || { users: 0, email_reachability: 0, sms_reachability: 0, segment_spend: 0 }
|
|
391
|
+
const percent = totalUsers > 0 ? Math.round((a.users * 10000) / totalUsers) / 100 : 0
|
|
392
|
+
const avm = a.users > 0 ? 0 : 0
|
|
393
|
+
out.push({
|
|
394
|
+
segment_key: seg.segment_key,
|
|
395
|
+
segment_name: seg.segment_name,
|
|
396
|
+
users: a.users,
|
|
397
|
+
percent,
|
|
398
|
+
avm,
|
|
399
|
+
email_reachability: a.email_reachability,
|
|
400
|
+
sms_reachability: a.sms_reachability,
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
return out
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
static computeFromEvents(
|
|
407
|
+
contacts: RfmContactRow[],
|
|
408
|
+
events: RfmEventRow[],
|
|
409
|
+
opts: RfmComputeOptions
|
|
410
|
+
): { segments: RfmPreviewSegment[]; thresholds: RfmThresholdRow[]; scored: RfmScoredContact[] } {
|
|
411
|
+
const aggregates = this.aggregateFromEvents(contacts, events, opts)
|
|
412
|
+
const { scored } = this.scoreAggregates(contacts, aggregates, { to: opts.to })
|
|
413
|
+
const thresholds = this.thresholdsFromScored(scored)
|
|
414
|
+
const segments = this.previewFromScored(scored)
|
|
415
|
+
return { segments, thresholds, scored }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|