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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user