/** * CDP Edge — Match Quality (Fase 5) * Rastreia qualidade dos dados enviados ao Meta CAPI. * Detecta degradação e alerta via CallMeBot. * Tenta auto-correção onde possível. */ import { sendCallMeBot } from '../dispatch/whatsapp.js'; import { Env, TrackPayload } from '../../types.js'; import { D1Database } from '@cloudflare/workers-types'; // ── Tipos ─────────────────────────────────────────────────────────────────────── export interface MatchQualityThresholds { email_rate_min: number; fbp_rate_min: number; composite_min: number; min_events_alert: number; } export interface EnrichedPayloadResult { payload: TrackPayload; recovered: { email: boolean; utm: boolean }; } export interface MatchQualityAlert { type: string; metric: string; message: string; severity?: 'critical' | 'warning'; } export interface MatchQualityAnalysis { total?: number; email_rate?: number; phone_rate?: number; fbp_rate?: number; fbc_rate?: number; ext_id_rate?: number; email_recovered_rate?: number; composite_score?: number; alerts: MatchQualityAlert[]; } // ── Thresholds de alerta ────────────────────────────────────────────────────── const THRESHOLDS: MatchQualityThresholds = { email_rate_min: 0.40, // < 40% dos eventos com email → alerta fbp_rate_min: 0.30, // < 30% com fbp cookie → alerta composite_min: 0.45, // < 45% score composto → alerta crítico min_events_alert: 10, // mínimo de eventos nas últimas 2h para disparar alerta }; // ── Log de qualidade (chamado em meta.js a cada dispatch) ───────────────────── /** * Registra flags de qualidade de um evento no D1 (background, não bloqueia). */ export async function logMatchQuality(DB: D1Database, eventName: string, payload: TrackPayload, recovered: { email: boolean; utm: boolean } = { email: false, utm: false }): Promise { if (!DB) return; try { await DB.prepare(` INSERT INTO match_quality_log ( event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id, was_email_recovered, was_utm_restored ) VALUES (?,?,?,?,?,?,?,?) `).bind( eventName, payload.email ? 1 : 0, payload.phone ? 1 : 0, payload.fbp ? 1 : 0, payload.fbc ? 1 : 0, payload.userId ? 1 : 0, recovered.email ? 1 : 0, recovered.utm ? 1 : 0, ).run(); } catch { /* não bloquear dispatch */ } } // ── Auto-correção de payload ─────────────────────────────────────────────────── /** * Tenta enriquecer o payload com dados do Identity Graph antes do envio ao Meta. * Retorna { payload enriquecido, flags de recuperação }. */ export async function autoEnrichPayload(env: Env, payload: TrackPayload): Promise { const recovered = { email: false, utm: false }; if (!env.DB) return { payload, recovered }; // 1. Tentar recuperar email/fbp/fbc do perfil pelo userId if (!payload.email && payload.userId) { try { const profile = await env.DB.prepare( `SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1` ).bind(payload.userId).first(); if (profile) { if (profile.email && !payload.email) { payload.email = profile.email as string; recovered.email = true; } if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp as string; if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc as string; if (profile.phone && !payload.phone) payload.phone = profile.phone as string; } } catch (err: any) { console.error('[MatchQuality] Error enriching payload with profile data:', { userId: payload.userId, email: payload.email, error: err?.message || String(err), stack: err?.stack, }); } } // 2. UTM Resurrection já foi tentada no /track handler (payload.utmRestored) if (payload.utmRestored) recovered.utm = true; return { payload, recovered }; } // ── Análise de qualidade (chamada pelo cron) ───────────────────────────────── /** * Analisa a qualidade das últimas 2h e retorna métricas + alertas. */ export async function analyzeMatchQuality(env: Env): Promise { if (!env.DB) return null; try { const row = await env.DB.prepare(` SELECT COUNT(*) AS total, ROUND(AVG(has_email) * 100, 1) AS email_rate, ROUND(AVG(has_phone) * 100, 1) AS phone_rate, ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate, ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate, ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate, ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate, ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score FROM match_quality_log WHERE logged_at >= datetime('now', '-2 hours') `).first(); if (!row || Number((row as any).total) < THRESHOLDS.min_events_alert) return { total: Number((row as any)?.total || 0), alerts: [] }; const alerts: MatchQualityAlert[] = []; if (Number((row as any).email_rate || 0) < THRESHOLDS.email_rate_min * 100) { alerts.push({ type: 'email_low', metric: `email_rate: ${(row as any).email_rate}%`, message: `Taxa de email baixa: ${(row as any).email_rate}% (mínimo: ${THRESHOLDS.email_rate_min * 100}%)`, }); } if (Number((row as any).fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) { alerts.push({ type: 'fbp_low', metric: `fbp_rate: ${(row as any).fbp_rate}%`, message: `Cookie fbp ausente em ${100 - Number((row as any).fbp_rate)}% dos eventos — verificar cdpTrack.js`, }); } if (Number((row as any).composite_score || 0) < THRESHOLDS.composite_min * 100) { alerts.push({ type: 'composite_critical', metric: `composite: ${(row as any).composite_score}%`, message: `Score composto de match quality crítico: ${(row as any).composite_score}%`, severity: 'critical', }); } return { total: Number((row as any).total), email_rate: Number((row as any).email_rate), phone_rate: Number((row as any).phone_rate), fbp_rate: Number((row as any).fbp_rate), fbc_rate: Number((row as any).fbc_rate), ext_id_rate: Number((row as any).ext_id_rate), email_recovered_rate: Number((row as any).email_recovered_rate), composite_score: Number((row as any).composite_score), alerts, }; } catch (err: any) { console.error('[MatchQuality] analyze error:', err?.message || String(err)); return null; } } // ── Alerta via CallMeBot ────────────────────────────────────────────────────── export async function alertMatchQuality(env: Env, analysis: MatchQualityAnalysis): Promise { if (!analysis || analysis.alerts.length === 0) return; const hasCritical = analysis.alerts.some(a => a.severity === 'critical'); const icon = hasCritical ? '🚨' : '⚠️'; const lines = [ `${icon} CDP Edge — Match Quality Alert`, ``, `📊 Últimas 2h (${analysis.total || 0} eventos):`, ` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`, ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`, ` Score: ${analysis.composite_score ?? 0}%`, ``, `🔍 Problemas:`, ...analysis.alerts.map(a => ` · ${a.message}`), ``, `🛠 Ações automáticas já ativas:`, ` · Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`, ` · UTM Resurrection ativa`, ``, new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }), ]; await sendCallMeBot(env, lines.join('\n')); } // ── Purge periódico (mensal) ────────────────────────────────────────────────── export async function purgeOldMatchQualityLogs(DB: D1Database): Promise { if (!DB) return; try { await DB.prepare( `DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')` ).run(); } catch (err: any) { console.error('[MatchQuality] Error purging old match quality logs:', { error: err?.message || String(err), stack: err?.stack, }); } }