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