/** * CDP Edge — Privacidade (LGPD) e integridade de conversões. * * - Consentimento: o SDK envia `consent: { ads, analytics }`. Recusa explícita * ('denied') sempre bloqueia o envio às plataformas de anúncio/analytics. * Sem sinal ('pending'/ausente) o comportamento é o legado, a menos que * REQUIRE_CONSENT=1 (modo estrito: só envia com 'granted'). * - Purchase pelo navegador: com BROWSER_PURCHASE=auto (padrão) é bloqueado * quando há webhook de checkout configurado — a compra vem do webhook, que é * autenticado. 'on' sempre permite; 'off' sempre bloqueia. * - Direitos do titular: deleteSubjectData() remove os dados de um e-mail/telefone. * - Retenção: purgeExpiredPii() anonimiza dados pessoais mais antigos que * DATA_RETENTION_DAYS (desligado quando vazio; mínimo 30 dias). */ import { Env } from '../types.js'; import { sha256, normalizePhone } from './utils.js'; export type ConsentPurpose = 'ads' | 'analytics'; export type ConsentValue = 'granted' | 'denied' | 'pending' | 'unknown'; export interface ConsentSignal { ads?: ConsentValue; analytics?: ConsentValue } export const MIN_RETENTION_DAYS = 30; export function consentAllows(env: Env, consent: { ads?: string; analytics?: string } | null | undefined, purpose: ConsentPurpose): boolean { const value = consent?.[purpose]; if (value === 'granted') return true; if (value === 'denied') return false; return env.REQUIRE_CONSENT !== '1'; } export function isBrowserPurchaseBlocked(env: Env): boolean { const mode = (env.BROWSER_PURCHASE || 'auto').toLowerCase(); if (mode === 'on') return false; if (mode === 'off') return true; return Boolean(env.WEBHOOK_SECRET_HOTMART || env.WEBHOOK_SECRET_KIWIFY || env.WEBHOOK_SECRET_TICTO); } export function retentionDays(env: Env): number | null { const days = parseInt(env.DATA_RETENTION_DAYS || '', 10); return Number.isFinite(days) && days >= MIN_RETENTION_DAYS ? days : null; } // Executa e devolve linhas afetadas; tabela ausente (instalação antiga) conta como 0. async function run(env: Env, sql: string, ...binds: unknown[]): Promise { try { const res = await env.DB!.prepare(sql).bind(...binds).run(); return res.meta?.changes ?? 0; } catch (err: any) { if (/no such table/i.test(err?.message || '')) return 0; throw err; } } async function all(env: Env, sql: string, ...binds: unknown[]): Promise { try { return ((await env.DB!.prepare(sql).bind(...binds).all()).results || []) as T[]; } catch (err: any) { if (/no such table/i.test(err?.message || '')) return []; throw err; } } /** Remove os dados pessoais de um titular (art. 18 LGPD). Retorna linhas afetadas por tabela. */ export async function deleteSubjectData( env: Env, subject: { email?: string | null; phone?: string | null }, ): Promise> { if (!env.DB) throw new Error('D1 não configurado'); const email = subject.email ? String(subject.email).toLowerCase().trim() : null; const phone = normalizePhone(subject.phone) ?? null; if (!email && !phone) throw new Error('informe email ou phone'); // Identificadores ligados ao titular (perfil e leads) const idRows = await all<{ user_id: string }>(env, ` SELECT user_id FROM user_profiles WHERE (?1 IS NOT NULL AND lower(email) = ?1) OR (?2 IS NOT NULL AND phone = ?2) UNION SELECT user_id FROM leads WHERE user_id IS NOT NULL AND ((?1 IS NOT NULL AND lower(email) = ?1) OR (?2 IS NOT NULL AND phone = ?2)) `, email, phone); const userIds = idRows.map(r => r.user_id).filter(Boolean); const leadIds = (await all<{ id: number }>(env, ` SELECT id FROM leads WHERE (?1 IS NOT NULL AND lower(email) = ?1) OR (?2 IS NOT NULL AND phone = ?2) `, email, phone)).map(r => r.id); const inList = (n: number) => Array.from({ length: n }, () => '?').join(',') || 'NULL'; const out: Record = {}; if (leadIds.length) { out.messaging_history = await run(env, `DELETE FROM messaging_history WHERE lead_id IN (${inList(leadIds.length)})`, ...leadIds); } if (userIds.length) { const ph = inList(userIds.length); out.events = await run(env, `DELETE FROM events WHERE user_id IN (${ph})`, ...userIds); out.edge_fingerprints = await run(env, `DELETE FROM edge_fingerprints WHERE user_id IN (${ph})`, ...userIds); out.fraud_signals = await run(env, `DELETE FROM fraud_signals WHERE user_id IN (${ph})`, ...userIds); } // ?1 = email, ?2 = phone, ?3.. = user_ids (NULL quando não há perfil ligado) const ids = userIds.length ? userIds : [null]; const where = `(?1 IS NOT NULL AND lower(email) = ?1) OR (?2 IS NOT NULL AND phone = ?2) OR user_id IN (${ids.map((_, i) => `?${i + 3}`).join(',')})`; out.nurture_sequences = await run(env, `DELETE FROM nurture_sequences WHERE ${where}`, email, phone, ...ids); out.leads = await run(env, `DELETE FROM leads WHERE ${where}`, email, phone, ...ids); out.user_profiles = await run(env, `DELETE FROM user_profiles WHERE ${where}`, email, phone, ...ids); if (email) out.webhook_events = await run(env, `DELETE FROM webhook_events WHERE lower(email) = ?1`, email); // api_failures guarda o payload enviado às plataformas (com hashes): apaga o payload que contém o hash do titular const hashes = [email && await sha256(email), phone && await sha256(phone)].filter(Boolean) as string[]; out.api_failures = 0; for (const h of hashes) { out.api_failures += await run(env, `UPDATE api_failures SET raw_payload = NULL WHERE raw_payload LIKE ?1`, `%${h}%`); } return out; } /** Anonimiza dados pessoais anteriores ao prazo de retenção. Não apaga métricas agregáveis. */ export async function purgeExpiredPii(env: Env, days: number): Promise> { if (!env.DB) throw new Error('D1 não configurado'); if (!Number.isFinite(days) || days < MIN_RETENTION_DAYS) throw new Error(`prazo mínimo de ${MIN_RETENTION_DAYS} dias`); const cutoff = `-${Math.floor(days)} days`; return { leads: await run(env, ` UPDATE leads SET email = NULL, phone = NULL, first_name = NULL, last_name = NULL, ip_address = NULL, raw_payload = NULL WHERE created_at < datetime('now', ?1) AND (email IS NOT NULL OR phone IS NOT NULL OR first_name IS NOT NULL OR last_name IS NOT NULL OR ip_address IS NOT NULL OR raw_payload IS NOT NULL) `, cutoff), user_profiles: await run(env, ` UPDATE user_profiles SET email = NULL, phone = NULL WHERE updated_at < datetime('now', ?1) AND (email IS NOT NULL OR phone IS NOT NULL) `, cutoff), webhook_events: await run(env, ` UPDATE webhook_events SET email = NULL, raw_payload = NULL WHERE created_at < datetime('now', ?1) AND (email IS NOT NULL OR raw_payload IS NOT NULL) `, cutoff), api_failures: await run(env, `UPDATE api_failures SET raw_payload = NULL WHERE created_at < datetime('now', ?1) AND raw_payload IS NOT NULL`, cutoff), nurture_sequences: await run(env, `DELETE FROM nurture_sequences WHERE created_at < datetime('now', ?1)`, cutoff), fraud_signals: await run(env, `DELETE FROM fraud_signals WHERE detected_at < datetime('now', ?1)`, cutoff), edge_fingerprints: await run(env, `DELETE FROM edge_fingerprints WHERE created_at < datetime('now', ?1)`, cutoff), }; }