7c20b518d4
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
633 lines
25 KiB
TypeScript
633 lines
25 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 } 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<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 = 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<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 ────────────────────────────────────────
|
|
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<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)
|
|
|
|
// 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<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 { 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)
|
|
})
|