/** * CDP Edge — Meta Conversions API v25.0 * Envia eventos server-side para a Meta CAPI. */ import { sha256, normalizePhone, normalizeCity } from '../utils.js'; import { logApiFailure } from '../db.js'; import { logMatchQuality, autoEnrichPayload } from '../ml/matchquality.js'; import { Env, TrackPayload } from '../../types.js'; import { ExecutionContext } from '@cloudflare/workers-types'; interface EnrichedPayload { payload: TrackPayload; recovered: { email: boolean; utm: boolean }; } export async function sendMetaCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise { if (env.DISABLE_EXTERNAL_DISPATCH === '1') return { skipped: 'external dispatch disabled' }; if (!env.META_ACCESS_TOKEN) return { skipped: 'META_ACCESS_TOKEN not set' }; // Auto-enriquecer payload com dados do Identity Graph antes do envio let recovered = { email: false, utm: false }; if (env.DB && payload) { const enriched = await autoEnrichPayload(env, payload) as EnrichedPayload; payload = enriched.payload; recovered = enriched.recovered; } const { email, phone, firstName, lastName, city, state, country, zip, dob, fbp, fbc, userId, eventId, pageUrl, value, currency, contentIds, contentName, contentType, numItems, // Dual-layer context — funil avançado + imóveis funnel_stage, distanceBucket: distance_bucket, intentScoreNum: intent_score, intent_bucket, ltvScore, ltvClass, metaSignal, metaSignalBucket: metaSignalBucketVal, } = payload; const phoneNorm = normalizePhone(phone); const countryCode = (country || (request as any)?.cf?.country || 'br').toLowerCase(); const stateCode = state ? String(state).toLowerCase() : undefined; const cityNorm = normalizeCity(city); const userData: Record = { ...(email && { em: await sha256(email) || '' }), ...(phoneNorm && { ph: await sha256(phoneNorm) || '' }), ...(firstName && { fn: await sha256(firstName) || '' }), ...(lastName && { ln: await sha256(lastName) || '' }), ...(cityNorm && { ct: await sha256(cityNorm) || '' }), ...(stateCode && { st: await sha256(stateCode) || '' }), ...(countryCode && { country: await sha256(countryCode) || '' }), ...(userId && { external_id: await sha256(String(userId)) || '' }), ...(zip && { zp: await sha256(zip) || '' }), ...(dob && { db: await sha256(dob) || '' }), ...(fbp && { fbp }), ...(fbc && { fbc }), client_ip_address: request?.headers.get('CF-Connecting-IP') || request?.headers.get('X-Forwarded-For') || '', client_user_agent: request?.headers.get('User-Agent') || '', }; const customData: Record = { ...(value !== undefined && { value: parseFloat(String(value)) }), ...(currency && { currency: String(currency).toUpperCase() }), ...(contentIds && contentIds.length > 0 && { content_ids: contentIds }), ...(contentName && { content_name: contentName }), ...(contentType && { content_type: contentType }), ...(numItems && { num_items: parseInt(String(numItems)) }), // Contexto de funil e proximidade — enriquece matching e otimização Meta ...(funnel_stage && { funnel_stage }), ...(distance_bucket && { distance_bucket }), ...(intent_score && { intent_score }), ...(ltvScore !== undefined && ltvScore !== null && { ltv_score: ltvScore }), ...(ltvClass && { ltv_class: ltvClass }), ...(metaSignal !== undefined && metaSignal !== null && { meta_signal: metaSignal }), ...(metaSignalBucketVal && { meta_signal_bucket: metaSignalBucketVal }), ...(intent_bucket && { intent_bucket }), }; const eventPayload = { event_name: eventName, event_time: Math.floor(Date.now() / 1000), event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, event_source_url: pageUrl || `https://${env.SITE_DOMAIN}`, action_source: 'website', user_data: userData, ...(Object.keys(customData).length > 0 && { custom_data: customData }), }; const requestBody: Record = { data: [eventPayload], access_token: env.META_ACCESS_TOKEN, }; if (env.META_TEST_CODE) { requestBody.test_event_code = env.META_TEST_CODE; } // Logar match quality em background (não bloqueia dispatch) if (env.DB && ctx) { ctx.waitUntil(logMatchQuality(env.DB, eventName, payload, recovered)); } else if (env.DB) { logMatchQuality(env.DB, eventName, payload, recovered).catch(() => {}); } const endpoint = `https://graph.facebook.com/v25.0/${env.META_PIXEL_ID}/events`; try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), }); const data = await res.json(); if (!res.ok) { const errorCode = (data as any).error?.code || String(res.status); const errorMessage = (data as any).error?.message || (data as any).error?.error_user_msg || 'Unknown error'; console.error('Meta CAPI error:', res.status, errorMessage); if (env.DB && ctx) { ctx.waitUntil(logApiFailure(env.DB, 'meta', eventName, errorCode, errorMessage, eventPayload.event_id, JSON.stringify(requestBody))); } } return data; } catch (err: any) { console.error('Meta CAPI fetch failed:', err?.message || String(err)); if (env.DB && ctx) { ctx.waitUntil(logApiFailure(env.DB, 'meta', eventName, 'FETCH_ERROR', err?.message || String(err), eventPayload.event_id, JSON.stringify(requestBody))); } if (env.RETRY_QUEUE) { const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'meta' }); if (ctx) ctx.waitUntil(send); else send.catch(() => {}); } return { error: err?.message || String(err) }; } }