cdp-edge 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +325 -308
- package/contracts/agent-versions.json +364 -0
- package/dist/commands/install.js +1 -1
- package/dist/commands/setup.js +1 -1
- package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +2 -2
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +14 -20
- package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/server-tracking.md +13 -13
- package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +3 -3
- package/package.json +81 -76
- package/server-edge-tracker/index.js +780 -0
- package/server-edge-tracker/modules/db.js +531 -0
- package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
- package/server-edge-tracker/modules/dispatch/meta.js +103 -0
- package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
- package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
- package/server-edge-tracker/modules/intelligence.js +204 -0
- package/server-edge-tracker/modules/ml/bidding.js +245 -0
- package/server-edge-tracker/modules/ml/fraud.js +301 -0
- package/server-edge-tracker/modules/ml/ltv.js +320 -0
- package/server-edge-tracker/modules/ml/segmentation.js +316 -0
- package/server-edge-tracker/modules/utils.js +89 -0
- package/server-edge-tracker/schema-indexes.sql +67 -0
- package/server-edge-tracker/wrangler.toml +2 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Camada D1 (Database)
|
|
3
|
+
* Todas as operações de escrita/leitura no banco D1.
|
|
4
|
+
* Bindings: env.DB, env.GEO_CACHE, env.AUDIT_LOGS
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { sha256, normalizePhone, normalizeCity } from './utils.js';
|
|
8
|
+
|
|
9
|
+
// ── saveLead — inserir evento de conversão ────────────────────────────────────
|
|
10
|
+
export async function saveLead(env, eventName, payload, request, platform = 'website') {
|
|
11
|
+
if (!env.DB) return;
|
|
12
|
+
try {
|
|
13
|
+
const {
|
|
14
|
+
email, phone, firstName, lastName,
|
|
15
|
+
city, state, country,
|
|
16
|
+
fbp, fbc, userId,
|
|
17
|
+
utmSource, utmMedium, utmCampaign, utmContent, utmTerm,
|
|
18
|
+
pageUrl, value, currency, eventId, botScore,
|
|
19
|
+
engagementScore, intentionLevel, utmRestored,
|
|
20
|
+
} = payload;
|
|
21
|
+
|
|
22
|
+
await env.DB.prepare(`
|
|
23
|
+
INSERT INTO leads (
|
|
24
|
+
event_name, event_id, email, phone, first_name, last_name,
|
|
25
|
+
city, state, country, fbp, fbc, user_id,
|
|
26
|
+
utm_source, utm_medium, utm_campaign, utm_content, utm_term,
|
|
27
|
+
page_url, value, currency, ip_address, platform, bot_score,
|
|
28
|
+
engagement_score, intention_level, utm_restored, created_at
|
|
29
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
30
|
+
`).bind(
|
|
31
|
+
eventName,
|
|
32
|
+
eventId || null,
|
|
33
|
+
email || null,
|
|
34
|
+
normalizePhone(phone) || null,
|
|
35
|
+
firstName || null,
|
|
36
|
+
lastName || null,
|
|
37
|
+
city || null,
|
|
38
|
+
state || null,
|
|
39
|
+
(country || request.cf?.country || null),
|
|
40
|
+
fbp || null,
|
|
41
|
+
fbc || null,
|
|
42
|
+
userId || null,
|
|
43
|
+
utmSource || null,
|
|
44
|
+
utmMedium || null,
|
|
45
|
+
utmCampaign || null,
|
|
46
|
+
utmContent || null,
|
|
47
|
+
utmTerm || null,
|
|
48
|
+
pageUrl || null,
|
|
49
|
+
value !== undefined ? parseFloat(value) : null,
|
|
50
|
+
currency || 'BRL',
|
|
51
|
+
request.headers.get('CF-Connecting-IP') || null,
|
|
52
|
+
platform,
|
|
53
|
+
botScore || 0,
|
|
54
|
+
engagementScore !== undefined ? parseFloat(engagementScore) : null,
|
|
55
|
+
intentionLevel || null,
|
|
56
|
+
utmRestored ? 1 : 0,
|
|
57
|
+
).run();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('D1 saveLead error:', err.message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── calculateCohortLabel ──────────────────────────────────────────────────────
|
|
64
|
+
export function calculateCohortLabel(score, eventName) {
|
|
65
|
+
if (eventName === 'Purchase') return 'buyer_lookalike';
|
|
66
|
+
if (score >= 80) return 'high_intent';
|
|
67
|
+
if (score >= 30) return 'nurture';
|
|
68
|
+
return 'lost';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── upsertProfile — acumula cookies/scores entre visitas ─────────────────────
|
|
72
|
+
export async function upsertProfile(env, eventName, payload, request) {
|
|
73
|
+
if (!env.DB || !payload.userId) return;
|
|
74
|
+
try {
|
|
75
|
+
const {
|
|
76
|
+
userId, email, phone,
|
|
77
|
+
fbp, fbc, ttp, gclid, ttclid, gaClientId,
|
|
78
|
+
city, state, country,
|
|
79
|
+
engagementScore, userScore,
|
|
80
|
+
} = payload;
|
|
81
|
+
|
|
82
|
+
const scoreMap = { PageView: 5, ViewContent: 10, ScrollDepth: 3, TimeOnPage: 5, Lead: 30, InitiateCheckout: 50, Purchase: 100 };
|
|
83
|
+
const eventScore = scoreMap[eventName] || 2;
|
|
84
|
+
|
|
85
|
+
const behaviorBonus = userScore
|
|
86
|
+
? Math.round((Math.min(userScore, 100) / 100) * 20)
|
|
87
|
+
: (engagementScore ? Math.round((Math.min(engagementScore, 5) / 5) * 10) : 0);
|
|
88
|
+
|
|
89
|
+
const totalDelta = eventScore + behaviorBonus;
|
|
90
|
+
|
|
91
|
+
await env.DB.prepare(`
|
|
92
|
+
INSERT INTO user_profiles
|
|
93
|
+
(user_id, email, phone, fbp, fbc, ttp, gclid, ttclid, ga_client_id,
|
|
94
|
+
city, state, country, score, cohort_label, created_at, updated_at)
|
|
95
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
|
|
96
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
97
|
+
email = COALESCE(excluded.email, user_profiles.email),
|
|
98
|
+
phone = COALESCE(excluded.phone, user_profiles.phone),
|
|
99
|
+
fbp = COALESCE(excluded.fbp, user_profiles.fbp),
|
|
100
|
+
fbc = COALESCE(excluded.fbc, user_profiles.fbc),
|
|
101
|
+
ttp = COALESCE(excluded.ttp, user_profiles.ttp),
|
|
102
|
+
gclid = COALESCE(excluded.gclid, user_profiles.gclid),
|
|
103
|
+
ttclid = COALESCE(excluded.ttclid, user_profiles.ttclid),
|
|
104
|
+
ga_client_id = COALESCE(excluded.ga_client_id, user_profiles.ga_client_id),
|
|
105
|
+
city = COALESCE(excluded.city, user_profiles.city),
|
|
106
|
+
state = COALESCE(excluded.state, user_profiles.state),
|
|
107
|
+
country = COALESCE(excluded.country, user_profiles.country),
|
|
108
|
+
score = user_profiles.score + excluded.score,
|
|
109
|
+
cohort_label = excluded.cohort_label,
|
|
110
|
+
updated_at = datetime('now')
|
|
111
|
+
`).bind(
|
|
112
|
+
userId,
|
|
113
|
+
email || null,
|
|
114
|
+
normalizePhone(phone) || null,
|
|
115
|
+
fbp || null,
|
|
116
|
+
fbc || null,
|
|
117
|
+
ttp || null,
|
|
118
|
+
gclid || null,
|
|
119
|
+
ttclid || null,
|
|
120
|
+
gaClientId || null,
|
|
121
|
+
city || null,
|
|
122
|
+
state || null,
|
|
123
|
+
(country || request.cf?.country || null),
|
|
124
|
+
totalDelta,
|
|
125
|
+
calculateCohortLabel(totalDelta, eventName),
|
|
126
|
+
).run();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error('D1 upsertProfile error:', err.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── resolveDeviceGraph — Cross-Device Identity ────────────────────────────────
|
|
133
|
+
export async function resolveDeviceGraph(DB, currentUserId, email, phone) {
|
|
134
|
+
if (!DB || !currentUserId) return;
|
|
135
|
+
if (!email && !phone) return;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const conditions = [];
|
|
139
|
+
const bindings = [];
|
|
140
|
+
|
|
141
|
+
if (email) {
|
|
142
|
+
conditions.push('email = ?');
|
|
143
|
+
bindings.push(email.toLowerCase().trim());
|
|
144
|
+
}
|
|
145
|
+
if (phone) {
|
|
146
|
+
const digits = String(phone).replace(/\D/g, '');
|
|
147
|
+
if (digits.length >= 10) {
|
|
148
|
+
conditions.push('phone LIKE ?');
|
|
149
|
+
bindings.push(`%${digits.slice(-10)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (conditions.length === 0) return;
|
|
154
|
+
|
|
155
|
+
bindings.push(currentUserId);
|
|
156
|
+
const rows = await DB.prepare(`
|
|
157
|
+
SELECT user_id, email, phone, created_at
|
|
158
|
+
FROM user_profiles
|
|
159
|
+
WHERE (${conditions.join(' OR ')})
|
|
160
|
+
AND user_id != ?
|
|
161
|
+
ORDER BY created_at ASC
|
|
162
|
+
LIMIT 5
|
|
163
|
+
`).bind(...bindings).all();
|
|
164
|
+
|
|
165
|
+
if (!rows.results || rows.results.length === 0) return;
|
|
166
|
+
|
|
167
|
+
for (const match of rows.results) {
|
|
168
|
+
const emailMatch = email && match.email &&
|
|
169
|
+
email.toLowerCase().trim() === match.email.toLowerCase().trim();
|
|
170
|
+
const phoneMatch = phone && match.phone && (() => {
|
|
171
|
+
const a = String(phone).replace(/\D/g, '');
|
|
172
|
+
const b = String(match.phone).replace(/\D/g, '');
|
|
173
|
+
return a.slice(-10) === b.slice(-10) && a.length >= 10;
|
|
174
|
+
})();
|
|
175
|
+
|
|
176
|
+
if (!emailMatch && !phoneMatch) continue;
|
|
177
|
+
|
|
178
|
+
const matchType = emailMatch && phoneMatch ? 'email+phone' : (emailMatch ? 'email' : 'phone');
|
|
179
|
+
const matchConfidence = emailMatch && phoneMatch ? 0.99 : (emailMatch ? 0.95 : 0.85);
|
|
180
|
+
const primary = match.user_id;
|
|
181
|
+
const secondary = currentUserId;
|
|
182
|
+
|
|
183
|
+
await DB.prepare(`
|
|
184
|
+
INSERT OR IGNORE INTO device_graph
|
|
185
|
+
(primary_user_id, secondary_user_id, match_type, match_confidence)
|
|
186
|
+
VALUES (?, ?, ?, ?)
|
|
187
|
+
`).bind(primary, secondary, matchType, matchConfidence).run();
|
|
188
|
+
|
|
189
|
+
console.log(`[DeviceGraph] Linked ${secondary} → ${primary} via ${matchType} (confidence: ${matchConfidence})`);
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('resolveDeviceGraph error:', err.message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── fireAutomation — dispara regras de automação (WA/Email) ──────────────────
|
|
197
|
+
export async function fireAutomation(env, eventName, leadId, payload) {
|
|
198
|
+
if (!env.DB) return;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const { results: rules } = await env.DB
|
|
202
|
+
.prepare(
|
|
203
|
+
`SELECT id, channel, subject_template, message_template
|
|
204
|
+
FROM automation_rules
|
|
205
|
+
WHERE trigger_event = ?1 AND is_active = 1`
|
|
206
|
+
)
|
|
207
|
+
.bind(eventName)
|
|
208
|
+
.all();
|
|
209
|
+
|
|
210
|
+
if (!rules || rules.length === 0) return;
|
|
211
|
+
|
|
212
|
+
const vars = {
|
|
213
|
+
name: String(payload.firstName || payload.name || ''),
|
|
214
|
+
email: String(payload.email || ''),
|
|
215
|
+
phone: String(payload.phone || ''),
|
|
216
|
+
campaign: String(payload.utm_campaign || payload.utmCampaign || ''),
|
|
217
|
+
intention: String(payload.intentionLevel || payload.intention_level || ''),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const interpolate = (tpl) =>
|
|
221
|
+
tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
|
|
222
|
+
|
|
223
|
+
for (const rule of rules) {
|
|
224
|
+
const message = interpolate(rule.message_template);
|
|
225
|
+
const subject = rule.subject_template ? interpolate(rule.subject_template) : null;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
if (rule.channel === 'whatsapp' && payload.phone && env.WHATSAPP_ACCESS_TOKEN && env.WHATSAPP_PHONE_NUMBER_ID) {
|
|
229
|
+
const digits = String(payload.phone).replace(/\D/g, '');
|
|
230
|
+
const e164 = digits.startsWith('55') ? `+${digits}` : `+55${digits}`;
|
|
231
|
+
const waRes = await fetch(
|
|
232
|
+
`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
|
|
233
|
+
{
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: { 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
236
|
+
body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to: e164, type: 'text', text: { body: message } }),
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
const waData = await waRes.json();
|
|
240
|
+
const status = waRes.ok ? 'sent' : 'failed';
|
|
241
|
+
const meta = waRes.ok ? (waData.messages?.[0]?.id ?? null) : JSON.stringify(waData);
|
|
242
|
+
await env.DB.prepare(
|
|
243
|
+
`INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
|
|
244
|
+
).bind(leadId, 'whatsapp', e164, null, message, status, meta).run();
|
|
245
|
+
|
|
246
|
+
} else if (rule.channel === 'email' && payload.email && env.RESEND_API_KEY) {
|
|
247
|
+
const resendRes = await fetch('https://api.resend.com/emails', {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
from: env.RESEND_FROM_EMAIL || 'noreply@cdp-edge.app',
|
|
252
|
+
to: [payload.email],
|
|
253
|
+
subject: subject || `Olá, ${vars.name || 'você'}!`,
|
|
254
|
+
html: `<p>${message.replace(/\n/g, '<br>')}</p>`,
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
const resendData = await resendRes.json();
|
|
258
|
+
const status = resendRes.ok ? 'sent' : 'failed';
|
|
259
|
+
const meta = resendRes.ok ? (resendData.id ?? null) : JSON.stringify(resendData);
|
|
260
|
+
await env.DB.prepare(
|
|
261
|
+
`INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
|
|
262
|
+
).bind(leadId, 'email', payload.email, subject, message, status, meta).run();
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(`[Automation] rule ${rule.id} error:`, err.message);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error('[Automation] fireAutomation error:', err.message);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── getProfileByEmail ─────────────────────────────────────────────────────────
|
|
274
|
+
export async function getProfileByEmail(env, email) {
|
|
275
|
+
if (!env.DB || !email) return null;
|
|
276
|
+
try {
|
|
277
|
+
return await env.DB.prepare(
|
|
278
|
+
'SELECT * FROM user_profiles WHERE email = ? ORDER BY updated_at DESC LIMIT 1'
|
|
279
|
+
).bind(email.toLowerCase().trim()).first();
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── enrichGeoFromEdge — enriquece payload com dados de geolocalização ─────────
|
|
286
|
+
export async function enrichGeoFromEdge(request, env, payload) {
|
|
287
|
+
const cf = request.cf || {};
|
|
288
|
+
const ip = request.headers.get('CF-Connecting-IP') || '';
|
|
289
|
+
|
|
290
|
+
let geoData = null;
|
|
291
|
+
if (env.GEO_CACHE && ip) {
|
|
292
|
+
try {
|
|
293
|
+
const cached = await env.GEO_CACHE.get(`geo:${ip}`, 'json');
|
|
294
|
+
if (cached) geoData = cached;
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!geoData) {
|
|
299
|
+
geoData = {
|
|
300
|
+
country: cf.country || null,
|
|
301
|
+
continent: cf.continent || null,
|
|
302
|
+
asn: cf.asn || null,
|
|
303
|
+
asOrg: cf.asOrganization || null,
|
|
304
|
+
colo: cf.colo || null,
|
|
305
|
+
city: cf.city || null,
|
|
306
|
+
region: cf.region || null,
|
|
307
|
+
regionCode: cf.regionCode || null,
|
|
308
|
+
postalCode: cf.postalCode || null,
|
|
309
|
+
latitude: cf.latitude || null,
|
|
310
|
+
longitude: cf.longitude || null,
|
|
311
|
+
timezone: cf.timezone || null,
|
|
312
|
+
metroCode: cf.metroCode || null,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (env.GEO_CACHE && ip && geoData.country) {
|
|
316
|
+
try {
|
|
317
|
+
await env.GEO_CACHE.put(`geo:${ip}`, JSON.stringify(geoData), { expirationTtl: 3600 });
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
payload.country = payload.country || geoData.country;
|
|
323
|
+
payload.city = payload.city || geoData.city;
|
|
324
|
+
payload.state = payload.state || geoData.regionCode;
|
|
325
|
+
payload.zip = payload.zip || geoData.postalCode;
|
|
326
|
+
payload.geo = geoData;
|
|
327
|
+
|
|
328
|
+
return geoData;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── writeAuditLog — grava evento no R2 ───────────────────────────────────────
|
|
332
|
+
export async function writeAuditLog(env, eventName, payload, geoData) {
|
|
333
|
+
if (!env.AUDIT_LOGS) return;
|
|
334
|
+
try {
|
|
335
|
+
const now = new Date();
|
|
336
|
+
const y = now.getUTCFullYear();
|
|
337
|
+
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
338
|
+
const d = String(now.getUTCDate()).padStart(2, '0');
|
|
339
|
+
const key = `logs/${y}/${m}/${d}/${now.getTime()}_${eventName}.json`;
|
|
340
|
+
|
|
341
|
+
const log = {
|
|
342
|
+
timestamp: now.toISOString(),
|
|
343
|
+
event: eventName,
|
|
344
|
+
userId: payload.userId || null,
|
|
345
|
+
eventId: payload.eventId || null,
|
|
346
|
+
value: payload.value || null,
|
|
347
|
+
currency: payload.currency || null,
|
|
348
|
+
ltvClass: payload.ltvClass || null,
|
|
349
|
+
utm: {
|
|
350
|
+
source: payload.utmSource || null,
|
|
351
|
+
medium: payload.utmMedium || null,
|
|
352
|
+
campaign: payload.utmCampaign || null,
|
|
353
|
+
content: payload.utmContent || null,
|
|
354
|
+
term: payload.utmTerm || null,
|
|
355
|
+
restored: payload.utmRestored || false,
|
|
356
|
+
},
|
|
357
|
+
geo: geoData || null,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
await env.AUDIT_LOGS.put(key, JSON.stringify(log), {
|
|
361
|
+
httpMetadata: { contentType: 'application/json' },
|
|
362
|
+
});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error('[R2 Audit] Error:', err.message);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── generateEdgeFingerprint ───────────────────────────────────────────────────
|
|
369
|
+
export async function generateEdgeFingerprint(request) {
|
|
370
|
+
const asn = String(request.cf?.asn || '0');
|
|
371
|
+
const lang = (request.headers.get('Accept-Language') || 'unknown').split(',')[0].trim();
|
|
372
|
+
const ua = request.headers.get('User-Agent') || '';
|
|
373
|
+
|
|
374
|
+
const uaBase = ua
|
|
375
|
+
.toLowerCase()
|
|
376
|
+
.replace(/[\d.]+/g, '')
|
|
377
|
+
.replace(/[^a-z\s]/g, ' ')
|
|
378
|
+
.split(' ')
|
|
379
|
+
.filter(w => w.length > 3)
|
|
380
|
+
.slice(0, 4)
|
|
381
|
+
.join(' ')
|
|
382
|
+
.trim();
|
|
383
|
+
|
|
384
|
+
const raw = `${asn}|${lang}|${uaBase}`;
|
|
385
|
+
return sha256(raw);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── saveEdgeFingerprint ───────────────────────────────────────────────────────
|
|
389
|
+
export async function saveEdgeFingerprint(DB, fingerprint, userId, payload) {
|
|
390
|
+
if (!DB || !fingerprint) return;
|
|
391
|
+
const { utmSource, utmMedium, utmCampaign, utmContent, utmTerm } = payload;
|
|
392
|
+
if (!utmSource) return;
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
await DB.prepare(`
|
|
396
|
+
INSERT INTO edge_fingerprints (fingerprint, user_id, utm_source, utm_medium, utm_campaign, utm_content, utm_term)
|
|
397
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
398
|
+
`).bind(
|
|
399
|
+
fingerprint,
|
|
400
|
+
userId || null,
|
|
401
|
+
utmSource || null,
|
|
402
|
+
utmMedium || null,
|
|
403
|
+
utmCampaign || null,
|
|
404
|
+
utmContent || null,
|
|
405
|
+
utmTerm || null,
|
|
406
|
+
).run();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.error('saveEdgeFingerprint error:', err.message);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── resurrectUTM ──────────────────────────────────────────────────────────────
|
|
413
|
+
export async function resurrectUTM(DB, fingerprint) {
|
|
414
|
+
if (!DB || !fingerprint) return null;
|
|
415
|
+
try {
|
|
416
|
+
return await DB.prepare(`
|
|
417
|
+
SELECT utm_source, utm_medium, utm_campaign, utm_content, utm_term
|
|
418
|
+
FROM edge_fingerprints
|
|
419
|
+
WHERE fingerprint = ?
|
|
420
|
+
AND utm_source IS NOT NULL
|
|
421
|
+
AND created_at > datetime('now', '-48 hours')
|
|
422
|
+
ORDER BY created_at DESC
|
|
423
|
+
LIMIT 1
|
|
424
|
+
`).bind(fingerprint).first();
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── upsertLtvProfile — persiste LTV no perfil ────────────────────────────────
|
|
431
|
+
export async function upsertLtvProfile(env, userId, ltv) {
|
|
432
|
+
if (!env.DB || !userId) return;
|
|
433
|
+
try {
|
|
434
|
+
await env.DB.prepare(`
|
|
435
|
+
UPDATE user_profiles
|
|
436
|
+
SET predicted_ltv_class = ?,
|
|
437
|
+
predicted_ltv_value = ?,
|
|
438
|
+
updated_at = datetime('now')
|
|
439
|
+
WHERE user_id = ?
|
|
440
|
+
`).bind(ltv.class, ltv.value, userId).run();
|
|
441
|
+
} catch (err) {
|
|
442
|
+
console.error('upsertLtvProfile error:', err.message);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Feedback Loop — Log de falhas e métricas de saúde ────────────────────────
|
|
447
|
+
|
|
448
|
+
export async function logApiFailure(DB, platform, eventName, errorCode, errorMessage, eventId, rawPayload) {
|
|
449
|
+
try {
|
|
450
|
+
await DB.prepare(`
|
|
451
|
+
INSERT INTO api_failures (platform, event_name, error_code, error_message, event_id, raw_payload, retry_count, final_status)
|
|
452
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, 'failed')
|
|
453
|
+
`).bind(platform, eventName, String(errorCode), errorMessage, eventId, rawPayload).run();
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error('Failed to log API failure:', err.message);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function getHealthMetrics(DB, platform, hours = 24) {
|
|
460
|
+
try {
|
|
461
|
+
const failures = await DB.prepare(`
|
|
462
|
+
SELECT COUNT(*) as count, error_code
|
|
463
|
+
FROM api_failures
|
|
464
|
+
WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
|
|
465
|
+
GROUP BY error_code
|
|
466
|
+
`).bind(platform).all();
|
|
467
|
+
|
|
468
|
+
const totalSent = await DB.prepare(`
|
|
469
|
+
SELECT COUNT(*) as count
|
|
470
|
+
FROM leads
|
|
471
|
+
WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
|
|
472
|
+
`).bind(platform).first();
|
|
473
|
+
|
|
474
|
+
const totalFailed = failures.results?.reduce((sum, f) => sum + f.count, 0) || 0;
|
|
475
|
+
const successRate = totalSent?.count > 0
|
|
476
|
+
? ((totalSent.count - totalFailed) / totalSent.count) * 100
|
|
477
|
+
: 100;
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
platform,
|
|
481
|
+
hours,
|
|
482
|
+
events_sent: totalSent?.count || 0,
|
|
483
|
+
events_failed: totalFailed,
|
|
484
|
+
success_rate: successRate,
|
|
485
|
+
errors_detected: (failures.results || []).map(f => ({ code: f.error_code, count: f.count })),
|
|
486
|
+
issues: totalFailed > (totalSent?.count || 0) * 0.1 ? ['high_error_rate'] : [],
|
|
487
|
+
};
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error('Failed to get health metrics:', err.message);
|
|
490
|
+
return { platform, hours, events_sent: 0, events_failed: 0, success_rate: 0, errors_detected: [], issues: ['metrics_unavailable'] };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function generateDailyReport(DB) {
|
|
495
|
+
const platforms = ['meta', 'ga4', 'tiktok', 'pinterest', 'reddit'];
|
|
496
|
+
const today = new Date().toISOString().split('T')[0];
|
|
497
|
+
const reports = [];
|
|
498
|
+
|
|
499
|
+
for (const platform of platforms) {
|
|
500
|
+
const metrics = await getHealthMetrics(DB, platform, 24);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
await DB.prepare(`
|
|
504
|
+
INSERT INTO health_reports (report_date, platform, events_sent, events_failed, success_rate, errors_detected, issues_detected)
|
|
505
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
506
|
+
`).bind(
|
|
507
|
+
today, platform,
|
|
508
|
+
metrics.events_sent, metrics.events_failed, metrics.success_rate,
|
|
509
|
+
JSON.stringify(metrics.errors_detected), JSON.stringify(metrics.issues)
|
|
510
|
+
).run();
|
|
511
|
+
reports.push({ platform, status: 'ok' });
|
|
512
|
+
} catch (err) {
|
|
513
|
+
console.error(`Failed to generate report for ${platform}:`, err.message);
|
|
514
|
+
reports.push({ platform, status: 'failed' });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return reports;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export async function logIntelligence(DB, runType, platform, checkType, status, currentValue, expectedValue, message, alertSent = false) {
|
|
522
|
+
if (!DB) return;
|
|
523
|
+
try {
|
|
524
|
+
await DB.prepare(`
|
|
525
|
+
INSERT INTO intelligence_logs (run_type, platform, check_type, status, current_value, expected_value, message, alert_sent)
|
|
526
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
527
|
+
`).bind(runType, platform, checkType, status, String(currentValue ?? ''), String(expectedValue ?? ''), message, alertSent ? 1 : 0).run();
|
|
528
|
+
} catch (err) {
|
|
529
|
+
console.error('logIntelligence error:', err.message);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — GA4 Measurement Protocol
|
|
3
|
+
* Envia eventos server-side para o GA4 via Measurement Protocol.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { normalizePhone } from '../utils.js';
|
|
7
|
+
import { logApiFailure } from '../db.js';
|
|
8
|
+
|
|
9
|
+
export async function sendGA4Mp(env, ga4EventName, payload, ctx) {
|
|
10
|
+
if (!env.GA4_API_SECRET) return { skipped: 'GA4_API_SECRET not set' };
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
clientId, sessionId,
|
|
14
|
+
value, currency, contentName,
|
|
15
|
+
email, phone, firstName,
|
|
16
|
+
orderId,
|
|
17
|
+
} = payload;
|
|
18
|
+
|
|
19
|
+
if (!clientId) return { skipped: 'no clientId' };
|
|
20
|
+
|
|
21
|
+
const eventParams = {
|
|
22
|
+
...(value !== undefined && { value: parseFloat(value) }),
|
|
23
|
+
...(currency && { currency: String(currency).toUpperCase() }),
|
|
24
|
+
...(contentName && { content_name: contentName }),
|
|
25
|
+
...(orderId && { transaction_id: orderId }),
|
|
26
|
+
...(email && { user_data_email_address: email.toLowerCase().trim() }),
|
|
27
|
+
...(phone && { user_data_phone_number: normalizePhone(phone) }),
|
|
28
|
+
...(firstName && { user_data_first_name: firstName.toLowerCase().trim() }),
|
|
29
|
+
...(sessionId && { session_id: sessionId }),
|
|
30
|
+
engagement_time_msec: 100,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const body = {
|
|
34
|
+
client_id: clientId,
|
|
35
|
+
events: [{ name: ga4EventName, params: eventParams }],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const url = `https://www.google-analytics.com/mp/collect`
|
|
39
|
+
+ `?measurement_id=${env.GA4_MEASUREMENT_ID}`
|
|
40
|
+
+ `&api_secret=${env.GA4_API_SECRET}`;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (res.status !== 204) {
|
|
50
|
+
if (env.DB && ctx) {
|
|
51
|
+
ctx.waitUntil(logApiFailure(env.DB, 'ga4', ga4EventName, String(res.status), 'GA4 returned non-204 status', null, JSON.stringify(body)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return res.status === 204 ? { ok: true } : { status: res.status };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('GA4 MP fetch failed:', err.message);
|
|
58
|
+
|
|
59
|
+
if (env.DB && ctx) {
|
|
60
|
+
ctx.waitUntil(logApiFailure(env.DB, 'ga4', ga4EventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { error: err.message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Meta Conversions API v22.0
|
|
3
|
+
* Envia eventos server-side para a Meta CAPI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { sha256, normalizePhone, normalizeCity } from '../utils.js';
|
|
7
|
+
import { logApiFailure } from '../db.js';
|
|
8
|
+
|
|
9
|
+
export async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
10
|
+
const {
|
|
11
|
+
email, phone, firstName, lastName,
|
|
12
|
+
city, state, country,
|
|
13
|
+
zip, dob,
|
|
14
|
+
fbp, fbc, userId,
|
|
15
|
+
eventId, pageUrl,
|
|
16
|
+
value, currency,
|
|
17
|
+
contentIds, contentName, contentType, numItems,
|
|
18
|
+
} = payload;
|
|
19
|
+
|
|
20
|
+
const phoneNorm = normalizePhone(phone);
|
|
21
|
+
const countryCode = (country || request.cf?.country || 'br').toLowerCase();
|
|
22
|
+
const stateCode = state ? String(state).toLowerCase() : undefined;
|
|
23
|
+
const cityNorm = normalizeCity(city);
|
|
24
|
+
|
|
25
|
+
const userData = {
|
|
26
|
+
...(email && { em: await sha256(email) }),
|
|
27
|
+
...(phoneNorm && { ph: await sha256(phoneNorm) }),
|
|
28
|
+
...(firstName && { fn: await sha256(firstName) }),
|
|
29
|
+
...(lastName && { ln: await sha256(lastName) }),
|
|
30
|
+
...(cityNorm && { ct: await sha256(cityNorm) }),
|
|
31
|
+
...(stateCode && { st: await sha256(stateCode) }),
|
|
32
|
+
...(countryCode && { country: await sha256(countryCode) }),
|
|
33
|
+
...(userId && { external_id: await sha256(String(userId)) }),
|
|
34
|
+
...(zip && { zp: await sha256(zip) }),
|
|
35
|
+
...(dob && { db: await sha256(dob) }),
|
|
36
|
+
...(fbp && { fbp }),
|
|
37
|
+
...(fbc && { fbc }),
|
|
38
|
+
client_ip_address: request.headers.get('CF-Connecting-IP')
|
|
39
|
+
|| request.headers.get('X-Forwarded-For')
|
|
40
|
+
|| '',
|
|
41
|
+
client_user_agent: request.headers.get('User-Agent') || '',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const customData = {
|
|
45
|
+
...(value !== undefined && { value: parseFloat(value) }),
|
|
46
|
+
...(currency && { currency: String(currency).toUpperCase() }),
|
|
47
|
+
...(contentIds && contentIds.length > 0 && { content_ids: contentIds }),
|
|
48
|
+
...(contentName && { content_name: contentName }),
|
|
49
|
+
...(contentType && { content_type: contentType }),
|
|
50
|
+
...(numItems && { num_items: parseInt(numItems) }),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const eventPayload = {
|
|
54
|
+
event_name: eventName,
|
|
55
|
+
event_time: Math.floor(Date.now() / 1000),
|
|
56
|
+
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
57
|
+
event_source_url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
58
|
+
action_source: 'website',
|
|
59
|
+
user_data: userData,
|
|
60
|
+
...(Object.keys(customData).length > 0 && { custom_data: customData }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const requestBody = {
|
|
64
|
+
data: [eventPayload],
|
|
65
|
+
access_token: env.META_ACCESS_TOKEN,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (env.META_TEST_CODE) {
|
|
69
|
+
requestBody.test_event_code = env.META_TEST_CODE;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(endpoint, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify(requestBody),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const data = await res.json();
|
|
82
|
+
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const errorCode = data.error?.code || String(res.status);
|
|
85
|
+
const errorMessage = data.error?.message || data.error?.error_user_msg || 'Unknown error';
|
|
86
|
+
console.error('Meta CAPI error:', res.status, errorMessage);
|
|
87
|
+
|
|
88
|
+
if (env.DB && ctx) {
|
|
89
|
+
ctx.waitUntil(logApiFailure(env.DB, 'meta', eventName, errorCode, errorMessage, eventPayload.event_id, JSON.stringify(requestBody)));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return data;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('Meta CAPI fetch failed:', err.message);
|
|
96
|
+
|
|
97
|
+
if (env.DB && ctx) {
|
|
98
|
+
ctx.waitUntil(logApiFailure(env.DB, 'meta', eventName, 'FETCH_ERROR', err.message, eventPayload.event_id, JSON.stringify(requestBody)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { error: err.message };
|
|
102
|
+
}
|
|
103
|
+
}
|