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.
Files changed (27) hide show
  1. package/README.md +325 -308
  2. package/contracts/agent-versions.json +364 -0
  3. package/dist/commands/install.js +1 -1
  4. package/dist/commands/setup.js +1 -1
  5. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +2 -2
  6. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +14 -20
  7. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +4 -4
  8. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +13 -13
  9. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +1 -1
  10. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +4 -4
  11. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +3 -3
  12. package/package.json +81 -76
  13. package/server-edge-tracker/index.js +780 -0
  14. package/server-edge-tracker/modules/db.js +531 -0
  15. package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
  16. package/server-edge-tracker/modules/dispatch/meta.js +103 -0
  17. package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
  18. package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
  19. package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
  20. package/server-edge-tracker/modules/intelligence.js +204 -0
  21. package/server-edge-tracker/modules/ml/bidding.js +245 -0
  22. package/server-edge-tracker/modules/ml/fraud.js +301 -0
  23. package/server-edge-tracker/modules/ml/ltv.js +320 -0
  24. package/server-edge-tracker/modules/ml/segmentation.js +316 -0
  25. package/server-edge-tracker/modules/utils.js +89 -0
  26. package/server-edge-tracker/schema-indexes.sql +67 -0
  27. package/server-edge-tracker/wrangler.toml +2 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * CDP Edge — Intelligence Agent + Customer Match
