/** * CDP Edge — Utilities * Funções puras sem dependências externas. * Importadas por todos os outros módulos. */ // ── Tipos ─────────────────────────────────────────────────────────────────────── export interface FunnelStageResult { depth: string; funnelDepth: string; } export interface MetaSignalWeights { intent: number; ltv: number; dist: number; } export type DistanceBucket = 'very_close' | 'close' | 'nearby' | 'moderate' | 'far'; export type FunnelLevel = 'top' | 'mid' | 'bottom' | 'conversion' | 'unknown'; export type MetaSignalBucket = 'hot' | 'warm' | 'cold'; // ── CORS ────────────────────────────────────────────────────────────────────── export function isAllowedOrigin(origin: string | null, siteDomain: string | null): boolean { if (!origin || !siteDomain) return false; return origin === `https://${siteDomain}` || origin.endsWith(`.${siteDomain}`) || origin === 'http://localhost:3000' || origin === 'http://localhost:5173'; } export function corsHeaders(origin: string | null, siteDomain: string | null): Record { const allowed = isAllowedOrigin(origin, siteDomain) ? origin : (siteDomain ? `https://${siteDomain}` : '*'); return { 'Access-Control-Allow-Origin': allowed || '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With, Authorization', 'Access-Control-Max-Age': '86400', }; } // ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ───────────────── export async function sha256(value: string | null | undefined): Promise { if (!value) return undefined; const clean = String(value).toLowerCase().trim(); if (!clean) return undefined; const buf = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(clean) ); return Array.from(new Uint8Array(buf)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } // ── Normalização de telefone → somente dígitos + DDI 55 ────────────────────── export function normalizePhone(phone: string | null | undefined): string | undefined { if (!phone) return undefined; let digits = String(phone).replace(/\D/g, ''); if (digits.length === 11 && !digits.startsWith('55')) digits = '55' + digits; if (digits.length === 10 && !digits.startsWith('55')) digits = '55' + digits; return digits.length >= 10 ? digits : undefined; } // ── Normalização de cidade → lowercase sem acentos ──────────────────────────── export function normalizeCity(city: string | null | undefined): string | undefined { if (!city) return undefined; return String(city) .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]/g, ''); } // ── Parse seguro de JSON armazenado como TEXT no D1 ─────────────────────────── export function tryParseJson(str: string | null, fallback?: T): T | null { if (!str) return fallback !== undefined ? fallback : null; try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; } } // ── Mapa Meta → GA4 event names ─────────────────────────────────────────────── export const META_TO_GA4: Record = { PageView: 'page_view', ViewContent: 'view_item', Lead: 'generate_lead', Contact: 'generate_lead', Schedule: 'generate_lead', InitiateCheckout: 'begin_checkout', AddToCart: 'add_to_cart', AddPaymentInfo: 'add_payment_info', Purchase: 'purchase', CompleteRegistration: 'sign_up', Subscribe: 'subscribe', StartTrial: 'start_trial', Search: 'search', AddToWishlist: 'add_to_wishlist', }; // ── Lista canônica de eventos válidos (19 eventos) ──────────────────────────── export const VALID_EVENT_NAMES = new Set([ 'PageView','ViewContent','Lead','Purchase','InitiateCheckout', 'AddToCart','CompleteRegistration','Contact','Schedule', 'StartTrial','Subscribe','SubmitApplication','Search', 'video_start','video_25','video_50','video_75','video_complete', // Imóveis — intenção de visita física, financiamento e favoritar 'FindLocation','CustomizeProduct','AddToWishlist', // Quiz Funnel (Fase 6) 'QuizStart','QuizAnswer','QuizComplete', ]); // ── Taxonomia de funil (funnel_stage → profundidade semântica) ──────────────── // Fonte de verdade para interpretar funnel_stage em qualquer ponto do sistema. export const FUNNEL_TAXONOMY = { top: ['scroll_50', 'time_30s', 'page_view', 'gallery_view', 'AddToWishlist'], mid: ['map_view', 'gallery_click', 'price_hover', 'time_3min', 'FindLocation'], bottom: ['route_click', 'whatsapp_click', 'cta_hover', 'CustomizeProduct'], conversion: ['schedule_confirmed', 'lead_form', 'purchase', 'visit_booked'], }; // Índice invertido: funnel_stage → depth (construído uma vez, zero custo em runtime) const _STAGE_TO_DEPTH: Record = Object.entries(FUNNEL_TAXONOMY).reduce((acc, [depth, stages]) => { stages.forEach(s => { acc[s] = depth as FunnelLevel; }); return acc; }, {} as Record); /** * Resolve funnel_stage em funnelDepth semântico. * bottom_intent = intenção forte (route_click, whatsapp_click) * bottom_conversion = ação confirmada (schedule_confirmed, lead_form) */ export function resolveFunnelStage(funnel_stage: string | null | undefined): FunnelStageResult { const depth = _STAGE_TO_DEPTH[funnel_stage || ''] || 'unknown'; const funnelDepth = depth === 'conversion' ? 'bottom_conversion' : depth === 'bottom' ? 'bottom_intent' : depth; return { depth, funnelDepth }; } // ── Normalização de intent_score → 0.0–1.0 ─────────────────────────────────── // Aceita: string ('high'/'medium'/'low'), numérico 0-1 ou numérico 0-100 const _INTENT_STRING_MAP: Record = { high: 0.92, medium: 0.65, low: 0.30 }; export function resolveIntentScore(value: string | number | null | undefined): number | null { if (value === null || value === undefined) return null; if (typeof value === 'string') return _INTENT_STRING_MAP[value.toLowerCase()] ?? null; const num = parseFloat(String(value)); if (isNaN(num)) return null; const normalized = num > 1 ? num / 100 : num; // escala 0-100 → 0-1 return Math.min(1, Math.max(0, Math.round(normalized * 100) / 100)); } /** * Distância (distanceBucket) → peso numérico para meta_signal. * very_close=1.0 ... far=0.1 ... sem dado=0.3 (neutro) */ export function distanceBucketWeight(bucket: string | null | undefined): number { const map: Record = { very_close: 1.0, close: 0.75, nearby: 0.5, moderate: 0.25, far: 0.1 }; return map[bucket as DistanceBucket] ?? 0.3; } /** * Pesos dinâmicos do meta_signal por profundidade de funil. * Fundo: comportamento pesa mais (intent + dist). * Topo: perfil pesa mais (ltv). * Default (mid/unknown): balanceado. */ export function computeMetaSignalWeights(funnelLevel: FunnelLevel | string | null | undefined): MetaSignalWeights { if (funnelLevel === 'bottom' || funnelLevel === 'conversion') { return { intent: 0.5, ltv: 0.2, dist: 0.3 }; } if (funnelLevel === 'top') { return { intent: 0.2, ltv: 0.6, dist: 0.2 }; } return { intent: 0.4, ltv: 0.4, dist: 0.2 }; } /** * Quantiza meta_signal contínuo em bucket legível. * Usado em criação de públicos e leitura de BI. */ export function metaSignalBucket(score: number | null | undefined): MetaSignalBucket { if (!score) return 'cold'; if (score >= 0.8) return 'hot'; if (score >= 0.6) return 'warm'; return 'cold'; } // ── Input Validation & Sanitization — Segurança contra XSS/Injection ──────── /** * Valida formato de email (basic RFC-compliant) */ export function isValidEmail(email: string | null | undefined): boolean { if (!email || typeof email !== 'string') return false; const trimmed = email.trim(); if (trimmed.length > 256) return false; // Limite razoável const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; return emailRegex.test(trimmed); } /** * Sanitiza string contra XSS/HTML injection * Remove tags HTML, scripts, e caracteres perigosos */ export function sanitizeString(input: string | null | undefined, maxLength: number = 512): string | null { if (!input || typeof input !== 'string') return null; let sanitized = String(input).trim(); // Remove HTML tags sanitized = sanitized.replace(/<[^>]*>/g, ''); // Remove JavaScript event handlers sanitized = sanitized.replace(/on\w+\s*=/gi, ''); // Remove javascript: protocol sanitized = sanitized.replace(/javascript:/gi, ''); // Remove caracteres perigosos sanitized = sanitized.replace(/[<>\"'`]/g, ''); // Remove caracteres Unicode perigosos sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, ''); // Limita comprimento if (sanitized.length > maxLength) { sanitized = sanitized.substring(0, maxLength); } return sanitized.length > 0 ? sanitized : null; } /** * Valida e sanitiza URL (para pageUrl) */ export function isValidUrl(url: string | null | undefined): boolean { if (!url || typeof url !== 'string') return false; const trimmed = url.trim(); if (trimmed.length > 2048) return false; // Limite razoável try { const parsed = new URL(trimmed); return ['http:', 'https:'].includes(parsed.protocol); } catch { return false; } } /** * Valida formato de CPF (11 dígitos) */ export function isValidCPF(cpf: string | null | undefined): boolean { if (!cpf || typeof cpf !== 'string') return false; const cleaned = cpf.replace(/\D/g, ''); return cleaned.length === 11 && /^\d+$/.test(cleaned); } /** * Valida formato de CNPJ (14 dígitos) */ export function isValidCNPJ(cnpj: string | null | undefined): boolean { if (!cnpj || typeof cnpj !== 'string') return false; const cleaned = cnpj.replace(/\D/g, ''); return cleaned.length === 14 && /^\d+$/.test(cleaned); } /** * Valida formato de valor numérico (para value em Purchase) */ export function isValidValue(value: number | null | undefined): boolean { if (value === null || value === undefined) return true; // Valor opcional const num = Number(value); return !isNaN(num) && num >= 0 && num <= 9_999_999; } /** * Valida moeda (currency field) */ export function isValidCurrency(currency: string | null | undefined): boolean { if (!currency || typeof currency !== 'string') return true; // Opcional const trimmed = currency.trim().toUpperCase(); const validCurrencies = ['BRL', 'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY', 'CHF']; return trimmed.length === 3 && validCurrencies.includes(trimmed); } /** * Sanitiza array de strings (para contentIds, etc.) */ export function sanitizeStringArray(input: string[] | null | undefined, maxLength: number = 512): string[] | null { if (!input || !Array.isArray(input)) return null; const sanitized = input .map(item => sanitizeString(item, maxLength)) .filter(item => item !== null) as string[]; return sanitized.length > 0 ? sanitized : null; } /** * Valida UTM parameters (utmSource, utmMedium, utmCampaign, utmContent, utmTerm) */ export function isValidUTM(param: string | null | undefined, paramType: string): boolean { if (!param || typeof param !== 'string') return true; // Opcional const trimmed = param.trim(); const maxLength = paramType === 'utm_source' ? 100 : 200; if (trimmed.length > maxLength) return false; // Verifica caracteres perigosos const dangerousPatterns = ['