Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes

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>
This commit is contained in:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
@@ -0,0 +1,298 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: notification-webhook
|--------------------------------------------------------------------------
| Recebe webhooks de status dos provedores (Evolution API, Meta, etc.)
| e atualiza notification_logs + processa opt-out (SAIR).
|
| Runtime: Deno (Supabase Edge Functions)
| Linguagem: JavaScript puro
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
const EVOLUTION_API_KEY = Deno.env.get('EVOLUTION_API_KEY') || ''
const META_VERIFY_TOKEN = Deno.env.get('META_VERIFY_TOKEN') || ''
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
Deno.serve(async (req) => {
const url = new URL(req.url)
const provider = url.searchParams.get('provider') || 'unknown'
// Meta webhook verification (GET com challenge)
if (req.method === 'GET' && provider === 'meta') {
return handleMetaVerification(url)
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405)
}
try {
const body = await req.json()
switch (provider) {
case 'evolution':
return await handleEvolutionWebhook(req, body)
case 'meta':
return await handleMetaWebhook(body)
default:
return jsonResponse({ error: `Provider "${provider}" não suportado` }, 400)
}
} catch (err) {
console.error(`[webhook] Erro:`, err.message)
return jsonResponse({ error: err.message }, 500)
}
})
// ── Evolution API Webhook ───────────────────────────────────
/**
* Processa webhooks da Evolution API.
* Eventos relevantes:
* - messages.update: status de entrega (enviado, entregue, lido)
* - messages.upsert: mensagem recebida (para detectar "SAIR")
*/
async function handleEvolutionWebhook (req, body) {
// Validação básica da API key
const apiKey = req.headers.get('apikey') || ''
if (EVOLUTION_API_KEY && apiKey !== EVOLUTION_API_KEY) {
return jsonResponse({ error: 'Invalid API key' }, 401)
}
const event = body.event
const instance = body.instance
console.log(`[evolution] Evento: ${event}, Instância: ${instance}`)
// ── Status de mensagem enviada ────
if (event === 'messages.update') {
const key = body.data?.key
const messageId = key?.id
const status = body.data?.update?.status
if (!messageId) {
return jsonResponse({ ok: true, skipped: 'no messageId' })
}
// Mapeia status numérico da Evolution
// 1 = pendente, 2 = enviado ao servidor, 3 = entregue, 4 = lido, 5 = erro
const statusMap = {
1: null, // pendente — não atualiza
2: 'sent', // enviado ao servidor WhatsApp
3: 'delivered', // entregue ao dispositivo
4: 'read', // lido
5: 'failed', // erro
}
const mappedStatus = statusMap[status]
if (!mappedStatus) {
return jsonResponse({ ok: true, skipped: `status ${status} ignorado` })
}
await updateLogStatus(messageId, mappedStatus)
return jsonResponse({ ok: true, status: mappedStatus })
}
// ── Mensagem recebida (para opt-out) ────
if (event === 'messages.upsert') {
const message = body.data?.message
const text = (
message?.conversation ||
message?.extendedTextMessage?.text ||
''
).trim().toUpperCase()
const remoteJid = body.data?.key?.remoteJid || ''
const phone = remoteJid.replace('@s.whatsapp.net', '').replace('@c.us', '')
// Detecta opt-out
if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) {
console.log(`[evolution] Opt-out detectado: ${phone}`)
await handleOptOut(phone, instance)
return jsonResponse({ ok: true, action: 'opt_out' })
}
// Detecta confirmação (futuro: atualizar status da sessão)
if (['OK', '✅', 'CONFIRMAR', 'CONFIRMO', 'SIM'].includes(text)) {
console.log(`[evolution] Confirmação detectada: ${phone}`)
// TODO: buscar sessão pendente mais próxima e confirmar
return jsonResponse({ ok: true, action: 'confirmation_detected' })
}
return jsonResponse({ ok: true, action: 'message_ignored' })
}
return jsonResponse({ ok: true, event_ignored: event })
}
// ── Meta WhatsApp Webhook ───────────────────────────────────
/**
* Verificação de webhook da Meta (challenge handshake).
*/
function handleMetaVerification (url) {
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
if (mode === 'subscribe' && token === META_VERIFY_TOKEN) {
return new Response(challenge, { status: 200 })
}
return jsonResponse({ error: 'Verification failed' }, 403)
}
/**
* Processa webhooks da Meta WhatsApp Business API.
*/
async function handleMetaWebhook (body) {
const entries = body.entry || []
for (const entry of entries) {
const changes = entry.changes || []
for (const change of changes) {
const value = change.value || {}
// ── Status de mensagem ────
if (value.statuses) {
for (const st of value.statuses) {
const messageId = st.id
const status = st.status // sent, delivered, read, failed
const errors = st.errors || []
if (messageId && status) {
const mappedStatus = status === 'failed' ? 'failed' : status
await updateLogStatus(messageId, mappedStatus, errors[0]?.message)
}
}
}
// ── Mensagens recebidas (opt-out) ────
if (value.messages) {
for (const msg of value.messages) {
const text = (msg.text?.body || '').trim().toUpperCase()
const phone = msg.from || ''
if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) {
console.log(`[meta] Opt-out detectado: ${phone}`)
await handleOptOut(phone, null)
}
// Botão de resposta rápida (quick reply)
if (msg.type === 'button' || msg.type === 'interactive') {
const payload = msg.button?.payload || msg.interactive?.button_reply?.id || ''
console.log(`[meta] Button reply: ${payload} de ${phone}`)
// TODO: processar confirmação/cancelamento via botão
}
}
}
}
}
return jsonResponse({ ok: true })
}
// ── Helpers compartilhados ──────────────────────────────────
/**
* Atualiza o status no notification_logs com base no provider_message_id.
*/
async function updateLogStatus (providerMessageId, status, failureReason) {
const now = new Date().toISOString()
const updateData = { provider_status: status }
switch (status) {
case 'sent':
updateData.status = 'sent'
break
case 'delivered':
updateData.status = 'delivered'
updateData.delivered_at = now
break
case 'read':
updateData.status = 'read'
updateData.read_at = now
break
case 'failed':
updateData.status = 'failed'
updateData.failed_at = now
updateData.failure_reason = failureReason || 'Falha reportada pelo provedor'
break
}
const { error } = await supabase
.from('notification_logs')
.update(updateData)
.eq('provider_message_id', providerMessageId)
if (error) {
console.warn(`[updateLogStatus] Erro ao atualizar ${providerMessageId}:`, error.message)
}
}
/**
* Processa opt-out: desativa WhatsApp para o paciente e cancela pendentes.
* @param {string} phone - número de telefone (apenas dígitos)
* @param {string|null} instanceName - nome da instância Evolution (para identificar owner)
*/
async function handleOptOut (phone, instanceName) {
// Normaliza telefone
const cleanPhone = String(phone).replace(/\D/g, '')
if (!cleanPhone) return
// Busca paciente(s) com esse telefone
const { data: patients } = await supabase
.from('patients')
.select('id, owner_id, telefone')
.or(`telefone.like.%${cleanPhone}%`)
if (!patients || patients.length === 0) {
console.warn(`[opt-out] Nenhum paciente encontrado para ${cleanPhone}`)
return
}
for (const patient of patients) {
// Atualiza preferência (o trigger cancela pendentes automaticamente)
const { error } = await supabase
.from('notification_preferences')
.upsert({
owner_id: patient.owner_id,
tenant_id: patient.owner_id, // será ajustado pelo context
patient_id: patient.id,
whatsapp_opt_in: false,
lgpd_opt_out_date: new Date().toISOString(),
lgpd_opt_out_reason: 'Paciente respondeu SAIR no WhatsApp',
}, {
onConflict: 'owner_id,patient_id',
ignoreDuplicates: false,
})
if (error) {
console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id}:`, error.message)
} else {
console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id}`)
}
}
}
/**
* Helper para respostas JSON padronizadas.
*/
function jsonResponse (data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
@@ -0,0 +1,345 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: process-email-queue
|--------------------------------------------------------------------------
| Processa a notification_queue para channel = 'email'.
|
| Fluxo por item:
| 1. Busca pendentes (channel='email', status='pendente', scheduled_at <= now)
| 2. Marca como 'processando' (lock otimista)
| 3. Busca canal SMTP em notification_channels
| 4. Resolve template: COALESCE(tenant, global)
| 5. Renderiza variáveis e condicionais {{#if}}
| 6. Envia via SMTP (Deno raw TCP com STARTTLS)
| 7. Atualiza queue e insere em notification_logs
| 8. Em erro: retry com backoff ou marca 'falhou'
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { SmtpClient } from 'https://deno.land/x/smtp@v0.7.0/mod.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
}
// ── Template renderer ──────────────────────────────────────────
function resolveVariable(key: string, vars: Record<string, unknown>): unknown {
if (!key.includes('.')) return vars[key]
return key.split('.').reduce((obj: any, part) => obj?.[part], vars)
}
function renderTemplate(template: string, variables: Record<string, unknown>): string {
if (!template) return ''
let result = template
// Blocos condicionais {{#if var}}...{{/if}}
result = result.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key, content) => resolveVariable(key, variables) ? content : ''
)
// Substituições simples {{variavel}}
result = result.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const value = resolveVariable(key, variables)
return value !== undefined && value !== null ? String(value) : ''
})
return result
}
function stripHtml(html: string): string {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// ── SMTP sender ────────────────────────────────────────────────
interface SmtpCredentials {
host: string
port: number
username: string
password: string
from_email: string
from_name?: string
tls?: boolean
}
async function sendEmail(
creds: SmtpCredentials,
to: string,
subject: string,
bodyHtml: string,
bodyText: string
): Promise<{ messageId?: string }> {
const client = new SmtpClient()
const connectConfig = {
hostname: creds.host,
port: creds.port,
username: creds.username,
password: creds.password,
}
// Porta 465 = TLS direto, outras = STARTTLS
if (creds.port === 465 || creds.tls === true) {
await client.connectTLS(connectConfig)
} else {
await client.connect(connectConfig)
}
const fromHeader = creds.from_name
? `${creds.from_name} <${creds.from_email}>`
: creds.from_email
await client.send({
from: fromHeader,
to,
subject,
content: bodyText,
html: bodyHtml,
})
await client.close()
return { messageId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }
}
// ── Main handler ───────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const now = new Date().toISOString()
// 1. Busca itens pendentes de email
const { data: items, error: fetchErr } = await supabase
.from('notification_queue')
.select('*')
.eq('channel', 'email')
.eq('status', 'pendente')
.lte('scheduled_at', now)
.lt('attempts', 5) // respeita max_attempts padrão
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items || items.length === 0) {
return new Response(
JSON.stringify({ message: 'Nenhum email na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const results: Array<{ id: string; status: string; error?: string }> = []
for (const item of items) {
// 2. Lock otimista — marca como processando
const { error: lockErr } = await supabase
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
.eq('status', 'pendente') // garante que outro worker não pegou
if (lockErr) {
results.push({ id: item.id, status: 'skip', error: 'lock failed' })
continue
}
try {
// 3. Busca canal SMTP
const { data: channel, error: chErr } = await supabase
.from('notification_channels')
.select('credentials, sender_address, provider')
.eq('owner_id', item.owner_id)
.eq('channel', 'email')
.eq('is_active', true)
.is('deleted_at', null)
.single()
if (chErr || !channel) {
throw new Error('Canal SMTP não encontrado ou inativo para este owner')
}
// 4. Resolve template: tenant → global fallback
const { data: tenantTpl } = await supabase
.from('email_templates_tenant')
.select('subject, body_html, body_text, enabled')
.eq('tenant_id', item.tenant_id)
.eq('owner_id', item.owner_id)
.eq('template_key', item.template_key)
.maybeSingle()
// Se tenant desabilitou o template → ignorar
if (tenantTpl && tenantTpl.enabled === false) {
await supabase
.from('notification_queue')
.update({ status: 'ignorado' })
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'email',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
status: 'failed',
failure_reason: 'Template desabilitado pelo tenant',
created_at: now,
})
results.push({ id: item.id, status: 'ignorado' })
continue
}
// Busca global
const { data: globalTpl } = await supabase
.from('email_templates_global')
.select('subject, body_html, body_text')
.eq('key', item.template_key)
.eq('is_active', true)
.single()
if (!globalTpl) {
throw new Error(`Template global não encontrado: ${item.template_key}`)
}
// COALESCE: tenant sobrescreve global quando não-null
const resolvedSubject = tenantTpl?.subject ?? globalTpl.subject
const resolvedBodyHtml = tenantTpl?.body_html ?? globalTpl.body_html
const resolvedBodyText = tenantTpl?.body_text ?? globalTpl.body_text
// 5. Renderiza variáveis
const vars = item.resolved_vars || {}
const finalSubject = renderTemplate(resolvedSubject, vars)
const finalBodyHtml = renderTemplate(resolvedBodyHtml, vars)
const finalBodyText = resolvedBodyText
? renderTemplate(resolvedBodyText, vars)
: stripHtml(finalBodyHtml)
// 6. Envia via SMTP
const creds = channel.credentials as SmtpCredentials
if (!creds.from_email && channel.sender_address) {
creds.from_email = channel.sender_address
}
const sendResult = await sendEmail(
creds,
item.recipient_address,
finalSubject,
finalBodyHtml,
finalBodyText
)
// 7. Sucesso — atualiza queue
await supabase
.from('notification_queue')
.update({
status: 'enviado',
sent_at: new Date().toISOString(),
provider_message_id: sendResult.messageId || null,
})
.eq('id', item.id)
// Insere log de sucesso
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'email',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
resolved_message: finalBodyHtml,
resolved_vars: vars,
status: 'sent',
provider: channel.provider || 'smtp',
provider_message_id: sendResult.messageId || null,
sent_at: new Date().toISOString(),
})
results.push({ id: item.id, status: 'enviado' })
} catch (err) {
// 8. Erro — retry com backoff ou falha definitiva
const attempts = item.attempts + 1
const maxAttempts = item.max_attempts || 5
const isExhausted = attempts >= maxAttempts
const retryDelay = attempts * 2 * 60 * 1000 // backoff: attempts * 2 min
const nextRetryAt = isExhausted
? null
: new Date(Date.now() + retryDelay).toISOString()
await supabase
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
last_error: err.message,
next_retry_at: nextRetryAt,
})
.eq('id', item.id)
// Log de falha
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'email',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
status: 'failed',
failure_reason: err.message,
failed_at: new Date().toISOString(),
})
results.push({ id: item.id, status: isExhausted ? 'falhou' : 'retry', error: err.message })
}
}
const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length
const retried = results.filter(r => r.status === 'retry').length
const ignored = results.filter(r => r.status === 'ignorado').length
return new Response(
JSON.stringify({
processed: results.length,
sent,
failed,
retried,
ignored,
details: results,
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
@@ -0,0 +1,284 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: process-notification-queue
|--------------------------------------------------------------------------
| Processa a notification_queue para channel = 'whatsapp' via Evolution API.
|
| Fluxo por item:
| 1. Busca pendentes (channel='whatsapp', status='pendente', scheduled_at <= now)
| 2. Marca como 'processando' (lock otimista)
| 3. Busca credenciais Evolution API em notification_channels
| 4. Resolve template: tenant → global fallback
| 5. Renderiza variáveis {{var}}
| 6. Envia via Evolution API (sendText)
| 7. Atualiza queue + insere notification_logs
| 8. Em erro: retry com backoff ou marca 'falhou'
|--------------------------------------------------------------------------
*/
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',
}
// ── Template renderer ──────────────────────────────────────────
function renderTemplate(template: string, variables: Record<string, unknown>): string {
if (!template) return ''
return template.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const val = variables[key]
return val !== undefined && val !== null ? String(val) : ''
})
}
// ── Evolution API sender ───────────────────────────────────────
interface EvolutionCredentials {
api_url: string
api_key: string
instance_name: string
}
async function sendWhatsapp(
credentials: EvolutionCredentials,
recipient: string,
message: string
): Promise<{ messageId?: string; status: string }> {
const url = `${credentials.api_url}/message/sendText/${credentials.instance_name}`
const res = await fetch(url, {
method: 'POST',
headers: {
'apikey': credentials.api_key,
'Content-Type': 'application/json',
},
body: JSON.stringify({
number: recipient,
text: message,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data?.message || `Evolution API error ${res.status}`)
}
return {
messageId: data?.key?.id || data?.messageId || null,
status: 'sent',
}
}
// ── Main handler ───────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const now = new Date().toISOString()
// 1. Busca itens pendentes de WhatsApp
const { data: items, error: fetchErr } = await supabase
.from('notification_queue')
.select('*')
.eq('channel', 'whatsapp')
.eq('status', 'pendente')
.lte('scheduled_at', now)
.lt('attempts', 5)
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items?.length) {
return new Response(
JSON.stringify({ message: 'Nenhum WhatsApp na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const results: Array<{ id: string; status: string; error?: string }> = []
// Cache de credenciais por owner para evitar queries repetidas
const credentialsCache = new Map<string, EvolutionCredentials | null>()
for (const item of items) {
// 2. Lock otimista
const { error: lockErr } = await supabase
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
.eq('status', 'pendente')
if (lockErr) {
results.push({ id: item.id, status: 'skip', error: 'lock failed' })
continue
}
try {
// 3. Busca credenciais Evolution API (com cache)
let credentials = credentialsCache.get(item.owner_id)
if (credentials === undefined) {
const { data: channel } = await supabase
.from('notification_channels')
.select('credentials')
.eq('owner_id', item.owner_id)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
// Fallback: busca por tenant_id
if (!channel?.credentials && item.tenant_id) {
const { data: tenantChannel } = await supabase
.from('notification_channels')
.select('credentials')
.eq('tenant_id', item.tenant_id)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
credentials = tenantChannel?.credentials as EvolutionCredentials | null
} else {
credentials = channel?.credentials as EvolutionCredentials | null
}
credentialsCache.set(item.owner_id, credentials ?? null)
}
if (!credentials) {
throw new Error('Canal WhatsApp não encontrado ou inativo para este owner')
}
// 4. Resolve template: tenant → global fallback
let templateBody: string | null = null
const { data: tenantTpl } = await supabase
.from('notification_templates')
.select('body_text, is_active')
.eq('tenant_id', item.tenant_id)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (tenantTpl) {
templateBody = tenantTpl.body_text
} else {
const { data: globalTpl } = await supabase
.from('notification_templates')
.select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
templateBody = globalTpl?.body_text || null
}
if (!templateBody) {
throw new Error(`Template WhatsApp não encontrado: ${item.template_key}`)
}
// 5. Renderiza variáveis
const vars = item.resolved_vars || {}
const message = renderTemplate(templateBody, vars)
// 6. Envia via Evolution API
const sendResult = await sendWhatsapp(credentials, item.recipient_address, message)
// 7. Sucesso
await supabase
.from('notification_queue')
.update({
status: 'enviado',
sent_at: new Date().toISOString(),
provider_message_id: sendResult.messageId || null,
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'whatsapp',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
resolved_message: message,
resolved_vars: vars,
status: 'sent',
provider: 'evolution_api',
provider_message_id: sendResult.messageId || null,
sent_at: new Date().toISOString(),
})
results.push({ id: item.id, status: 'enviado' })
} catch (err) {
// 8. Erro — retry com backoff
const attempts = item.attempts + 1
const maxAttempts = item.max_attempts || 5
const isExhausted = attempts >= maxAttempts
const retryMs = attempts * 2 * 60 * 1000
await supabase
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
last_error: err.message,
next_retry_at: isExhausted ? null : new Date(Date.now() + retryMs).toISOString(),
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'whatsapp',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
status: 'failed',
failure_reason: err.message,
failed_at: new Date().toISOString(),
})
results.push({ id: item.id, status: isExhausted ? 'falhou' : 'retry', error: err.message })
}
}
const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length
const retried = results.filter(r => r.status === 'retry').length
return new Response(
JSON.stringify({ processed: results.length, sent, failed, retried, details: results }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
@@ -0,0 +1,318 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: process-sms-queue
|--------------------------------------------------------------------------
| Processa a notification_queue para channel = 'sms' via Twilio.
|
| Modelo: Créditos da plataforma
| - Credenciais Twilio são da plataforma (env vars)
| - Antes de enviar, debita 1 crédito do tenant via RPC
| - Sem crédito → marca como 'sem_credito'
|
| Fluxo:
| 1. Busca pendentes (channel='sms', status='pendente', scheduled_at <= now)
| 2. Lock otimista (status → processando)
| 3. Debita crédito SMS do tenant (addon_credits)
| 4. Resolve template (tenant → global fallback)
| 5. Renderiza variáveis {{var}}
| 6. Envia via Twilio REST API (credenciais da plataforma)
| 7. Atualiza queue + insere notification_logs
| 8. Retry com backoff em caso de erro
|--------------------------------------------------------------------------
*/
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',
}
// ── Template renderer ──────────────────────────────────────────
function renderTemplate(template: string, variables: Record<string, unknown>): string {
if (!template) return ''
return template.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const val = variables[key]
return val !== undefined && val !== null ? String(val) : ''
})
}
// ── Twilio SMS sender (credenciais da plataforma) ──────────────
async function sendViaTwilio(
to: string,
body: string,
fromOverride?: string | null
): Promise<{ sid: string; status: string }> {
const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID')!
const authToken = Deno.env.get('TWILIO_AUTH_TOKEN')!
const fromNumber = fromOverride || Deno.env.get('TWILIO_FROM_NUMBER')!
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
const auth = btoa(`${accountSid}:${authToken}`)
const params = new URLSearchParams()
params.set('From', fromNumber)
params.set('To', to)
params.set('Body', body)
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message || `Twilio error ${res.status}: ${data.code}`)
}
return { sid: data.sid, status: data.status }
}
// ── Mock sender (dev/test) ─────────────────────────────────────
function isMockMode(): boolean {
const sid = Deno.env.get('TWILIO_ACCOUNT_SID') || ''
return sid.startsWith('AC_TEST') || Deno.env.get('DEV') === 'true'
}
function mockSend(_to: string, _body: string): { sid: string; status: string } {
const sid = `mock_${crypto.randomUUID().slice(0, 8)}`
console.log(`[SMS MOCK] To: ${_to} | Body: ${_body} | SID: ${sid}`)
return { sid, status: 'sent' }
}
// ── Main handler ───────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const now = new Date().toISOString()
// 1. Busca itens pendentes
const { data: items, error: fetchErr } = await supabase
.from('notification_queue')
.select('*')
.eq('channel', 'sms')
.eq('status', 'pendente')
.lte('scheduled_at', now)
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items?.length) {
return new Response(
JSON.stringify({ message: 'Nenhum SMS na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const results: Array<{ id: string; status: string; error?: string }> = []
for (const item of items) {
// Filtra por max_attempts
if (item.attempts >= (item.max_attempts || 5)) continue
// 2. Lock otimista
const { error: lockErr } = await supabase
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
.eq('status', 'pendente')
if (lockErr) {
results.push({ id: item.id, status: 'skip', error: 'lock failed' })
continue
}
try {
// 3. Debita crédito SMS do tenant
const { data: debitResult, error: debitErr } = await supabase
.rpc('debit_addon_credit', {
p_tenant_id: item.tenant_id,
p_addon_type: 'sms',
p_queue_id: item.id,
p_description: `SMS para ${item.recipient_address}`,
})
if (debitErr) {
throw new Error(`Erro ao debitar crédito: ${debitErr.message}`)
}
if (!debitResult?.success) {
// Sem crédito — não envia, marca como sem_credito
await supabase
.from('notification_queue')
.update({ status: 'sem_credito', last_error: debitResult?.reason || 'Sem créditos SMS' })
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'sms',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
status: 'failed',
failure_reason: `Sem créditos SMS: ${debitResult?.reason || 'balance=0'}`,
failed_at: new Date().toISOString(),
})
results.push({ id: item.id, status: 'sem_credito', error: debitResult?.reason })
continue
}
// 4. Resolve template: tenant → global fallback
let template: { body_text: string } | null = null
const { data: tenantTpl } = await supabase
.from('notification_templates')
.select('body_text, is_active')
.eq('tenant_id', item.tenant_id)
.eq('key', item.template_key)
.eq('channel', 'sms')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (tenantTpl) {
template = tenantTpl
} else {
const { data: globalTpl } = await supabase
.from('notification_templates')
.select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key)
.eq('channel', 'sms')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
template = globalTpl
}
if (!template) {
throw new Error(`Template SMS não encontrado: ${item.template_key}`)
}
// 5. Renderiza variáveis
const vars = item.resolved_vars || {}
const message = renderTemplate(template.body_text, vars)
// 6. Busca from_number override do tenant (se tiver)
const { data: creditRow } = await supabase
.from('addon_credits')
.select('from_number_override')
.eq('tenant_id', item.tenant_id)
.eq('addon_type', 'sms')
.maybeSingle()
const fromOverride = creditRow?.from_number_override || null
// 7. Envia via Twilio (ou mock)
let sendResult: { sid: string; status: string }
if (isMockMode()) {
sendResult = mockSend(item.recipient_address, message)
} else {
sendResult = await sendViaTwilio(item.recipient_address, message, fromOverride)
}
// 8. Sucesso
await supabase
.from('notification_queue')
.update({
status: 'enviado',
sent_at: new Date().toISOString(),
provider_message_id: sendResult.sid,
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'sms',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
resolved_message: message,
resolved_vars: vars,
status: 'sent',
provider: 'twilio',
provider_message_id: sendResult.sid,
sent_at: new Date().toISOString(),
})
results.push({ id: item.id, status: 'enviado' })
} catch (err) {
// 9. Erro — retry com backoff
const attempts = item.attempts + 1
const maxAttempts = item.max_attempts || 5
const isExhausted = attempts >= maxAttempts
const retryMs = attempts * 2 * 60 * 1000
await supabase
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
last_error: err.message,
next_retry_at: isExhausted ? null : new Date(Date.now() + retryMs).toISOString(),
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'sms',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
status: 'failed',
failure_reason: err.message,
failed_at: new Date().toISOString(),
})
results.push({ id: item.id, status: isExhausted ? 'falhou' : 'retry', error: err.message })
}
}
const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length
const noCredit = results.filter(r => r.status === 'sem_credito').length
return new Response(
JSON.stringify({ processed: results.length, sent, failed, no_credit: noCredit, details: results }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
@@ -0,0 +1,304 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: process-whatsapp-queue
|--------------------------------------------------------------------------
| Processa a notification_queue para channel='whatsapp' e provider='twilio'.
| Usa credenciais da SUBCONTA de cada tenant (modelo de subcontas).
|
| Fluxo:
| 1. Busca itens pendentes (channel='whatsapp', status='pendente')
| 2. Filtra somente tenants com provider='twilio' em notification_channels
| 3. Lock otimista (status → processando)
| 4. Resolve template (tenant → global fallback)
| 5. Renderiza variáveis {{var}}
| 6. Envia via Twilio usando credenciais da SUBCONTA do tenant
| 7. Atualiza queue + insere notification_logs com estimated_cost_brl
| 8. Retry com backoff em caso de erro
|--------------------------------------------------------------------------
*/
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',
}
// ── Template renderer ──────────────────────────────────────────────────────
function renderTemplate(template: string, variables: Record<string, unknown>): string {
if (!template) return ''
return template.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
const val = variables[key]
return val !== undefined && val !== null ? String(val) : ''
})
}
// ── Twilio WhatsApp sender (via subconta) ─────────────────────────────────
async function sendWhatsAppViaTwilio(
subAccountSid: string,
subAuthToken: string,
fromNumber: string,
toNumber: string,
body: string
): Promise<{ sid: string; status: string }> {
const url = `https://api.twilio.com/2010-04-01/Accounts/${subAccountSid}/Messages.json`
const auth = btoa(`${subAccountSid}:${subAuthToken}`)
const params = new URLSearchParams()
params.set('From', `whatsapp:${fromNumber}`)
params.set('To', `whatsapp:${toNumber}`)
params.set('Body', body)
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message || `Twilio error ${res.status}: ${data.code}`)
}
return { sid: data.sid, status: data.status }
}
// ── Mock sender ────────────────────────────────────────────────────────────
function isMockMode(): boolean {
const sid = Deno.env.get('TWILIO_ACCOUNT_SID') || ''
return sid.startsWith('AC_TEST') || Deno.env.get('DEV') === 'true'
}
function mockSend(to: string, body: string): { sid: string; status: string } {
const sid = `mock_wa_${crypto.randomUUID().slice(0, 8)}`
console.log(`[WA MOCK] To: ${to} | Body: ${body} | SID: ${sid}`)
return { sid, status: 'sent' }
}
// ── Main handler ──────────────────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const usdBrlRate = parseFloat(Deno.env.get('USD_BRL_RATE') ?? '5.5')
const now = new Date().toISOString()
// 1. Busca itens pendentes de WhatsApp
const { data: items, error: fetchErr } = await supabase
.from('notification_queue')
.select('*')
.eq('channel', 'whatsapp')
.eq('status', 'pendente')
.lte('scheduled_at', now)
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items?.length) {
return new Response(
JSON.stringify({ message: 'Nenhuma mensagem WhatsApp na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// Cache de canais por tenant para evitar N+1
const channelCache: Record<string, {
twilio_subaccount_sid: string
twilio_phone_number: string
cost_per_message_usd: number
price_per_message_brl: number
credentials: Record<string, string>
} | null> = {}
async function getChannel(tenantId: string) {
if (tenantId in channelCache) return channelCache[tenantId]
const { data } = await supabase
.from('notification_channels')
.select('twilio_subaccount_sid, twilio_phone_number, cost_per_message_usd, price_per_message_brl, credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
channelCache[tenantId] = data
return data
}
const results: Array<{ id: string; status: string; error?: string }> = []
for (const item of items) {
if (item.attempts >= (item.max_attempts || 5)) continue
// 2. Lock otimista
const { error: lockErr } = await supabase
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
.eq('status', 'pendente')
if (lockErr) {
results.push({ id: item.id, status: 'skip', error: 'lock failed' })
continue
}
try {
// 3. Busca canal twilio do tenant
const channel = await getChannel(item.tenant_id)
if (!channel?.twilio_subaccount_sid) {
throw new Error('Tenant não tem subconta Twilio WhatsApp ativa')
}
const subToken = channel.credentials?.subaccount_auth_token
if (!subToken) throw new Error('subaccount_auth_token não encontrado nas credenciais')
// 4. Resolve template: tenant → global fallback
let template: { body_text: string } | null = null
const { data: tenantTpl } = await supabase
.from('notification_templates')
.select('body_text')
.eq('tenant_id', item.tenant_id)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (tenantTpl) {
template = tenantTpl
} else {
const { data: globalTpl } = await supabase
.from('notification_templates')
.select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
template = globalTpl
}
if (!template) throw new Error(`Template WhatsApp não encontrado: ${item.template_key}`)
// 5. Renderiza variáveis
const vars = item.resolved_vars || {}
const message = renderTemplate(template.body_text, vars)
// 6. Envia via Twilio (subconta do tenant)
let sendResult: { sid: string; status: string }
if (isMockMode()) {
sendResult = mockSend(item.recipient_address, message)
} else {
sendResult = await sendWhatsAppViaTwilio(
channel.twilio_subaccount_sid,
subToken,
channel.twilio_phone_number,
item.recipient_address,
message
)
}
// Custo estimado em BRL
const costBrl = (channel.cost_per_message_usd ?? 0) * usdBrlRate
// 7. Sucesso — atualiza fila
await supabase
.from('notification_queue')
.update({
status: 'enviado',
sent_at: new Date().toISOString(),
provider_message_id: sendResult.sid,
})
.eq('id', item.id)
// 7b. Insere no log
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'whatsapp',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
resolved_message: message,
resolved_vars: vars,
status: 'sent',
provider: 'twilio',
provider_message_id: sendResult.sid,
provider_status: sendResult.status,
estimated_cost_brl: costBrl,
sent_at: new Date().toISOString(),
})
results.push({ id: item.id, status: 'enviado' })
} catch (e) {
// 8. Erro — retry com backoff exponencial
const attempts = item.attempts + 1
const maxAttempts = item.max_attempts || 5
const isExhausted = attempts >= maxAttempts
const retryMs = Math.min(attempts * 2 * 60 * 1000, 30 * 60 * 1000) // max 30min
await supabase
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
last_error: e.message,
next_retry_at: isExhausted ? null : new Date(Date.now() + retryMs).toISOString(),
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
patient_id: item.patient_id,
channel: 'whatsapp',
template_key: item.template_key,
schedule_key: item.schedule_key,
recipient_address: item.recipient_address,
status: 'failed',
provider: 'twilio',
failure_reason: e.message,
failed_at: new Date().toISOString(),
})
results.push({ id: item.id, status: isExhausted ? 'falhou' : 'retry', error: e.message })
}
}
const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length
const retry = results.filter(r => r.status === 'retry').length
return new Response(
JSON.stringify({ processed: results.length, sent, failed, retry, details: results }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
@@ -0,0 +1,210 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: submit-patient-intake (A#20 rev2)
|--------------------------------------------------------------------------
| Defesa em camadas SELF-HOSTED (substitui Turnstile):
| 1. Honeypot field → frontend renderiza, edge rejeita se vier preenchido
| 2. Rate limit por IP → check_rate_limit RPC
| 3. Math captcha CONDICIONAL → exigido só após N falhas do mesmo IP
| 4. Logging → record_submission_attempt RPC (genérico)
| 5. Modo paranoid → saas_security_config.captcha_required_globally
|
| Endpoints:
| POST / → submit do form
| POST /captcha-challenge → gera math challenge (id+question) sob demanda
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
const ENDPOINT_NAME = 'patient_intake'
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY)
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
}
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...CORS_HEADERS }
})
}
// SHA-256 estável (rate limit é por hash, nunca pelo IP cru — privacidade)
async function hashIp(ip) {
if (!ip) return null
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`agenciapsi:${ip}`))
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32)
}
function getClientIp(req) {
const xff = req.headers.get('x-forwarded-for')
if (xff) return xff.split(',')[0].trim()
return req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || null
}
// ─────────────────────────────────────────────────────────────────────────
// Handler principal
// -----------------------------------------------------------------------------
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS_HEADERS })
if (req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405)
const url = new URL(req.url)
const isCaptchaEndpoint = url.pathname.endsWith('/captcha-challenge')
// ── Endpoint de challenge (sem rate limit aqui pra não bloquear UX) ──
if (isCaptchaEndpoint) {
const { data, error } = await supabase.rpc('generate_math_challenge')
if (error) return jsonResponse({ error: 'challenge-failed', message: error.message }, 500)
return jsonResponse({ challenge: data }, 200)
}
// ── Endpoint principal: submit do intake ──
let body
try {
body = await req.json()
} catch {
return jsonResponse({ error: 'invalid-json' }, 400)
}
const token = String(body?.token || '').trim()
const payload = body?.payload && typeof body.payload === 'object' ? body.payload : null
const honeypot = String(body?.website || '').trim() // honeypot field name
const captchaId = body?.captcha_id ? String(body.captcha_id) : null
const captchaAnswer = body?.captcha_answer != null ? Number(body.captcha_answer) : null
const ip = getClientIp(req)
const ipHash = await hashIp(ip)
const userAgent = String(body?.client_info || req.headers.get('user-agent') || '').slice(0, 500)
// ── 1) HONEYPOT: bot tipicamente preenche todos os campos ──
if (honeypot.length > 0) {
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: false,
p_blocked_by: 'honeypot',
p_error_code: 'honeypot_filled',
p_error_msg: null,
p_user_agent: userAgent,
p_metadata: null
})
// Resposta deliberadamente igual à de sucesso pra não dar feedback ao bot
return jsonResponse({ ok: true, intake_id: null }, 200)
}
if (!token || !payload) {
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: false,
p_blocked_by: 'validation',
p_error_code: 'missing_fields',
p_error_msg: null,
p_user_agent: userAgent,
p_metadata: null
})
return jsonResponse({ error: 'missing-fields' }, 400)
}
// ── 2) RATE LIMIT + decisão de captcha ──
const { data: rl, error: rlErr } = await supabase.rpc('check_rate_limit', {
p_ip_hash: ipHash,
p_endpoint: ENDPOINT_NAME
})
if (rlErr) {
// fail-open mas logado
console.error('[submit-patient-intake] check_rate_limit failed:', rlErr.message)
}
if (rl && rl.allowed === false) {
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: false,
p_blocked_by: 'rate_limit',
p_error_code: rl.reason || 'blocked',
p_error_msg: null,
p_user_agent: userAgent,
p_metadata: null
})
return jsonResponse({
error: 'rate-limited',
retry_after_seconds: rl.retry_after_seconds || null
}, 429)
}
// ── 3) MATH CAPTCHA condicional ──
if (rl && rl.requires_captcha === true) {
if (!captchaId || !Number.isFinite(captchaAnswer)) {
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: false,
p_blocked_by: 'captcha',
p_error_code: 'captcha_required',
p_error_msg: null,
p_user_agent: userAgent,
p_metadata: null
})
return jsonResponse({ error: 'captcha-required' }, 403)
}
const { data: ok, error: vErr } = await supabase.rpc('verify_math_challenge', {
p_id: captchaId,
p_answer: captchaAnswer
})
if (vErr || !ok) {
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: false,
p_blocked_by: 'captcha',
p_error_code: 'captcha_wrong',
p_error_msg: vErr?.message || null,
p_user_agent: userAgent,
p_metadata: null
})
return jsonResponse({ error: 'captcha-wrong' }, 403)
}
}
// ── 4) Chama RPC do intake ──
const { data, error } = await supabase.rpc('create_patient_intake_request_v2', {
p_token: token,
p_payload: payload,
p_client_info: userAgent || null
})
if (error) {
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: false,
p_blocked_by: 'rpc',
p_error_code: error.code || 'rpc_err',
p_error_msg: error.message,
p_user_agent: userAgent,
p_metadata: null
})
return jsonResponse({ error: 'rpc-failed', message: error.message }, 400)
}
await supabase.rpc('record_submission_attempt', {
p_endpoint: ENDPOINT_NAME,
p_ip_hash: ipHash,
p_success: true,
p_blocked_by: null,
p_error_code: null,
p_error_msg: null,
p_user_agent: userAgent,
p_metadata: null
})
return jsonResponse({ ok: true, intake_id: data }, 200)
})
@@ -0,0 +1,135 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: sync-email-templates
|--------------------------------------------------------------------------
| Sincroniza templates globais ativos para email_templates_tenant.
|
| POST body: { tenant_id: string, owner_id: string }
|
| Para cada template em email_templates_global (is_active = true):
| - Se não existe no tenant → INSERT com subject/body NULL (herda global)
| - Se existe mas synced_version < global.version → UPDATE synced_version
|
| Retorna: { synced: number, updated: number }
|--------------------------------------------------------------------------
*/
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',
}
Deno.serve(async (req: Request) => {
// CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
if (req.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Método não permitido' }),
{ status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
try {
const { tenant_id, owner_id } = await req.json()
if (!tenant_id || !owner_id) {
return new Response(
JSON.stringify({ error: 'tenant_id e owner_id são obrigatórios' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// 1. Busca todos os templates globais ativos
const { data: globals, error: globalsErr } = await supabase
.from('email_templates_global')
.select('key, version')
.eq('is_active', true)
if (globalsErr) throw globalsErr
if (!globals || globals.length === 0) {
return new Response(
JSON.stringify({ synced: 0, updated: 0, message: 'Nenhum template global ativo' }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 2. Busca templates existentes do tenant
const { data: tenantTemplates, error: tenantErr } = await supabase
.from('email_templates_tenant')
.select('template_key, synced_version')
.eq('tenant_id', tenant_id)
.eq('owner_id', owner_id)
if (tenantErr) throw tenantErr
const tenantMap = new Map(
(tenantTemplates || []).map(t => [t.template_key, t.synced_version])
)
let synced = 0
let updated = 0
for (const global of globals) {
const existingVersion = tenantMap.get(global.key)
if (existingVersion === undefined) {
// Não existe → INSERT com campos null (herda do global)
const { error: insertErr } = await supabase
.from('email_templates_tenant')
.insert({
tenant_id,
owner_id,
template_key: global.key,
subject: null,
body_html: null,
body_text: null,
enabled: true,
synced_version: global.version,
})
if (insertErr) {
console.error(`[sync] Erro ao inserir ${global.key}:`, insertErr.message)
continue
}
synced++
} else if (existingVersion < global.version) {
// Existe mas desatualizado → UPDATE apenas synced_version
const { error: updateErr } = await supabase
.from('email_templates_tenant')
.update({ synced_version: global.version })
.eq('tenant_id', tenant_id)
.eq('owner_id', owner_id)
.eq('template_key', global.key)
if (updateErr) {
console.error(`[sync] Erro ao atualizar ${global.key}:`, updateErr.message)
continue
}
updated++
}
// Se synced_version >= global.version → já está em dia, skip
}
return new Response(
JSON.stringify({ synced, updated }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (err) {
console.error('[sync-email-templates] Erro:', err)
return new Response(
JSON.stringify({ error: err.message || 'Erro interno' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})
@@ -0,0 +1,163 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: test-sms-channel
|--------------------------------------------------------------------------
| Envia SMS de teste usando credenciais da plataforma.
| Debita 1 crédito do tenant.
|
| POST body: { owner_id: string }
| Retorna: { success: boolean, message: string, sid?: string }
|--------------------------------------------------------------------------
*/
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',
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
if (req.method !== 'POST') {
return new Response(
JSON.stringify({ success: false, message: 'Método não permitido' }),
{ status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
try {
const { owner_id } = await req.json()
if (!owner_id) {
return new Response(
JSON.stringify({ success: false, message: 'owner_id é obrigatório' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// 1. Busca telefone do owner
const { data: profile } = await supabase
.from('profiles')
.select('phone, full_name, tenant_id')
.eq('id', owner_id)
.single()
if (!profile?.phone) {
return new Response(
JSON.stringify({ success: false, message: 'Telefone não encontrado no seu perfil. Configure em Meu Perfil.' }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const tenantId = profile.tenant_id || owner_id
// 2. Verifica créditos SMS do tenant
const { data: debitResult, error: debitErr } = await supabase.rpc('debit_addon_credit', {
p_tenant_id: tenantId,
p_addon_type: 'sms',
p_description: `SMS teste para ${profile.phone}`,
})
if (debitErr) {
return new Response(
JSON.stringify({ success: false, message: `Erro ao verificar créditos: ${debitErr.message}` }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!debitResult?.success) {
const reasonMap: Record<string, string> = {
no_credits: 'Sem créditos SMS. Adquira um pacote em Recursos Extras.',
insufficient_balance: 'Saldo de créditos SMS insuficiente.',
daily_limit_reached: 'Limite diário de envios atingido.',
hourly_limit_reached: 'Limite por hora de envios atingido.',
credits_expired: 'Seus créditos SMS expiraram.',
}
return new Response(
JSON.stringify({ success: false, message: reasonMap[debitResult?.reason] || 'Sem créditos SMS disponíveis.' }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 3. Credenciais da plataforma
const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID')!
const authToken = Deno.env.get('TWILIO_AUTH_TOKEN')!
const fromNumber = Deno.env.get('TWILIO_FROM_NUMBER')!
// Busca override de from_number do tenant
const { data: creditRow } = await supabase
.from('addon_credits')
.select('from_number_override')
.eq('tenant_id', tenantId)
.eq('addon_type', 'sms')
.maybeSingle()
const fromAddr = creditRow?.from_number_override || fromNumber
// 4. Modo mock para testes
if (accountSid.startsWith('AC_TEST') || Deno.env.get('DEV') === 'true') {
const mockSid = `mock_test_${crypto.randomUUID().slice(0, 8)}`
console.log(`[SMS TEST MOCK] To: ${profile.phone} | SID: ${mockSid}`)
return new Response(
JSON.stringify({ success: true, message: `SMS de teste enviado (mock) para ${profile.phone}`, sid: mockSid }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 5. Envia SMS real via Twilio
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
const auth = btoa(`${accountSid}:${authToken}`)
const params = new URLSearchParams()
params.set('From', fromAddr)
params.set('To', profile.phone)
params.set('Body', 'Teste de configuração SMS — Agência PSI. Canal funcionando!')
const res = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
const data = await res.json()
if (!res.ok) {
return new Response(
JSON.stringify({
success: false,
message: data.message || `Erro Twilio: ${data.code || res.status}`,
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
return new Response(
JSON.stringify({
success: true,
message: `SMS de teste enviado para ${profile.phone}`,
sid: data.sid,
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (err) {
console.error('[test-sms-channel] Erro:', err)
return new Response(
JSON.stringify({ success: false, message: err.message || 'Erro interno' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})
@@ -0,0 +1,632 @@
/*
|--------------------------------------------------------------------------
| 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)
})
@@ -0,0 +1,112 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Function: twilio-whatsapp-webhook
|--------------------------------------------------------------------------
| Recebe callbacks de status do Twilio para mensagens WhatsApp enviadas
| pelas subcontas de cada tenant.
|
| URL configurada no número de cada subconta:
| https://<project>.supabase.co/functions/v1/twilio-whatsapp-webhook?tenant_id=<uuid>
|
| Eventos recebidos (MessageStatus):
| queued, failed, sent, delivered, undelivered, read
|--------------------------------------------------------------------------
*/
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, GET, OPTIONS',
}
// Mapeia status Twilio → status interno
function mapStatus(twilioStatus: string): string | null {
switch (twilioStatus) {
case 'delivered': return 'delivered'
case 'read': return 'read'
case 'failed':
case 'undelivered': return 'failed'
case 'sent': return 'sent'
default: return null
}
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
// Twilio envia POST com form-encoded
if (req.method !== 'POST') {
return new Response('ok', { status: 200, headers: corsHeaders })
}
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const url = new URL(req.url)
const tenantId = url.searchParams.get('tenant_id')
const formData = await req.formData()
const messageSid = formData.get('MessageSid') as string
const messageStatus = formData.get('MessageStatus') as string
const to = formData.get('To') as string
const errorCode = formData.get('ErrorCode') as string | null
const errorMessage = formData.get('ErrorMessage') as string | null
if (!messageSid || !messageStatus) {
return new Response('ok', { status: 200, headers: corsHeaders })
}
const internalStatus = mapStatus(messageStatus)
if (!internalStatus) {
// Status intermediário (queued, accepted, etc.) — ignora
return new Response('ok', { status: 200, headers: corsHeaders })
}
// Atualiza notification_logs pelo provider_message_id
const updateData: Record<string, unknown> = {
provider_status: messageStatus,
status: internalStatus,
}
if (internalStatus === 'delivered') {
updateData.delivered_at = new Date().toISOString()
} else if (internalStatus === 'read') {
updateData.read_at = new Date().toISOString()
updateData.delivered_at = new Date().toISOString()
} else if (internalStatus === 'failed') {
updateData.failed_at = new Date().toISOString()
updateData.failure_reason = errorMessage
? `${errorCode}: ${errorMessage}`
: `Twilio status: ${messageStatus}`
}
const query = supabase
.from('notification_logs')
.update(updateData)
.eq('provider_message_id', messageSid)
if (tenantId) query.eq('tenant_id', tenantId)
const { error } = await query
if (error) {
console.error('[webhook] Erro ao atualizar log:', error.message)
} else {
console.log(`[webhook] ${messageSid}${messageStatus} (tenant: ${tenantId ?? 'unknown'})`)
}
// Twilio espera 200 TwiML vazio ou texto simples
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'text/xml' },
})
} catch (e) {
console.error('[webhook] Erro:', e)
return new Response('ok', { status: 200, headers: corsHeaders })
}
})