3
+ * runIntelligenceAgent, customer match Meta/Google, health checks
4
+ */
5
+
6
+ import { sha256 } from './utils.js';
7
+ import { getHealthMetrics, generateDailyReport, logIntelligence } from './db.js';
8
+ import { sendCallMeBot } from './dispatch/whatsapp.js';
9
+
10
+ // ── Versões esperadas das APIs ────────────────────────────────────────────────
11
+ const EXPECTED_API_VERSIONS = {
12
+ meta: 'v22.0',
13
+ ga4: 'latest',
14
+ tiktok: 'v1.3',
15
+ pinterest: 'v5',
16
+ reddit: 'v2.0',
17
+ };
18
+
19
+ const ALERT_THRESHOLDS = {
20
+ errorRateCritical: 0.20,
21
+ errorRateWarning: 0.10,
22
+ };
23
+
24
+ // ── Alerta via CallMeBot ──────────────────────────────────────────────────────
25
+ export async function sendIntelligenceAlert(env, severity, title, details) {
26
+ const icon = severity === 'critical' ? '🚨' : '⚠️';
27
+ const texto = `${icon} CDP Edge — ${title}\n\n${details}\n\n${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
28
+ return sendCallMeBot(env, texto);
29
+ }
30
+
31
+ // ── Check de versões de API ───────────────────────────────────────────────────
32
+ export async function checkApiVersionsIntelligence(env, runType) {
33
+ const results = [];
34
+
35
+ for (const [platform, expected] of Object.entries(EXPECTED_API_VERSIONS)) {
36
+ const currentMap = { meta: 'v22.0', tiktok: 'v1.3', ga4: 'latest', pinterest: 'v5', reddit: 'v2.0' };
37
+ const current = currentMap[platform] || 'unknown';
38
+ const isOk = current === expected || expected === 'latest';
39
+ const status = isOk ? 'ok' : 'warning';
40
+
41
+ await logIntelligence(env.DB, runType, platform, 'api_version', status, current, expected,
42
+ isOk ? `${platform} ${current} — versão correta` : `${platform} ${current} desatualizado, esperado ${expected}`
43
+ );
44
+
45
+ results.push({ platform, current, expected, status });
46
+ }
47
+
48
+ return results;
49
+ }
50
+
51
+ // ── Auditoria de taxa de erro ─────────────────────────────────────────────────
52
+ export async function auditErrorRates(env, runType) {
53
+ if (!env.DB) return [];
54
+ const alerts = [];
55
+
56
+ for (const platform of ['meta', 'ga4', 'tiktok']) {
57
+ const metrics = await getHealthMetrics(env.DB, platform, 24);
58
+ const errorRate = metrics.events_sent > 0 ? metrics.events_failed / metrics.events_sent : 0;
59
+
60
+ let status = 'ok';
61
+ if (errorRate >= ALERT_THRESHOLDS.errorRateCritical) status = 'critical';
62
+ else if (errorRate >= ALERT_THRESHOLDS.errorRateWarning) status = 'warning';
63
+
64
+ const message = `${platform}: ${metrics.events_sent} eventos, ${metrics.events_failed} falhas (${(errorRate * 100).toFixed(1)}%)`;
65
+ const alertSent = status !== 'ok'
66
+ ? await sendIntelligenceAlert(env, status, `Taxa de Erro Alta — ${platform.toUpperCase()}`,
67
+ `📊 ${message}\n🎯 Taxa: ${(errorRate * 100).toFixed(1)}% (limite: ${ALERT_THRESHOLDS.errorRateWarning * 100}%)`)
68
+ : false;
69
+
70
+ await logIntelligence(env.DB, runType, platform, 'error_rate', status,
71
+ `${(errorRate * 100).toFixed(1)}%`, `${ALERT_THRESHOLDS.errorRateWarning * 100}%`, message, alertSent
72
+ );
73
+
74
+ if (status !== 'ok') alerts.push({ platform, errorRate, status });
75
+ }
76
+
77
+ return alerts;
78
+ }
79
+
80
+ // ── Runner principal do Intelligence Agent ────────────────────────────────────
81
+ export async function runIntelligenceAgent(env, runType) {
82
+ console.log(`[Intelligence Agent] Iniciando ${runType}`);
83
+
84
+ // 1. Check de versões
85
+ const versionResults = await checkApiVersionsIntelligence(env, runType);
86
+ console.log(`[Intelligence Agent] Versões verificadas: ${versionResults.length} plataformas`);
87
+
88
+ // 2. Relatório diário
89
+ if (env.DB) {
90
+ const reports = await generateDailyReport(env.DB);
91
+ console.log(`[Intelligence Agent] Relatórios gerados: ${reports.length}`);
92
+ }
93
+
94
+ // 3. Auditoria de taxas de erro
95
+ const errorAlerts = await auditErrorRates(env, runType);
96
+ if (errorAlerts.length > 0) {
97
+ console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
98
+ }
99
+
100
+ // 4. Auditoria mensal adicional
101
+ if (runType === 'monthly_audit') {
102
+ if (env.DB) {
103
+ try {
104
+ const ltvStats = await env.DB.prepare(`
105
+ SELECT predicted_ltv_class, COUNT(*) as count
106
+ FROM user_profiles
107
+ WHERE predicted_ltv_class IS NOT NULL AND updated_at > datetime('now', '-30 days')
108
+ GROUP BY predicted_ltv_class
109
+ `).all();
110
+
111
+ const summary = ltvStats.results?.map(r => `${r.predicted_ltv_class}: ${r.count}`).join(', ') || 'sem dados';
112
+ await logIntelligence(env.DB, runType, 'all', 'ltv_distribution', 'ok', summary, null,
113
+ `Distribuição LTV últimos 30 dias: ${summary}`);
114
+ console.log(`[Intelligence Agent] LTV distribution: ${summary}`);
115
+ } catch (err) {
116
+ console.error('LTV audit error:', err.message);
117
+ }
118
+ }
119
+ }
120
+
121
+ // 5. Customer Match sync semanal
122
+ const cmResult = await syncMetaCustomAudience(env);
123
+ console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.num_received ?? 0}`);
124
+
125
+ console.log(`[Intelligence Agent] ${runType} concluído`);
126
+ }
127
+
128
+ // ── syncMetaCustomAudience — D1 → Meta Custom Audiences ─────────────────────
129
+ export async function syncMetaCustomAudience(env) {
130
+ if (!env.META_ACCESS_TOKEN || !env.META_AD_ACCOUNT_ID || !env.META_AUDIENCE_ID) {
131
+ console.log('[CustomerMatch] Meta: secrets não configurados — pulando sync');
132
+ return { skipped: 'META_AD_ACCOUNT_ID ou META_AUDIENCE_ID não configurados' };
133
+ }
134
+ if (!env.DB) return { skipped: 'DB não disponível' };
135
+
136
+ try {
137
+ const profiles = await env.DB.prepare(`
138
+ SELECT email, phone FROM user_profiles
139
+ WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
140
+ AND updated_at > datetime('now', '-30 days')
141
+ AND email IS NOT NULL
142
+ LIMIT 10000
143
+ `).all();
144
+
145
+ if (!profiles.results || profiles.results.length === 0) {
146
+ console.log('[CustomerMatch] Meta: nenhum perfil elegível');
147
+ return { sent: 0 };
148
+ }
149
+
150
+ const data = await Promise.all(
151
+ profiles.results.map(async (p) => [
152
+ p.email ? await sha256(p.email) : '',
153
+ p.phone ? await sha256(p.phone) : '',
154
+ ])
155
+ );
156
+
157
+ const body = { payload: { schema: ['EMAIL_SHA256', 'PHONE_SHA256'], data } };
158
+ const endpoint = `https://graph.facebook.com/v22.0/${env.META_AUDIENCE_ID}/users`;
159
+
160
+ const res = await fetch(endpoint, {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ ...body, access_token: env.META_ACCESS_TOKEN }),
164
+ });
165
+
166
+ const result = await res.json();
167
+
168
+ if (!res.ok) {
169
+ console.error('[CustomerMatch] Meta erro:', res.status, result.error?.message || 'unknown');
170
+ return { error: result.error?.message, sent: 0 };
171
+ }
172
+
173
+ console.log(`[CustomerMatch] Meta: ${profiles.results.length} perfis sincronizados`);
174
+ return { sent: profiles.results.length, num_received: result.num_received };
175
+
176
+ } catch (err) {
177
+ console.error('[CustomerMatch] Meta fetch error:', err.message);
178
+ return { error: err.message, sent: 0 };
179
+ }
180
+ }
181
+
182
+ // ── buildGoogleCustomerMatchExport — gera JSON para Google Ads Customer Match ─
183
+ export async function buildGoogleCustomerMatchExport(env) {
184
+ if (!env.DB) return [];
185
+
186
+ const profiles = await env.DB.prepare(`
187
+ SELECT email, phone, first_name, last_name FROM user_profiles
188
+ WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
189
+ AND updated_at > datetime('now', '-30 days')
190
+ AND email IS NOT NULL
191
+ LIMIT 10000
192
+ `).all();
193
+
194
+ if (!profiles.results?.length) return [];
195
+
196
+ return Promise.all(
197
+ profiles.results.map(async (p) => ({
198
+ hashed_email: p.email ? await sha256(p.email) : '',
199
+ hashed_phone: p.phone ? await sha256(p.phone) : '',
200
+ first_name: p.first_name || '',
201
+ last_name: p.last_name || '',
202
+ }))
203
+ );
204
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * CDP Edge — Bidding Recommendations ML (Fase 2)
3
+ * Handlers das rotas /api/bidding/*
4
+ */
5
+
6
+ import { tryParseJson } from '../utils.js';
7
+
8
+ // ── Constantes de calibração ──────────────────────────────────────────────────
9
+ const PLATFORM_FACTORS = { meta: 0.85, google: 0.90, tiktok: 0.75 };
10
+
11
+ function getSegmentMultiplier(avgLtvClass, avgBehaviorScore) {
12
+ const ltv = parseFloat(avgLtvClass || 0);
13
+ const eng = parseFloat(avgBehaviorScore || 0);
14
+ if (ltv >= 0.7 && eng >= 0.7) return 1.4;
15
+ if (ltv >= 0.7 && eng >= 0.4) return 1.2;
16
+ if (ltv >= 0.4 && eng >= 0.7) return 1.0;
17
+ if (ltv >= 0.4 && eng >= 0.4) return 0.8;
18
+ return 0.6;
19
+ }
20
+
21
+ function getConfidenceAdjustment(confidence) {
22
+ if (confidence >= 0.7) return 1.00;
23
+ if (confidence >= 0.4) return 0.85;
24
+ return 0.70;
25
+ }
26
+
27
+ // ── POST /api/bidding/recommend ───────────────────────────────────────────────
28
+ export async function handleBiddingRecommend(env, request, headers) {
29
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
30
+
31
+ let body;
32
+ try { body = await request.json(); }
33
+ catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
34
+
35
+ const {
36
+ vertical = 'geral', platform = 'meta',
37
+ target_roi = 3.5, period_days = 30,
38
+ campaign_id = null, budget = null,
39
+ } = body;
40
+
41
+ const platforms = platform === 'all'
42
+ ? Object.keys(PLATFORM_FACTORS)
43
+ : [platform].filter(p => PLATFORM_FACTORS[p]);
44
+
45
+ if (platforms.length === 0) {
46
+ return new Response(JSON.stringify({ error: 'platform deve ser: meta, google, tiktok ou all' }), { status: 400, headers });
47
+ }
48
+ if (target_roi < 1 || target_roi > 20) {
49
+ return new Response(JSON.stringify({ error: 'target_roi deve estar entre 1 e 20' }), { status: 400, headers });
50
+ }
51
+
52
+ try {
53
+ const segmentsRes = await env.DB.prepare(`
54
+ SELECT ms.id AS segment_id, ms.cluster_name, ms.avg_ltv_class, ms.avg_behavior_score,
55
+ ms.avg_engagement_score, ms.silhouette_score,
56
+ COUNT(msm.id) AS member_count,
57
+ AVG(l.predicted_ltv) AS real_avg_ltv,
58
+ SUM(CASE WHEN l.event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
59
+ FROM ml_segments ms
60
+ LEFT JOIN ml_segment_members msm ON msm.cluster_id = ms.id
61
+ LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
62
+ AND l.created_at >= datetime('now', '-' || ? || ' days')
63
+ WHERE ms.is_active = 1 AND ms.client_vertical IN (?, 'general')
64
+ GROUP BY ms.id
65
+ HAVING member_count > 0
66
+ ORDER BY real_avg_ltv DESC
67
+ LIMIT 10
68
+ `).bind(period_days, vertical).all();
69
+
70
+ const segments = segmentsRes.results || [];
71
+
72
+ let globalLtv = 0, globalLeads = 0, globalConversions = 0;
73
+ if (segments.length === 0) {
74
+ const globalRes = await env.DB.prepare(`
75
+ SELECT COUNT(*) AS total_leads, AVG(predicted_ltv) AS avg_ltv,
76
+ SUM(CASE WHEN event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
77
+ FROM leads
78
+ WHERE created_at >= datetime('now', '-' || ? || ' days')
79
+ AND (bot_score IS NULL OR bot_score < 2)
80
+ `).bind(period_days).first();
81
+
82
+ globalLeads = globalRes?.total_leads || 0;
83
+ globalLtv = globalRes?.avg_ltv || 0;
84
+ globalConversions = globalRes?.conversions || 0;
85
+
86
+ if (globalLeads < 10) {
87
+ return new Response(JSON.stringify({
88
+ error: `Dados insuficientes. Apenas ${globalLeads} leads no período de ${period_days} dias. Mínimo: 10.`,
89
+ leads_found: globalLeads, required: 10,
90
+ }), { status: 400, headers });
91
+ }
92
+ }
93
+
94
+ const now = new Date().toISOString();
95
+ const recommendations = [];
96
+
97
+ const targetSegments = segments.length > 0
98
+ ? segments
99
+ : [{ segment_id: null, cluster_name: 'Global (sem segmentação)', avg_ltv_class: 0.5, avg_behavior_score: 0.5, avg_engagement_score: 0.5, member_count: globalLeads, real_avg_ltv: globalLtv, conversions: globalConversions }];
100
+
101
+ for (const seg of targetSegments) {
102
+ const avgLtv = parseFloat(seg.real_avg_ltv || 0);
103
+ const convs = parseInt(seg.conversions || 0);
104
+ const confidence = Math.min(1, convs / 100);
105
+
106
+ const estimatedLtv = avgLtv > 0 ? avgLtv :
107
+ seg.avg_ltv_class >= 0.7 ? 497 : seg.avg_ltv_class >= 0.4 ? 297 : 97;
108
+
109
+ const cpaTarget = estimatedLtv / target_roi;
110
+ const segMult = getSegmentMultiplier(seg.avg_ltv_class, seg.avg_behavior_score);
111
+ const confAdj = getConfidenceAdjustment(confidence);
112
+ const alertMsg = convs < 30 ? `Atenção: apenas ${convs} conversões no período. Bid baseado em estimativa de LTV — aplique com cautela.` : null;
113
+
114
+ for (const plat of platforms) {
115
+ const platFactor = PLATFORM_FACTORS[plat];
116
+ const recommendedBid = Math.max(5, cpaTarget * platFactor * segMult * confAdj);
117
+ const expectedRoi = estimatedLtv / (recommendedBid / platFactor);
118
+
119
+ const reasoning = `Segmento "${seg.cluster_name}": LTV=${estimatedLtv.toFixed(0)} BRL, ` +
120
+ `CPA_alvo=${cpaTarget.toFixed(0)} BRL, fator_plataforma=${platFactor}, ` +
121
+ `mult_segmento=${segMult}, ajuste_confiança=${confAdj}, ` +
122
+ `base: ${convs} conversões em ${period_days} dias.`;
123
+
124
+ try {
125
+ await env.DB.prepare(`
126
+ INSERT INTO bid_recommendations (
127
+ generated_at, vertical, platform, period_days, target_roi,
128
+ segment_id, segment_name, leads_analyzed, conversions_found,
129
+ avg_ltv, cpa_target, recommended_bid, bid_currency,
130
+ confidence, expected_roi, reasoning, ai_used, alert_message,
131
+ platform_factor, confidence_adjustment, segment_multiplier, is_active
132
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?,1)
133
+ `).bind(
134
+ now, vertical, plat, period_days, target_roi,
135
+ seg.segment_id || null, seg.cluster_name,
136
+ seg.member_count || globalLeads, convs,
137
+ estimatedLtv, cpaTarget, recommendedBid, 'BRL',
138
+ confidence, expectedRoi, reasoning, alertMsg || null,
139
+ platFactor, confAdj, segMult,
140
+ ).run();
141
+ } catch (e) { console.error('[Bidding] D1 insert error:', e.message); }
142
+
143
+ recommendations.push({
144
+ platform: plat, segment: seg.cluster_name, segment_id: seg.segment_id || null,
145
+ avg_ltv: Math.round(estimatedLtv * 100) / 100,
146
+ avg_ltv_class: seg.avg_ltv_class >= 0.7 ? 'High' : seg.avg_ltv_class >= 0.4 ? 'Medium' : 'Low',
147
+ cpa_target: Math.round(cpaTarget * 100) / 100,
148
+ recommended_bid: Math.round(recommendedBid * 100) / 100,
149
+ bid_currency: 'BRL', confidence: Math.round(confidence * 100) / 100,
150
+ expected_roi: Math.round(expectedRoi * 100) / 100, reasoning, alert: alertMsg,
151
+ });
152
+ }
153
+ }
154
+
155
+ await env.DB.prepare(`UPDATE bid_recommendations SET is_active = 0 WHERE vertical = ? AND generated_at < ? AND is_active = 1`).bind(vertical, now).run().catch(() => {});
156
+
157
+ const avgConfidence = recommendations.length > 0
158
+ ? recommendations.reduce((s, r) => s + r.confidence, 0) / recommendations.length
159
+ : 0;
160
+
161
+ return new Response(JSON.stringify({
162
+ success: true, generated_at: now, vertical, period_days, target_roi,
163
+ data_quality: {
164
+ leads_analyzed: targetSegments.reduce((s, sg) => s + (sg.member_count || 0), 0),
165
+ conversions_found: targetSegments.reduce((s, sg) => s + (sg.conversions || 0), 0),
166
+ segments_active: segments.length, confidence: Math.round(avgConfidence * 100) / 100,
167
+ },
168
+ recommendations,
169
+ global_summary: {
170
+ total_recommendations: recommendations.length,
171
+ avg_confidence: Math.round(avgConfidence * 100) / 100,
172
+ expected_cost_reduction: avgConfidence >= 0.7 ? '-20%' : avgConfidence >= 0.4 ? '-10%' : 'indefinido (dados insuficientes)',
173
+ segments_analyzed: segments.length,
174
+ },
175
+ }), { status: 200, headers });
176
+
177
+ } catch (err) {
178
+ console.error('[Bidding] recommend error:', err.message);
179
+ return new Response(JSON.stringify({ error: 'Erro ao gerar recomendações', message: err.message }), { status: 500, headers });
180
+ }
181
+ }
182
+
183
+ // ── GET /api/bidding/history ──────────────────────────────────────────────────
184
+ export async function handleBiddingHistory(env, request, headers) {
185
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
186
+
187
+ const url = new URL(request.url);
188
+ const vertical = url.searchParams.get('vertical') || null;
189
+ const platform = url.searchParams.get('platform') || null;
190
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
191
+
192
+ try {
193
+ const conditions = [];
194
+ const bindings = [];
195
+ if (vertical) { conditions.push('vertical = ?'); bindings.push(vertical); }
196
+ if (platform) { conditions.push('platform = ?'); bindings.push(platform); }
197
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
198
+
199
+ const result = await env.DB.prepare(`
200
+ SELECT id, generated_at, vertical, platform, period_days, target_roi,
201
+ segment_name, leads_analyzed, conversions_found, avg_ltv, cpa_target,
202
+ recommended_bid, bid_currency, confidence, expected_roi,
203
+ reasoning, alert_message, ai_used, is_active,
204
+ applied_at, applied_campaign, applied_result
205
+ FROM bid_recommendations
206
+ ${where}
207
+ ORDER BY generated_at DESC
208
+ LIMIT ?
209
+ `).bind(...bindings, limit).all();
210
+
211
+ const items = (result.results || []).map(r => ({ ...r, applied_result: tryParseJson(r.applied_result, null) }));
212
+ return new Response(JSON.stringify({ success: true, total: items.length, history: items }), { status: 200, headers });
213
+ } catch (err) {
214
+ console.error('[Bidding] history error:', err.message);
215
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
216
+ }
217
+ }
218
+
219
+ // ── GET /api/bidding/status ───────────────────────────────────────────────────
220
+ export async function handleBiddingStatus(env, request, headers) {
221
+ if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
222
+
223
+ const url = new URL(request.url);
224
+ const vertical = url.searchParams.get('vertical') || null;
225
+
226
+ try {
227
+ let query = `
228
+ SELECT platform, vertical, MAX(generated_at) as last_generated,
229
+ AVG(confidence) as avg_confidence, AVG(recommended_bid) as avg_bid,
230
+ COUNT(*) as recommendations_count,
231
+ SUM(CASE WHEN alert_message IS NOT NULL THEN 1 ELSE 0 END) as alerts_count
232
+ FROM bid_recommendations
233
+ WHERE is_active = 1
234
+ `;
235
+ const bindings = [];
236
+ if (vertical) { query += ' AND vertical = ?'; bindings.push(vertical); }
237
+ query += ' GROUP BY platform, vertical ORDER BY last_generated DESC';
238
+
239
+ const result = await env.DB.prepare(query).bind(...bindings).all();
240
+ return new Response(JSON.stringify({ success: true, status: result.results || [] }), { status: 200, headers });
241
+ } catch (err) {
242
+ console.error('[Bidding] status error:', err.message);
243
+ return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
244
+ }
245
+ }