Files
agenciapsilmno/supabase/functions/twilio-whatsapp-provision/index.ts
T
Leonardo 9b21642e15 F4 schema-per-tenant: edge functions roteiam pro schema do tenant
- _shared/tenant.ts: helper (adminClient, tenantDbForId, schemaForTenant,
  listTenantSchemas, resolveTenantByChannel, tenantSchemaName)
- _shared/whatsapp-hooks.ts: hooks de tabela tenant recebem tdb; RPCs de
  credito (deduct/add_whatsapp_credits) e tenant_members seguem em supa+p_tenant_id
- inbound (twilio/evolution): tenant_id da URL -> tdb pra conversation_messages
  e notification_channels
- crons de fila (process-notification/email/sms/whatsapp-queue): varrem
  listTenantSchemas e drenam a fila de cada schema (Q3: filas sao per-tenant);
  modo single-tenant se body.tenant_id vier
- crons reminders/checks (send-session-reminders, conversation-sla-check,
  whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates):
  loop por tenant
- routing por tenant_id (send-whatsapp-message, send-session-reminder-manual,
  twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId;
  channel-actions sem tenant_id varrem schemas por channel_id
- asaas-*: tenant_id do body -> tdb; asaas-webhook fica global (whatsapp_credit_purchases)
- notification-webhook (Meta): resolve tenant via channel_routing por phone_number_id,
  fan-out por message_id quando nao resolve
- caller send-session-reminder-manual passa tenant_id (evento vive no schema)

Pendente: save-intake-progress e fluxos anon por token (decisao de roteamento)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 08:44:09 -03:00

660 lines
27 KiB
TypeScript

/*
|--------------------------------------------------------------------------
| 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, type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId, listTenantSchemas, type TenantRef } from '../_shared/tenant.ts'
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<string, string>,
subSid?: string,
subToken?: string
): Promise<Record<string, unknown>> {
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<string, unknown>
}
// 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<string, string> = {
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 = adminClient()
// ── schema-per-tenant: resolve um canal por id varrendo os schemas tenant ──
// As ações deprovision/suspend/reactivate/test_send recebem só channel_id e
// notification_channels vive no schema do tenant (sem coluna tenant_id). Sem o
// tenant_id no payload, varremos os schemas provisionados pra localizar o canal.
// Retorna { tdb, channel, ref } ou null se não achar em nenhum schema.
async function findChannelById(channelId: string): Promise<
{ tdb: SupabaseClient; channel: Record<string, unknown>; ref: TenantRef } | null
> {
const refs = await listTenantSchemas(supabaseAdmin)
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
const { data, error } = await tdb
.from('notification_channels')
.select('*')
.eq('id', channelId)
.maybeSingle()
if (error) {
console.warn(`[provision] busca canal em ${ref.schema}:`, error.message)
continue
}
if (data) return { tdb, channel: data as Record<string, unknown>, ref }
}
return null
}
// 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<string, unknown>
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<string, unknown> = {}
try {
const { data: cfg } = await supabaseAdmin.rpc('get_twilio_config')
if (cfg && typeof cfg === 'object') cfgFromDb = cfg as Record<string, unknown>
} 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<boolean> {
const { data } = await supabaseAdmin
.from('saas_admins')
.select('id')
.eq('user_id', user.id)
.maybeSingle()
return !!data
}
// ── Busca canal do tenant (tabela tenant → schema do tenant) ─────
async function getChannel(tid: string) {
const tdb = await tenantDbForId(supabaseAdmin, tid)
const { data, error } = await tdb
.from('notification_channels')
.select('*')
.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<string> {
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)
const tdb = await tenantDbForId(supabaseAdmin, 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 (notification_channels é tabela tenant — sem tenant_id)
const channelData = {
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 tdb
.from('notification_channels')
.update(channelData)
.eq('id', existing.id)
.select('*')
.single()
if (error) throw error
savedChannel = data
} else {
const { data, error } = await tdb
.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 found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { tdb, channel: ch } = found
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
// 1. Libera número
if (ch.twilio_phone_sid && subToken) {
try {
await twilio.releaseNumber(ch.twilio_subaccount_sid as string, ch.twilio_phone_sid as string, subToken)
} catch (e) {
console.warn('[deprovision] Erro ao liberar número (ignorado):', (e as Error).message)
}
}
// 2. Fecha subconta
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid as string, 'closed')
// 3. Soft-delete do canal (tabela tenant)
const { error: delErr } = await tdb
.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 found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { tdb, channel: ch } = found
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 as string, twilioStatus)
await tdb
.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 {
// schema-per-tenant: notification_channels/logs e twilio_subaccount_usage
// vivem no schema de cada tenant. Varremos todos os schemas provisionados
// (ou paramos no que contém channelId, se especificado).
const refs = await listTenantSchemas(supabaseAdmin)
const synced = []
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
// Canais twilio do schema (ou somente o especificado)
let chQuery = tdb
.from('notification_channels')
.select('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) chQuery = chQuery.eq('id', channelId)
const { data: channels, error } = await chQuery
if (error) {
console.warn(`[sync_usage] canais em ${ref.schema}:`, error.message)
continue
}
for (const ch of channels ?? []) {
try {
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
if (!subToken) continue
// Busca mensagens enviadas no mês via notification_logs (tabela tenant)
const { data: logs } = await tdb
.from('notification_logs')
.select('status, estimated_cost_brl')
.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 (tabela tenant)
const { error: upsertErr } = await tdb
.from('twilio_subaccount_usage')
.upsert({
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 as Error).message)
}
}
// Se filtramos por channelId e já achamos, não precisa varrer o resto
if (channelId && synced.length > 0) break
}
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<string, boolean>
}>) ?? []
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 found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { channel: ch, ref } = found
// Verifica permissão: próprio tenant (via ref.tenantId) ou admin
const admin = await isSaasAdmin()
if (!admin) {
const { data: member } = await supabaseAdmin
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', ref.tenantId)
.eq('user_id', user.id)
.maybeSingle()
if (!member) return err('Sem permissão', 403)
}
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.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 as string,
subToken,
ch.twilio_phone_number as string,
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)
})