/* |-------------------------------------------------------------------------- | 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' | | schema-per-tenant: notification_queue/templates/logs vivem no schema físico | de cada tenant (SEM coluna tenant_id). O cron VARRE todos os tenants; se vier | body.tenant_id, processa só aquele (modo single). addon_credits é GLOBAL | (admin) e a RPC debit_addon_credit continua em admin.rpc com p_tenant_id. | | Fluxo: | 1. Busca pendentes (channel='sms', status='pendente', scheduled_at <= now) | 2. Lock otimista (status → processando) | 3. Debita crédito SMS do tenant (RPC, admin + p_tenant_id) | 4. Resolve template (templates do schema pertencem ao tenant) | 5. Renderiza variáveis {{var}} | 6. Envia via Twilio REST API (credenciais da plataforma) | 7. Atualiza queue + insere notification_logs (tdb) | 8. Retry com backoff em caso de erro |-------------------------------------------------------------------------- */ import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2' import { adminClient, listTenantSchemas, tenantDbForId, schemaForTenant } from '../_shared/tenant.ts' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', } // ── Template renderer ────────────────────────────────────────── function renderTemplate(template: string, variables: Record): 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' } } // ── Processa a fila de UM tenant ─────────────────────────────── type Result = { id: string; status: string; error?: string } async function processTenantQueue( admin: SupabaseClient, tdb: SupabaseClient, tenantId: string ): Promise { const now = new Date().toISOString() // 1. Busca itens pendentes deste tenant const { data: items, error: fetchErr } = await tdb .from('notification_queue') .select('*') .eq('channel', 'sms') .eq('status', 'pendente') .lte('scheduled_at', now) .order('scheduled_at', { ascending: true }) .limit(20) if (fetchErr) throw new Error(fetchErr.message) if (!items?.length) return [] const results: Result[] = [] for (const item of items) { // Filtra por max_attempts if (item.attempts >= (item.max_attempts || 5)) continue // 2. Lock otimista const { error: lockErr } = await tdb .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 (RPC global, p_tenant_id da iteração) const { data: debitResult, error: debitErr } = await admin .rpc('debit_addon_credit', { p_tenant_id: tenantId, 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 tdb .from('notification_queue') .update({ status: 'sem_credito', last_error: debitResult?.reason || 'Sem créditos SMS' }) .eq('id', item.id) await tdb.from('notification_logs').insert({ 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: templates do schema pertencem ao tenant. // Preferimos override do owner; senão default do schema. let template: { body_text: string } | null = null const { data: ownerTpl } = await tdb .from('notification_templates') .select('body_text, is_active') .eq('owner_id', item.owner_id) .eq('key', item.template_key) .eq('channel', 'sms') .eq('is_active', true) .is('deleted_at', null) .maybeSingle() if (ownerTpl) { template = ownerTpl } else { const { data: defaultTpl } = await tdb .from('notification_templates') .select('body_text') .eq('key', item.template_key) .eq('channel', 'sms') .eq('is_default', true) .eq('is_active', true) .is('deleted_at', null) .maybeSingle() template = defaultTpl } 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 (addon_credits é GLOBAL → admin) const { data: creditRow } = await admin .from('addon_credits') .select('from_number_override') .eq('tenant_id', tenantId) .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 tdb .from('notification_queue') .update({ status: 'enviado', sent_at: new Date().toISOString(), provider_message_id: sendResult.sid, }) .eq('id', item.id) await tdb.from('notification_logs').insert({ 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 tdb .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 tdb.from('notification_logs').insert({ 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 }) } } return results } // ── Main handler ─────────────────────────────────────────────── Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } const admin = adminClient() // Modo single-tenant se body.tenant_id; senão varre todos. let bodyTenantId: string | null = null try { const body = await req.json() bodyTenantId = body?.tenant_id ?? null } catch { // sem body / body inválido → modo varredura } const results: Result[] = [] const errors: Array<{ tenantId: string; error: string }> = [] try { if (bodyTenantId) { const schema = await schemaForTenant(admin, bodyTenantId) if (!schema) { return new Response( JSON.stringify({ error: `schema indisponível para tenant ${bodyTenantId}` }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const tdb = await tenantDbForId(admin, bodyTenantId) results.push(...await processTenantQueue(admin, tdb, bodyTenantId)) } else { const tenants = await listTenantSchemas(admin) for (const t of tenants) { try { const tdb = admin.schema(t.schema) results.push(...await processTenantQueue(admin, tdb, t.tenantId)) } catch (e) { console.error(`[process-sms-queue] tenant ${t.tenantId} falhou:`, e.message) errors.push({ tenantId: t.tenantId, error: e.message }) } } } } catch (err) { return new Response( JSON.stringify({ error: err.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (!results.length && !errors.length) { return new Response( JSON.stringify({ message: 'Nenhum SMS na fila', processed: 0 }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } 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, tenantErrors: errors }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) })