F4 schema-per-tenant: edge functions roteiam pro schema do tenant

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-06-13 08:44:09 -03:00
parent ba8348d4a6
commit 9b21642e15
27 changed files with 1291 additions and 835 deletions
@@ -24,7 +24,8 @@
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { createClient, type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId, listTenantSchemas, type TenantRef } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -181,10 +182,32 @@ Deno.serve(async (req: Request) => {
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')!
)
const supabaseAdmin = adminClient()
// ── schema-per-tenant: resolve um canal por id varrendo os schemas tenant ──
// As ações deprovision/suspend/reactivate/test_send recebem só channel_id e
// notification_channels vive no schema do tenant (sem coluna tenant_id). Sem o
// tenant_id no payload, varremos os schemas provisionados pra localizar o canal.
// Retorna { tdb, channel, ref } ou null se não achar em nenhum schema.
async function findChannelById(channelId: string): Promise<
{ tdb: SupabaseClient; channel: Record<string, unknown>; ref: TenantRef } | null
> {
const refs = await listTenantSchemas(supabaseAdmin)
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
const { data, error } = await tdb
.from('notification_channels')
.select('*')
.eq('id', channelId)
.maybeSingle()
if (error) {
console.warn(`[provision] busca canal em ${ref.schema}:`, error.message)
continue
}
if (data) return { tdb, channel: data as Record<string, unknown>, ref }
}
return null
}
// Verifica autenticação
const { data: { user }, error: authErr } = await supabase.auth.getUser()
@@ -232,12 +255,12 @@ Deno.serve(async (req: Request) => {
return !!data
}
// ── Busca canal do tenant ────────────────────────────────────────
// ── Busca canal do tenant (tabela tenant → schema do tenant) ─────
async function getChannel(tid: string) {
const { data, error } = await supabaseAdmin
const tdb = await tenantDbForId(supabaseAdmin, tid)
const { data, error } = await tdb
.from('notification_channels')
.select('*')
.eq('tenant_id', tid)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
@@ -290,6 +313,7 @@ Deno.serve(async (req: Request) => {
}
const ownerId = await getOwnerId(tenantId)
const tdb = await tenantDbForId(supabaseAdmin, tenantId)
// 1. Cria subconta Twilio
const subaccountData = await twilio.createSubaccount(
@@ -316,9 +340,8 @@ Deno.serve(async (req: Request) => {
const numData = await twilio.buyNumber(subSid, finalNumber, webhookUrl, subToken)
phoneSid = numData.sid as string
// 4. Salva no banco
// 4. Salva no banco (notification_channels é tabela tenant — sem tenant_id)
const channelData = {
tenant_id: tenantId,
owner_id: ownerId,
channel: 'whatsapp',
provider: 'twilio',
@@ -338,7 +361,7 @@ Deno.serve(async (req: Request) => {
let savedChannel
if (existing?.id) {
const { data, error } = await supabaseAdmin
const { data, error } = await tdb
.from('notification_channels')
.update(channelData)
.eq('id', existing.id)
@@ -347,7 +370,7 @@ Deno.serve(async (req: Request) => {
if (error) throw error
savedChannel = data
} else {
const { data, error } = await supabaseAdmin
const { data, error } = await tdb
.from('notification_channels')
.insert(channelData)
.select('*')
@@ -377,30 +400,27 @@ Deno.serve(async (req: Request) => {
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)
const found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { tdb, channel: ch } = found
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const subToken = ch.credentials?.subaccount_auth_token as string
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
// 1. Libera número
if (ch.twilio_phone_sid && subToken) {
try {
await twilio.releaseNumber(ch.twilio_subaccount_sid, ch.twilio_phone_sid, subToken)
await twilio.releaseNumber(ch.twilio_subaccount_sid as string, ch.twilio_phone_sid as string, subToken)
} catch (e) {
console.warn('[deprovision] Erro ao liberar número (ignorado):', e.message)
console.warn('[deprovision] Erro ao liberar número (ignorado):', (e as Error).message)
}
}
// 2. Fecha subconta
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, 'closed')
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid as string, 'closed')
// 3. Soft-delete do canal
const { error: delErr } = await supabaseAdmin
// 3. Soft-delete do canal (tabela tenant)
const { error: delErr } = await tdb
.from('notification_channels')
.update({
deleted_at: new Date().toISOString(),
@@ -427,18 +447,15 @@ Deno.serve(async (req: Request) => {
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)
const found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { tdb, channel: ch } = found
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const twilioStatus = action === 'suspend' ? 'suspended' : 'active'
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, twilioStatus)
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid as string, twilioStatus)
await supabaseAdmin
await tdb
.from('notification_channels')
.update({
is_active: action === 'reactivate',
@@ -468,70 +485,83 @@ Deno.serve(async (req: Request) => {
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
// schema-per-tenant: notification_channels/logs e twilio_subaccount_usage
// vivem no schema de cada tenant. Varremos todos os schemas provisionados
// (ou paramos no que contém channelId, se especificado).
const refs = await listTenantSchemas(supabaseAdmin)
const synced = []
for (const ch of channels ?? []) {
try {
const subToken = ch.credentials?.subaccount_auth_token as string
if (!subToken) continue
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
// 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')
// Canais twilio do schema (ou somente o especificado)
let chQuery = tdb
.from('notification_channels')
.select('id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl')
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
.not('twilio_subaccount_sid', 'is', null)
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)
if (channelId) chQuery = chQuery.eq('id', channelId)
// 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)
const { data: channels, error } = await chQuery
if (error) {
console.warn(`[sync_usage] canais em ${ref.schema}:`, error.message)
continue
}
for (const ch of channels ?? []) {
try {
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
if (!subToken) continue
// Busca mensagens enviadas no mês via notification_logs (tabela tenant)
const { data: logs } = await tdb
.from('notification_logs')
.select('status, estimated_cost_brl')
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.gte('created_at', startDate)
.lte('created_at', endDate + 'T23:59:59Z')
const sent = logs?.length ?? 0
const delivered = logs?.filter(l => l.status === 'delivered' || l.status === 'read').length ?? 0
const failed = logs?.filter(l => l.status === 'failed').length ?? 0
const costBrl = logs?.reduce((sum, l) => sum + parseFloat(String(l.estimated_cost_brl ?? 0)), 0) ?? 0
const costUsd = costBrl / usdBrlRate
const revBrl = sent * (ch.price_per_message_brl ?? 0)
// Upsert no twilio_subaccount_usage (tabela tenant)
const { error: upsertErr } = await tdb
.from('twilio_subaccount_usage')
.upsert({
channel_id: ch.id,
twilio_subaccount_sid: ch.twilio_subaccount_sid,
period_start: startDate,
period_end: endDate,
messages_sent: sent,
messages_delivered: delivered,
messages_failed: failed,
cost_usd: costUsd,
cost_brl: costBrl,
revenue_brl: revBrl,
usd_brl_rate: usdBrlRate,
synced_at: new Date().toISOString(),
}, {
onConflict: 'channel_id,period_start,period_end',
})
if (upsertErr) throw upsertErr
synced.push({ channel_id: ch.id, sent, delivered, failed })
} catch (e) {
console.warn(`[sync_usage] canal ${ch.id}:`, (e as Error).message)
}
}
// Se filtramos por channelId e já achamos, não precisa varrer o resto
if (channelId && synced.length > 0) break
}
return ok({ success: true, synced })
@@ -588,32 +618,29 @@ Deno.serve(async (req: Request) => {
// 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)
const found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { channel: ch, ref } = found
// Verifica permissão: próprio tenant ou admin
// Verifica permissão: próprio tenant (via ref.tenantId) ou admin
const admin = await isSaasAdmin()
if (!admin) {
const { data: member } = await supabaseAdmin
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', ch.tenant_id)
.eq('tenant_id', ref.tenantId)
.eq('user_id', user.id)
.maybeSingle()
if (!member) return err('Sem permissão', 403)
}
const subToken = ch.credentials?.subaccount_auth_token as string
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
if (!ch.twilio_subaccount_sid || !subToken) return err('Canal não provisionado', 400)
const result = await twilio.sendWhatsApp(
ch.twilio_subaccount_sid,
ch.twilio_subaccount_sid as string,
subToken,
ch.twilio_phone_number,
ch.twilio_phone_number as string,
toNumber,
message
)