/** * CDP Edge — Meta Conversions API v26.0 * Envia eventos server-side para a Meta CAPI. */ import { sha256, normalizePhone, normalizeCity, normalizeZip, normalizeDob } 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'; import { graphBase, getMetaToken } from '../meta-api.js'; interface EnrichedPayload { payload: TrackPayload; recovered: { email: boolean; utm: boolean }; } const MAX_RETRY_ATTEMPTS = 3; const MAX_EVENT_AGE_S = 7 * 24 * 3600; // a Meta rejeita eventos com mais de 7 dias 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' }; const accessToken = await getMetaToken(env); if (!accessToken) 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 zipNorm = normalizeZip(zip); const dobNorm = normalizeDob(dob); const clientIp = (request?.headers.get('CF-Connecting-IP') || request?.headers.get('X-Forwarded-For')?.split(',')[0] || '').trim(); const clientUa = request?.headers.get('User-Agent') || ''; 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)) || '' }), ...(zipNorm && { zp: await sha256(zipNorm) || '' }), ...(dobNorm && { db: await sha256(dobNorm) || '' }), ...(fbp && { fbp }), ...(fbc && { fbc }), ...(clientIp && { client_ip_address: clientIp }), ...(clientUa && { client_user_agent: clientUa }), }; const customData: Record = { ...(value !== undefined && Number.isFinite(parseFloat(String(value))) && { 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 nowS = Math.floor(Date.now() / 1000); const eventTime = payload.eventTime && nowS - payload.eventTime < MAX_EVENT_AGE_S ? payload.eventTime : nowS; const eventPayload = { event_name: eventName, event_time: eventTime, 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], }; 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 = `${graphBase(env)}/${env.META_PIXEL_ID}/events`; // O token vai no header Authorization, nunca no corpo: assim não chega ao D1 (api_failures.raw_payload). const loggableBody = JSON.stringify(requestBody); // Reenvia preservando event_id/event_time; o limite evita loop quando o consumer da fila falha de novo. const enqueueRetry = () => { const attempt = payload.retryAttempt || 0; if (!env.RETRY_QUEUE || attempt >= MAX_RETRY_ATTEMPTS) return; const send = env.RETRY_QUEUE.send({ eventType: eventName, payload: { ...payload, eventId: eventPayload.event_id, eventTime, retryAttempt: attempt + 1 }, platform: 'meta', }); if (ctx) ctx.waitUntil(send); else send.catch(() => {}); }; try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, 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, Number(errorCode) === 190 ? '- token inválido/expirado: envie um novo em POST /admin/meta-token' : ''); if (env.DB && ctx) { ctx.waitUntil(logApiFailure(env.DB, 'meta', eventName, errorCode, errorMessage, eventPayload.event_id, loggableBody)); } // Falhas transitórias (rate limit / erro do lado da Meta) merecem nova tentativa. if (res.status === 429 || res.status >= 500) enqueueRetry(); } 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, loggableBody)); } enqueueRetry(); return { error: err?.message || String(err) }; } }