/** * CDP Edge — Plataformas Adicionais * Pinterest CAPI v5, Reddit CAPI v2.0, LinkedIn CAPI 202401, Spotify CAPI v1 */ import { sha256, normalizePhone } from '../utils.js'; import { logApiFailure } from '../db.js'; import { Env, TrackPayload } from '../../types.js'; import { ExecutionContext } from '@cloudflare/workers-types'; // ── Pinterest Conversions API v5 ────────────────────────────────────────────── export async function sendPinterestCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise { if (!env.PINTEREST_ACCESS_TOKEN || !env.PINTEREST_AD_ACCOUNT_ID) { return { skipped: 'Pinterest credentials not set' }; } const { email, phone, userId, eventId, pageUrl, value, currency, contentIds, contentName } = payload; const phoneNorm = normalizePhone(phone); const pinterestEventMap: Record = { PageView: 'pagevisit', ViewContent: 'pagevisit', Lead: 'lead', Purchase: 'checkout', AddToCart: 'addtocart', InitiateCheckout: 'checkout', CompleteRegistration: 'signup', Search: 'search', Contact: 'lead', }; const pEvent = pinterestEventMap[eventName] || 'custom'; const userData: Record = { ...(email && { em: [await sha256(email) || ''] }), ...(phoneNorm && { ph: [await sha256(phoneNorm) || ''] }), ...(userId && { external_id: [await sha256(String(userId)) || ''] }), client_ip_address: request?.headers.get('CF-Connecting-IP') || '', client_user_agent: request?.headers.get('User-Agent') || '', }; const body = { data: [{ event_name: pEvent, action_source: 'web', event_time: Math.floor(Date.now() / 1000), event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, event_source_url: pageUrl || '', user_data: userData, custom_data: { currency: (currency || 'BRL').toUpperCase(), value: value ? String(parseFloat(String(value))) : '0', ...(contentIds && contentIds.length > 0 && { content_ids: contentIds.map(String) }), ...(contentName && { content_name: contentName }), content_type: 'product', }, }], }; try { const res = await fetch( `https://api.pinterest.com/v5/ad_accounts/${env.PINTEREST_AD_ACCOUNT_ID}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.PINTEREST_ACCESS_TOKEN}` }, body: JSON.stringify(body) } ); const data = await res.json(); if (!res.ok) { const msg = (data as any).message || (data as any).code || String(res.status); console.error('Pinterest CAPI error:', res.status, msg); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, String(res.status), msg, (body.data as any)[0].event_id, JSON.stringify(body))); } return data; } catch (err: any) { console.error('Pinterest CAPI fetch failed:', err?.message || String(err)); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body))); if (env.RETRY_QUEUE) { const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'pinterest' }); if (ctx) ctx.waitUntil(send); else send.catch(() => {}); } return { error: err?.message || String(err) }; } } // ── Reddit Conversions API v2.0 ─────────────────────────────────────────────── export async function sendRedditCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise { if (!env.REDDIT_ACCESS_TOKEN || !env.REDDIT_AD_ACCOUNT_ID) { return { skipped: 'Reddit credentials not set' }; } const { email, phone, userId, eventId, pageUrl, value, currency } = payload; const phoneNorm = normalizePhone(phone); const redditEventMap: Record = { PageView: 'PageVisit', ViewContent: 'ViewContent', Lead: 'Lead', Purchase: 'Purchase', AddToCart: 'AddToCart', InitiateCheckout: 'Purchase', CompleteRegistration: 'SignUp', Search: 'Search', Contact: 'Lead', }; const rEvent = redditEventMap[eventName] || 'Custom'; const user: Record = { ...(email && { email: { value: await sha256(email) || '' } }), ...(phoneNorm && { phoneNumber: { value: await sha256(phoneNorm) || '' } }), ...(userId && { externalId: { value: await sha256(String(userId)) || '' } }), ipAddress: { value: request?.headers.get('CF-Connecting-IP') || '' }, userAgent: { value: request?.headers.get('User-Agent') || '' }, }; const event: Record = { event_at: new Date().toISOString(), event_type: { tracking_type: rEvent, ...(eventName === 'InitiateCheckout' && { conversion_type: 'BEGIN_CHECKOUT' }) }, click_id: (payload as any).rdtClid || '', event_metadata: { currency: (currency || 'BRL').toUpperCase(), value_decimal: String(value || 0), item_count: '1', conversion_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, }, user, }; const body = { events: [event] }; try { const res = await fetch( `https://ads-api.reddit.com/api/v2.0/conversions/events/${env.REDDIT_AD_ACCOUNT_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.REDDIT_ACCESS_TOKEN}` }, body: JSON.stringify(body) } ); if (!res.ok) { const txt = await res.text(); console.error('Reddit CAPI error:', txt); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, String(res.status), txt, event.event_metadata.conversion_id, JSON.stringify(body))); return { error: `HTTP ${res.status}` }; } return await res.json(); } catch (err: any) { console.error('Reddit CAPI fetch failed:', err?.message || String(err)); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body))); if (env.RETRY_QUEUE) { const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'reddit' }); if (ctx) ctx.waitUntil(send); else send.catch(() => {}); } return { error: err?.message || String(err) }; } } // ── LinkedIn Conversions API (LinkedIn-Version: 202401) ─────────────────────── export async function sendLinkedInCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise { if (!env.LINKEDIN_ACCESS_TOKEN || !env.LINKEDIN_CONVERSION_ID) { return { skipped: 'LinkedIn credentials not set' }; } const { email, phone, firstName, lastName, userId, eventId, pageUrl, value, currency } = payload; const phoneNorm = normalizePhone(phone); const linkedInEventMap: Record = { Lead: 'LEAD', Purchase: 'PURCHASE', CompleteRegistration: 'REGISTRATION', AddToCart: 'ADD_TO_CART', InitiateCheckout: 'OTHER', ViewContent: 'OTHER', PageView: 'OTHER', Contact: 'LEAD', }; const userInfo: Record = { ...(email && { 'SHA256_EMAIL': await sha256(email) || '' }), ...(phoneNorm && { 'SHA256_PHONE': await sha256(phoneNorm) || '' }), ...(firstName && { 'SHA256_FIRST_NAME': await sha256(firstName.toLowerCase().trim()) || '' }), ...(lastName && { 'SHA256_LAST_NAME': await sha256(lastName.toLowerCase().trim()) || '' }), }; const body: Record = { conversion: `urn:li:conversion:${env.LINKEDIN_CONVERSION_ID}`, conversionHappenedAt: Date.now(), conversionValue: value ? { currencyCode: (currency || 'BRL').toUpperCase(), amount: String(parseFloat(String(value))) } : undefined, eventId: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, ...(Object.keys(userInfo).length > 0 && { user: { userIds: Object.entries(userInfo).map(([idType, idValue]) => ({ idType, idValue })) } }), }; try { const res = await fetch('https://api.linkedin.com/rest/conversionEvents', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.LINKEDIN_ACCESS_TOKEN}`, 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0', }, body: JSON.stringify(body), }); if (!res.ok) { const txt = await res.text(); console.error('LinkedIn CAPI error:', txt); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, String(res.status), txt, body.eventId, JSON.stringify(body))); return { error: `HTTP ${res.status}` }; } return { ok: true }; } catch (err: any) { console.error('LinkedIn CAPI fetch failed:', err?.message || String(err)); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body))); if (env.RETRY_QUEUE) { const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'linkedin' }); if (ctx) ctx.waitUntil(send); else send.catch(() => {}); } return { error: err?.message || String(err) }; } } // ── Spotify Conversions API v1 ──────────────────────────────────────────────── export async function sendSpotifyCapi(env: Env, eventName: string, payload: TrackPayload, request: Request | null, ctx: ExecutionContext | null): Promise { if (!env.SPOTIFY_ACCESS_TOKEN || !env.SPOTIFY_AD_ACCOUNT_ID) { return { skipped: 'Spotify credentials not set' }; } const { email, phone, userId, eventId, pageUrl, value, currency } = payload; const phoneNorm = normalizePhone(phone); const spotifyEventMap: Record = { Purchase: 'PURCHASE', Lead: 'LEAD', CompleteRegistration: 'SIGN_UP', AddToCart: 'ADD_TO_CART', InitiateCheckout: 'INITIATE_CHECKOUT', ViewContent: 'VIEW_CONTENT', PageView: 'PAGE_VIEW', Contact: 'LEAD', }; const spEvent = spotifyEventMap[eventName] || 'CUSTOM'; const user: Record = { ...(email && { hashed_email: await sha256(email) || '' }), ...(phoneNorm && { hashed_phone: await sha256(phoneNorm) || '' }), ...(userId && { user_id: userId }), ip_address: request?.headers.get('CF-Connecting-IP') || '', user_agent: request?.headers.get('User-Agent') || '', }; const body = { data: [{ event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, event_type: spEvent, event_time: Math.floor(Date.now() / 1000), url: pageUrl || '', user, ...(value !== undefined && { value: { currency: (currency || 'BRL').toUpperCase(), amount: parseFloat(String(value)) }, }), }], }; try { const res = await fetch( `https://advertising-api.spotify.com/conversion/v1/accounts/${env.SPOTIFY_AD_ACCOUNT_ID}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.SPOTIFY_ACCESS_TOKEN}` }, body: JSON.stringify(body) } ); if (!res.ok) { const txt = await res.text(); console.error('Spotify CAPI error:', txt); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, String(res.status), txt, (body.data as any)[0].event_id, JSON.stringify(body))); return { error: `HTTP ${res.status}` }; } return await res.json(); } catch (err: any) { console.error('Spotify CAPI fetch failed:', err?.message || String(err)); if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body))); if (env.RETRY_QUEUE) { const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'spotify' }); if (ctx) ctx.waitUntil(send); else send.catch(() => {}); } return { error: err?.message || String(err) }; } }