@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.
@@ -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
+