/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: twilio-whatsapp-provision |-------------------------------------------------------------------------- | Gerencia o ciclo de vida das subcontas Twilio WhatsApp por tenant. | | Ações disponíveis (campo `action` no body): | provision — Cria subconta + provisiona número + configura webhook | deprovision — Libera número + fecha subconta | suspend — Suspende subconta (bloqueia envio mas mantém dados) | reactivate — Reativa subconta suspensa | sync_usage — Sincroniza consumo da subconta com twilio_subaccount_usage | search_numbers— Lista números disponíveis para compra | test_send — Envia mensagem de teste via subconta | | Env vars necessárias (Supabase secrets): | TWILIO_ACCOUNT_SID — Master account SID | TWILIO_AUTH_TOKEN — Master auth token | TWILIO_WHATSAPP_WEBHOOK — URL base do webhook (ex: https://xyz.supabase.co/functions/v1/twilio-whatsapp-webhook) | SUPABASE_URL | SUPABASE_SERVICE_ROLE_KEY | USD_BRL_RATE — Taxa de câmbio para calcular preço em BRL (ex: 5.20) | MARGIN_MULTIPLIER — Multiplicador de margem (ex: 1.4 = +40% sobre custo) |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', } // ── Twilio API helper ────────────────────────────────────────────────────── class TwilioClient { private masterSid: string private masterToken: string private baseUrl: string constructor(sid: string, token: string) { this.masterSid = sid this.masterToken = token this.baseUrl = `https://api.twilio.com/2010-04-01` } private authHeader(sid?: string, token?: string): string { return 'Basic ' + btoa(`${sid ?? this.masterSid}:${token ?? this.masterToken}`) } async request( method: string, path: string, params?: Record, subSid?: string, subToken?: string ): Promise> { const url = `${this.baseUrl}${path}` const auth = this.authHeader(subSid, subToken) const opts: RequestInit = { method, headers: { 'Authorization': auth, 'Content-Type': 'application/x-www-form-urlencoded', }, } if (params && method !== 'GET') { opts.body = new URLSearchParams(params).toString() } const finalUrl = (method === 'GET' && params) ? url + '?' + new URLSearchParams(params).toString() : url const res = await fetch(finalUrl, opts) const data = await res.json() if (!res.ok) { const msg = (data as { message?: string }).message || `Twilio HTTP ${res.status}` const code = (data as { code?: number }).code ?? res.status throw new TwilioError(msg, code, res.status) } return data as Record } // Cria subconta async createSubaccount(friendlyName: string) { return this.request('POST', '/Accounts.json', { FriendlyName: friendlyName }) } // Atualiza status da subconta (suspended | active | closed) async updateSubaccountStatus(subSid: string, status: string) { return this.request('POST', `/Accounts/${subSid}.json`, { Status: status }) } // Busca números disponíveis em um país async searchNumbers(country: string, areaCode?: string, smsEnabled = true) { const params: Record = { SmsEnabled: String(smsEnabled), Limit: '20', } if (areaCode) params.AreaCode = areaCode return this.request('GET', `/Accounts/${this.masterSid}/AvailablePhoneNumbers/${country}/Local.json`, params) } // Compra número para a subconta async buyNumber(subSid: string, phoneNumber: string, webhookUrl: string, subToken: string) { return this.request('POST', `/Accounts/${subSid}/IncomingPhoneNumbers.json`, { PhoneNumber: phoneNumber, SmsUrl: webhookUrl, SmsMethod: 'POST', StatusCallback: webhookUrl, }, subSid, subToken) } // Libera número da subconta async releaseNumber(subSid: string, phoneSid: string, subToken: string) { return this.request('DELETE', `/Accounts/${subSid}/IncomingPhoneNumbers/${phoneSid}.json`, undefined, subSid, subToken) } // Envia mensagem WhatsApp via subconta async sendWhatsApp(subSid: string, subToken: string, from: string, to: string, body: string) { return this.request('POST', `/Accounts/${subSid}/Messages.json`, { From: `whatsapp:${from}`, To: `whatsapp:${to}`, Body: body, }, subSid, subToken) } // Busca registros de uso da subconta async getUsageRecords(subSid: string, subToken: string, startDate: string, endDate: string) { return this.request('GET', `/Accounts/${subSid}/Usage/Records.json`, { Category: 'sms-outbound', StartDate: startDate, EndDate: endDate, }, subSid, subToken) } } class TwilioError extends Error { code: number httpStatus: number constructor(message: string, code: number, httpStatus: number) { super(message) this.name = 'TwilioError' this.code = code this.httpStatus = httpStatus } } // ── Helpers ─────────────────────────────────────────────────────────────── function ok(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } function err(message: string, status = 400, detail?: unknown) { return new Response(JSON.stringify({ error: message, detail }), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } // ── Main handler ────────────────────────────────────────────────────────── Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method !== 'POST') return err('Método não permitido', 405) // ── Auth: verifica JWT do usuário ────────────────────────────── const authHeader = req.headers.get('authorization') ?? '' const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, { global: { headers: { Authorization: authHeader } } } ) const supabaseAdmin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) // Verifica autenticação const { data: { user }, error: authErr } = await supabase.auth.getUser() if (authErr || !user) return err('Não autorizado', 401) // ── Parse body ────────────────────────────────────────────────── let body: Record try { body = await req.json() } catch { return err('JSON inválido', 400) } const action = body.action as string const tenantId = body.tenant_id as string | undefined if (!action) return err('Campo `action` obrigatório', 400) // ── Config: prioriza tabela saas_twilio_config; cai pra env como fallback ── // AUTH_TOKEN é sempre env (nunca DB) por segurança. let cfgFromDb: Record = {} try { const { data: cfg } = await supabaseAdmin.rpc('get_twilio_config') if (cfg && typeof cfg === 'object') cfgFromDb = cfg as Record } catch (_e) { /* tabela ainda não existe ou primeiro deploy → fallback puro env */ } const masterSid = (cfgFromDb.account_sid as string) || Deno.env.get('TWILIO_ACCOUNT_SID') || '' const masterToken = Deno.env.get('TWILIO_AUTH_TOKEN') || '' const webhookBase = (cfgFromDb.whatsapp_webhook_url as string) || Deno.env.get('TWILIO_WHATSAPP_WEBHOOK') || '' const usdBrlRate = Number(cfgFromDb.usd_brl_rate) || parseFloat(Deno.env.get('USD_BRL_RATE') ?? '5.5') const marginMult = Number(cfgFromDb.margin_multiplier) || parseFloat(Deno.env.get('MARGIN_MULTIPLIER') ?? '1.4') if (!masterSid) return err('TWILIO_ACCOUNT_SID não configurado. Configure em /saas/twilio-config no painel.', 500) if (!masterToken) return err('TWILIO_AUTH_TOKEN não configurado. Rode: supabase secrets set TWILIO_AUTH_TOKEN=...', 500) const twilio = new TwilioClient(masterSid, masterToken) // ── Verifica se é SaaS admin (para ações administrativas) ─────── async function isSaasAdmin(): Promise { const { data } = await supabaseAdmin .from('saas_admins') .select('id') .eq('user_id', user.id) .maybeSingle() return !!data } // ── Busca canal do tenant ──────────────────────────────────────── async function getChannel(tid: string) { const { data, error } = await supabaseAdmin .from('notification_channels') .select('*') .eq('tenant_id', tid) .eq('channel', 'whatsapp') .eq('provider', 'twilio') .is('deleted_at', null) .maybeSingle() if (error) throw error return data } // ── Busca owner_id do tenant ───────────────────────────────────── async function getOwnerId(tid: string): Promise { const { data, error } = await supabaseAdmin .from('tenant_members') .select('user_id') .eq('tenant_id', tid) .in('role', ['tenant_admin', 'admin']) .limit(1) .single() if (error) throw new Error(`Tenant não encontrado: ${error.message}`) return data.user_id } // ══════════════════════════════════════════════════════════════════ // AÇÃO: provision // ══════════════════════════════════════════════════════════════════ if (action === 'provision') { if (!tenantId) return err('tenant_id obrigatório', 400) if (!(await isSaasAdmin())) { // Tenants também podem provisionar a si mesmos const { data: member } = await supabaseAdmin .from('tenant_members') .select('tenant_id') .eq('tenant_id', tenantId) .eq('user_id', user.id) .in('role', ['tenant_admin', 'admin']) .maybeSingle() if (!member) return err('Sem permissão', 403) } const phoneNumber = body.phone_number as string | undefined const country = (body.country as string) ?? 'BR' const displayName = body.display_name as string | undefined const costPerMsgUsd = parseFloat((body.cost_per_message_usd as string) ?? '0.005') const pricePerMsgBrl = parseFloat((body.price_per_message_brl as string) ?? String(costPerMsgUsd * usdBrlRate * marginMult)) try { // Verifica se já existe canal twilio para este tenant const existing = await getChannel(tenantId) if (existing?.twilio_subaccount_sid) { return err('Tenant já possui subconta Twilio provisionada', 409) } const ownerId = await getOwnerId(tenantId) // 1. Cria subconta Twilio const subaccountData = await twilio.createSubaccount( `AgenciaPsi-${tenantId.slice(0, 8)}` ) const subSid = subaccountData.sid as string const subToken = subaccountData.auth_token as string let phoneSid = '' let finalNumber = phoneNumber ?? '' // 2. Compra número (se informado ou busca disponível) if (!finalNumber) { // Busca primeiro número disponível no país const available = await twilio.searchNumbers(country) const numbers = (available.available_phone_numbers as Array<{ phone_number: string }>) ?? [] if (!numbers.length) throw new Error(`Nenhum número disponível em ${country}`) finalNumber = numbers[0].phone_number } const webhookUrl = `${webhookBase}?tenant_id=${tenantId}` // 3. Compra número na subconta const numData = await twilio.buyNumber(subSid, finalNumber, webhookUrl, subToken) phoneSid = numData.sid as string // 4. Salva no banco const channelData = { tenant_id: tenantId, owner_id: ownerId, channel: 'whatsapp', provider: 'twilio', is_active: true, display_name: displayName ?? `WhatsApp Twilio — ${tenantId.slice(0, 8)}`, sender_address: finalNumber, connection_status: 'connected', credentials: { subaccount_auth_token: subToken }, twilio_subaccount_sid: subSid, twilio_phone_number: finalNumber, twilio_phone_sid: phoneSid, webhook_url: webhookUrl, cost_per_message_usd: costPerMsgUsd, price_per_message_brl: pricePerMsgBrl, provisioned_at: new Date().toISOString(), } let savedChannel if (existing?.id) { const { data, error } = await supabaseAdmin .from('notification_channels') .update(channelData) .eq('id', existing.id) .select('*') .single() if (error) throw error savedChannel = data } else { const { data, error } = await supabaseAdmin .from('notification_channels') .insert(channelData) .select('*') .single() if (error) throw error savedChannel = data } return ok({ success: true, channel: savedChannel, message: `Subconta Twilio provisionada com número ${finalNumber}`, }) } catch (e) { console.error('[provision] erro:', e) return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500) } } // ══════════════════════════════════════════════════════════════════ // AÇÃO: deprovision // ══════════════════════════════════════════════════════════════════ if (action === 'deprovision') { if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem deprovisionar', 403) const channelId = body.channel_id as string if (!channelId) return err('channel_id obrigatório', 400) try { const { data: ch, error } = await supabaseAdmin .from('notification_channels') .select('*') .eq('id', channelId) .single() if (error || !ch) return err('Canal não encontrado', 404) if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400) const subToken = ch.credentials?.subaccount_auth_token as string // 1. Libera número if (ch.twilio_phone_sid && subToken) { try { await twilio.releaseNumber(ch.twilio_subaccount_sid, ch.twilio_phone_sid, subToken) } catch (e) { console.warn('[deprovision] Erro ao liberar número (ignorado):', e.message) } } // 2. Fecha subconta await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, 'closed') // 3. Soft-delete do canal const { error: delErr } = await supabaseAdmin .from('notification_channels') .update({ deleted_at: new Date().toISOString(), is_active: false, connection_status: 'disconnected', }) .eq('id', channelId) if (delErr) throw delErr return ok({ success: true, message: 'Subconta encerrada e canal removido' }) } catch (e) { console.error('[deprovision] erro:', e) return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500) } } // ══════════════════════════════════════════════════════════════════ // AÇÃO: suspend / reactivate // ══════════════════════════════════════════════════════════════════ if (action === 'suspend' || action === 'reactivate') { if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem suspender/reativar', 403) const channelId = body.channel_id as string if (!channelId) return err('channel_id obrigatório', 400) try { const { data: ch, error } = await supabaseAdmin .from('notification_channels') .select('*') .eq('id', channelId) .single() if (error || !ch) return err('Canal não encontrado', 404) if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400) const twilioStatus = action === 'suspend' ? 'suspended' : 'active' await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, twilioStatus) await supabaseAdmin .from('notification_channels') .update({ is_active: action === 'reactivate', connection_status: action === 'reactivate' ? 'connected' : 'disconnected', }) .eq('id', channelId) return ok({ success: true, message: action === 'suspend' ? 'Subconta suspensa' : 'Subconta reativada', }) } catch (e) { console.error(`[${action}] erro:`, e) return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500) } } // ══════════════════════════════════════════════════════════════════ // AÇÃO: sync_usage // ══════════════════════════════════════════════════════════════════ if (action === 'sync_usage') { if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem sincronizar uso', 403) const channelId = body.channel_id as string const now = new Date() const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0] const endDate = now.toISOString().split('T')[0] try { // Busca todos os canais twilio (ou somente o especificado) const query = supabaseAdmin .from('notification_channels') .select('id, tenant_id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl') .eq('channel', 'whatsapp') .eq('provider', 'twilio') .is('deleted_at', null) .not('twilio_subaccount_sid', 'is', null) if (channelId) query.eq('id', channelId) const { data: channels, error } = await query if (error) throw error const synced = [] for (const ch of channels ?? []) { try { const subToken = ch.credentials?.subaccount_auth_token as string if (!subToken) continue // Busca mensagens enviadas no mês via notification_logs const { data: logs } = await supabaseAdmin .from('notification_logs') .select('status, estimated_cost_brl') .eq('tenant_id', ch.tenant_id) .eq('channel', 'whatsapp') .eq('provider', 'twilio') .gte('created_at', startDate) .lte('created_at', endDate + 'T23:59:59Z') const sent = logs?.length ?? 0 const delivered = logs?.filter(l => l.status === 'delivered' || l.status === 'read').length ?? 0 const failed = logs?.filter(l => l.status === 'failed').length ?? 0 const costBrl = logs?.reduce((sum, l) => sum + parseFloat(String(l.estimated_cost_brl ?? 0)), 0) ?? 0 const costUsd = costBrl / usdBrlRate const revBrl = sent * (ch.price_per_message_brl ?? 0) // Upsert no twilio_subaccount_usage const { error: upsertErr } = await supabaseAdmin .from('twilio_subaccount_usage') .upsert({ tenant_id: ch.tenant_id, channel_id: ch.id, twilio_subaccount_sid: ch.twilio_subaccount_sid, period_start: startDate, period_end: endDate, messages_sent: sent, messages_delivered: delivered, messages_failed: failed, cost_usd: costUsd, cost_brl: costBrl, revenue_brl: revBrl, usd_brl_rate: usdBrlRate, synced_at: new Date().toISOString(), }, { onConflict: 'channel_id,period_start,period_end', }) if (upsertErr) throw upsertErr synced.push({ channel_id: ch.id, sent, delivered, failed }) } catch (e) { console.warn(`[sync_usage] canal ${ch.id}:`, e.message) } } return ok({ success: true, synced }) } catch (e) { console.error('[sync_usage] erro:', e) return err(e.message, 500) } } // ══════════════════════════════════════════════════════════════════ // AÇÃO: search_numbers // ══════════════════════════════════════════════════════════════════ if (action === 'search_numbers') { if (!(await isSaasAdmin())) return err('Apenas SaaS admins podem buscar números', 403) const country = (body.country as string) ?? 'US' const areaCode = body.area_code as string | undefined try { const result = await twilio.searchNumbers(country, areaCode) const numbers = (result.available_phone_numbers as Array<{ phone_number: string friendly_name: string locality: string region: string capabilities: Record }>) ?? [] return ok({ numbers: numbers.map(n => ({ phone_number: n.phone_number, friendly_name: n.friendly_name, locality: n.locality, region: n.region, sms: n.capabilities?.sms ?? false, mms: n.capabilities?.mms ?? false, voice: n.capabilities?.voice ?? false, })) }) } catch (e) { return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500) } } // ══════════════════════════════════════════════════════════════════ // AÇÃO: test_send // ══════════════════════════════════════════════════════════════════ if (action === 'test_send') { const channelId = body.channel_id as string const toNumber = body.to as string const message = (body.message as string) ?? 'Mensagem de teste — AgenciaPsi' if (!channelId || !toNumber) return err('channel_id e to são obrigatórios', 400) // Tenant pode testar o seu próprio canal; admin pode testar qualquer um try { const { data: ch, error } = await supabaseAdmin .from('notification_channels') .select('*') .eq('id', channelId) .single() if (error || !ch) return err('Canal não encontrado', 404) // Verifica permissão: próprio tenant ou admin const admin = await isSaasAdmin() if (!admin) { const { data: member } = await supabaseAdmin .from('tenant_members') .select('tenant_id') .eq('tenant_id', ch.tenant_id) .eq('user_id', user.id) .maybeSingle() if (!member) return err('Sem permissão', 403) } const subToken = ch.credentials?.subaccount_auth_token as string if (!ch.twilio_subaccount_sid || !subToken) return err('Canal não provisionado', 400) const result = await twilio.sendWhatsApp( ch.twilio_subaccount_sid, subToken, ch.twilio_phone_number, toNumber, message ) return ok({ success: true, message_sid: result.sid, status: result.status, }) } catch (e) { return err(e instanceof TwilioError ? `Twilio: ${e.message}` : e.message, 500) } } return err(`Ação desconhecida: ${action}`, 400) })