const DAY_MS = 24 * 60 * 60 * 1000 export type RfmEventRow = { contact_id?: string | null reachy_id?: string | null event_name?: string | null event_timestamp: string | Date event_data?: any } export type RfmContactRow = { id: string reachy_id?: string | null email?: string | null phone?: string | null is_subscribed?: boolean | null communication_preferences?: any } export type RfmAggregateRow = { contact_id: string last_ts: Date event_count: number } export type RfmScoredContact = RfmAggregateRow & { recency_days: number r_score: 1 | 2 | 3 | 4 | 5 f_score: 1 | 2 | 3 | 4 | 5 segment_key: string email_ok: 0 | 1 sms_ok: 0 | 1 } export type RfmPreviewSegment = { segment_key: string segment_name: string users: number percent: number avm: number email_reachability: number sms_reachability: number } export type RfmThresholdRow = { kind: 'r' | 'f' score: 1 | 2 | 3 | 4 | 5 min_value: number max_value: number } export type RfmComputeOptions = { from: string | Date to: string | Date rfEventName: string filterContactIds?: Set | string[] | null } type PercentileCuts = [number, number, number, number] type SegmentRef = { segment_key: string segment_name: string segment_order: number } const SEGMENTS_REF: SegmentRef[] = [ { segment_key: 'cannot_lose_them', segment_name: 'Cannot Lose Them', segment_order: 1 }, { segment_key: 'at_risk', segment_name: 'At Risk', segment_order: 2 }, { segment_key: 'hibernating', segment_name: 'Hibernating', segment_order: 3 }, { segment_key: 'about_to_sleep', segment_name: 'About to Sleep', segment_order: 4 }, { segment_key: 'needing_attention', segment_name: 'Needing Attention', segment_order: 5 }, { segment_key: 'promising', segment_name: 'Promising', segment_order: 6 }, { segment_key: 'new_users', segment_name: 'New Users', segment_order: 7 }, { segment_key: 'potential_loyalists', segment_name: 'Potential Loyalists', segment_order: 8 }, { segment_key: 'loyal_users', segment_name: 'Loyal Users', segment_order: 9 }, { segment_key: 'champions', segment_name: 'Champions', segment_order: 10 }, ] function toDate(input: string | Date): Date { if (input instanceof Date) return input const d = new Date(String(input || '')) if (Number.isNaN(d.getTime())) throw new Error(`Data inválida: ${String(input)}`) return d } function utcDay(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)) } function recencyDays(to: Date, last: Date): number { const a = utcDay(to).getTime() const b = utcDay(last).getTime() return Math.max(0, Math.floor((a - b) / DAY_MS)) } function normalizeSet(ids?: Set | string[] | null): Set | null { if (!ids) return null if (ids instanceof Set) return new Set(Array.from(ids).map((s) => String(s))) return new Set((ids || []).map((s) => String(s))) } function percentileDisc(values: number[], p: number): number { const n = values.length if (n <= 0) return Number.NaN const pp = Math.max(0, Math.min(1, p)) const k = Math.ceil(pp * n) // 1..n const idx = Math.min(n - 1, Math.max(0, k - 1)) return values[idx] } function percentileDiscCuts(valuesRaw: number[]): PercentileCuts { const values = [...valuesRaw].sort((a, b) => a - b) return [ percentileDisc(values, 0.2), percentileDisc(values, 0.4), percentileDisc(values, 0.6), percentileDisc(values, 0.8), ] } function scoreRecency(days: number, cuts: PercentileCuts): 1 | 2 | 3 | 4 | 5 { // Menor "days" = mais recente = score maior if (days <= cuts[0]) return 5 if (days <= cuts[1]) return 4 if (days <= cuts[2]) return 3 if (days <= cuts[3]) return 2 return 1 } function scoreFrequency(count: number, cuts: PercentileCuts): 1 | 2 | 3 | 4 | 5 { // Menor contagem = score menor if (count <= cuts[0]) return 1 if (count <= cuts[1]) return 2 if (count <= cuts[2]) return 3 if (count <= cuts[3]) return 4 return 5 } function computeSegmentKey(r: number, f: number): string { if (r >= 4 && f >= 4) return 'champions' if (r === 3 && f >= 4) return 'loyal_users' if (r >= 4 && f >= 2 && f <= 3) return 'potential_loyalists' if (r === 5 && f === 1) return 'new_users' if (r === 4 && f === 1) return 'promising' if (r === 3 && f === 3) return 'needing_attention' if (r === 3 && f <= 2) return 'about_to_sleep' if (r <= 2 && f === 5) return 'cannot_lose_them' if (r <= 2 && f >= 3 && f <= 4) return 'at_risk' return 'hibernating' } function channelsArray(prefs: any): any[] { const ch = prefs && typeof prefs === 'object' ? prefs?.channels : undefined return Array.isArray(ch) ? ch : [] } function hasChannel(prefs: any, channel: 'email' | 'sms'): boolean { const arr = channelsArray(prefs) return arr.some((c: any) => String(c?.channel || '').trim().toLowerCase() === channel) } function isChannelSubscribed(prefs: any, channel: 'email' | 'sms'): boolean { const arr = channelsArray(prefs) return arr.some((c: any) => { if (String(c?.channel || '').trim().toLowerCase() !== channel) return false const sub = c?.subscribed return String(sub ?? 'false').trim().toLowerCase() === 'true' }) } function reachableBy(prefs: any, channel: 'email' | 'sms'): boolean { const rb = prefs && typeof prefs === 'object' ? prefs?.reachable_by : undefined const raw = rb && typeof rb === 'object' ? rb?.[channel] : undefined return String(raw ?? '').trim().toLowerCase() === 'true' } function emailOk(contact: RfmContactRow): 0 | 1 { const prefs = contact.communication_preferences || {} if (contact.is_subscribed === false) return 0 if (hasChannel(prefs, 'email')) return isChannelSubscribed(prefs, 'email') ? 1 : 0 if (reachableBy(prefs, 'email')) return 1 const email = contact.email ? String(contact.email).trim() : '' return email ? 1 : 0 } function smsOk(contact: RfmContactRow): 0 | 1 { const prefs = contact.communication_preferences || {} if (contact.is_subscribed === false) return 0 if (hasChannel(prefs, 'sms')) return isChannelSubscribed(prefs, 'sms') ? 1 : 0 if (reachableBy(prefs, 'sms')) return 1 const phone = contact.phone ? String(contact.phone).trim() : '' return phone ? 1 : 0 } export class RfmEngine { /** * Agrega (last_ts + event_count) por contato, replicando: * - filtro de event_name e janela [from..to] * - COALESCE(contact_id, contacts.id por reachy_id) * - filtro opcional por contact_ids */ static aggregateFromEvents( contacts: RfmContactRow[], events: RfmEventRow[], opts: RfmComputeOptions ): RfmAggregateRow[] { const from = toDate(opts.from).getTime() const to = toDate(opts.to).getTime() const evName = String(opts.rfEventName || '').trim() const filterSet = normalizeSet(opts.filterContactIds) const contactById = new Map() const reachyToContactId = new Map() for (const c of contacts || []) { const cid = c?.id ? String(c.id).trim() : '' if (cid) contactById.set(cid, c) const rid = c?.reachy_id ? String(c.reachy_id).trim() : '' if (rid && cid && !reachyToContactId.has(rid)) reachyToContactId.set(rid, cid) } const acc = new Map() for (const e of events || []) { const name = e?.event_name != null ? String(e.event_name) : '' if (evName && name !== evName) continue const ts = toDate(e.event_timestamp).getTime() if (ts < from || ts > to) continue const rawCid = e?.contact_id ? String(e.contact_id).trim() : '' const rawRid = e?.reachy_id ? String(e.reachy_id).trim() : '' if (!rawCid && !rawRid) continue const mappedCid = (() => { if (rawCid && contactById.has(rawCid)) return rawCid if (rawRid) { const mapped = reachyToContactId.get(rawRid) if (mapped && contactById.has(mapped)) return mapped } return '' })() if (!mappedCid) continue if (filterSet && !filterSet.has(mappedCid)) continue const cur = acc.get(mappedCid) if (!cur) { acc.set(mappedCid, { last: ts, count: 1 }) } else { cur.count += 1 if (ts > cur.last) cur.last = ts } } const out: RfmAggregateRow[] = [] acc.forEach((v, contactId) => { out.push({ contact_id: contactId, last_ts: new Date(v.last), event_count: Math.max(0, Math.trunc(v.count)), }) }) return out } static scoreAggregates( contacts: RfmContactRow[], aggregates: RfmAggregateRow[], opts: Pick ): { scored: RfmScoredContact[]; r_days_cuts: PercentileCuts; f_count_cuts: PercentileCuts } { const to = toDate(opts.to) const contactById = new Map() for (const c of contacts || []) { const cid = c?.id ? String(c.id).trim() : '' if (cid) contactById.set(cid, c) } const rows: Array<{ agg: RfmAggregateRow; days: number }> = [] const recencyValues: number[] = [] const freqValues: number[] = [] for (const a of aggregates || []) { const cid = a?.contact_id ? String(a.contact_id).trim() : '' if (!cid) continue if (!contactById.has(cid)) continue // paridade com JOIN contacts const days = recencyDays(to, a.last_ts) rows.push({ agg: a, days }) recencyValues.push(days) freqValues.push(Math.max(0, Math.trunc(a.event_count))) } const rCuts = percentileDiscCuts(recencyValues) const fCuts = percentileDiscCuts(freqValues) const scored: RfmScoredContact[] = [] for (const r of rows) { const c = contactById.get(r.agg.contact_id)! const rScore = scoreRecency(r.days, rCuts) const fScore = scoreFrequency(Math.max(0, Math.trunc(r.agg.event_count)), fCuts) scored.push({ contact_id: r.agg.contact_id, last_ts: r.agg.last_ts, event_count: Math.max(0, Math.trunc(r.agg.event_count)), recency_days: r.days, r_score: rScore, f_score: fScore, segment_key: computeSegmentKey(rScore, fScore), email_ok: emailOk(c), sms_ok: smsOk(c), }) } return { scored, r_days_cuts: rCuts, f_count_cuts: fCuts } } static thresholdsFromScored(scored: RfmScoredContact[]): RfmThresholdRow[] { const rMin = new Map() const rMax = new Map() const fMin = new Map() const fMax = new Map() for (const s of scored || []) { const rScore = s.r_score const fScore = s.f_score const rVal = Math.max(0, Math.trunc(s.recency_days)) const fVal = Math.max(0, Math.trunc(s.event_count)) const rMinPrev = rMin.get(rScore) if (rMinPrev == null || rVal < rMinPrev) rMin.set(rScore, rVal) const rMaxPrev = rMax.get(rScore) if (rMaxPrev == null || rVal > rMaxPrev) rMax.set(rScore, rVal) const fMinPrev = fMin.get(fScore) if (fMinPrev == null || fVal < fMinPrev) fMin.set(fScore, fVal) const fMaxPrev = fMax.get(fScore) if (fMaxPrev == null || fVal > fMaxPrev) fMax.set(fScore, fVal) } const out: RfmThresholdRow[] = [] const scores: Array<1 | 2 | 3 | 4 | 5> = [1, 2, 3, 4, 5] for (const score of scores) { if (rMin.has(score) && rMax.has(score)) { out.push({ kind: 'r', score, min_value: rMin.get(score)!, max_value: rMax.get(score)!, }) } } for (const score of scores) { if (fMin.has(score) && fMax.has(score)) { out.push({ kind: 'f', score, min_value: fMin.get(score)!, max_value: fMax.get(score)!, }) } } // mesmo ORDER BY kind, score do SQL (texto): 'f' vem antes de 'r' out.sort((a, b) => (a.kind === b.kind ? a.score - b.score : a.kind.localeCompare(b.kind))) return out } static previewFromScored(scored: RfmScoredContact[]): RfmPreviewSegment[] { const totalUsers = scored.length const agg = new Map< string, { users: number; email_reachability: number; sms_reachability: number; segment_spend: number } >() for (const s of scored || []) { const key = String(s.segment_key || '').trim() || 'hibernating' const cur = agg.get(key) || { users: 0, email_reachability: 0, sms_reachability: 0, segment_spend: 0 } cur.users += 1 cur.email_reachability += s.email_ok cur.sms_reachability += s.sms_ok // spend/avm não é implementado nas funções atuais; manter 0 agg.set(key, cur) } const out: RfmPreviewSegment[] = [] for (const seg of SEGMENTS_REF) { const a = agg.get(seg.segment_key) || { users: 0, email_reachability: 0, sms_reachability: 0, segment_spend: 0 } const percent = totalUsers > 0 ? Math.round((a.users * 10000) / totalUsers) / 100 : 0 const avm = a.users > 0 ? 0 : 0 out.push({ segment_key: seg.segment_key, segment_name: seg.segment_name, users: a.users, percent, avm, email_reachability: a.email_reachability, sms_reachability: a.sms_reachability, }) } return out } static computeFromEvents( contacts: RfmContactRow[], events: RfmEventRow[], opts: RfmComputeOptions ): { segments: RfmPreviewSegment[]; thresholds: RfmThresholdRow[]; scored: RfmScoredContact[] } { const aggregates = this.aggregateFromEvents(contacts, events, opts) const { scored } = this.scoreAggregates(contacts, aggregates, { to: opts.to }) const thresholds = this.thresholdsFromScored(scored) const segments = this.previewFromScored(scored) return { segments, thresholds, scored } } }