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:
@@ -25,7 +25,8 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { createClient, SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
import { type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
import { adminClient, listTenantSchemas } from '../_shared/tenant.ts'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -89,7 +90,6 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
|
||||
|
||||
interface ChannelRow {
|
||||
id: string
|
||||
tenant_id: string
|
||||
owner_id: string
|
||||
provider: string
|
||||
credentials: Record<string, string>
|
||||
@@ -98,7 +98,7 @@ interface ChannelRow {
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: Date): Promise<{
|
||||
async function checkOneChannel(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, channel: ChannelRow, now: Date): Promise<{
|
||||
tenant_id: string
|
||||
channel_id: string
|
||||
previous_status: string | null
|
||||
@@ -114,10 +114,10 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
|
||||
if (!apiUrl || !apiKey || !instance) {
|
||||
// Credencial incompleta — não alertamos, só marca error e segue
|
||||
await supa.from('notification_channels')
|
||||
await tdb.from('notification_channels')
|
||||
.update({ connection_status: 'error', last_health_check: now.toISOString() })
|
||||
.eq('id', channel.id)
|
||||
return { tenant_id: channel.tenant_id, channel_id: channel.id, previous_status: channel.connection_status, new_status: 'error', action: 'config_missing' }
|
||||
return { tenant_id: tenantId, channel_id: channel.id, previous_status: channel.connection_status, new_status: 'error', action: 'config_missing' }
|
||||
}
|
||||
|
||||
const base = rewriteForContainer(apiUrl)
|
||||
@@ -160,10 +160,10 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
if (firstUnhealthyAtRaw) delete newMeta.first_unhealthy_at
|
||||
patch.metadata = newMeta
|
||||
|
||||
await supa.from('notification_channels').update(patch).eq('id', channel.id)
|
||||
const { data: resolved } = await supa.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
|
||||
await tdb.from('notification_channels').update(patch).eq('id', channel.id)
|
||||
const { data: resolved } = await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
|
||||
return {
|
||||
tenant_id: channel.tenant_id,
|
||||
tenant_id: tenantId,
|
||||
channel_id: channel.id,
|
||||
previous_status: channel.connection_status,
|
||||
new_status: newStatus,
|
||||
@@ -177,13 +177,13 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
newMeta.first_unhealthy_at = now.toISOString()
|
||||
}
|
||||
patch.metadata = newMeta
|
||||
await supa.from('notification_channels').update(patch).eq('id', channel.id)
|
||||
await tdb.from('notification_channels').update(patch).eq('id', channel.id)
|
||||
|
||||
const minutesUnhealthy = firstUnhealthyAt ? (now.getTime() - firstUnhealthyAt.getTime()) / 60000 : 0
|
||||
|
||||
if (minutesUnhealthy < thresholdMinutes) {
|
||||
return {
|
||||
tenant_id: channel.tenant_id,
|
||||
tenant_id: tenantId,
|
||||
channel_id: channel.id,
|
||||
previous_status: channel.connection_status,
|
||||
new_status: newStatus,
|
||||
@@ -231,17 +231,17 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
cleanedMeta.heartbeat_reconnect_last_at = now.toISOString()
|
||||
cleanedMeta.heartbeat_reconnect_count = (Number(cleanedMeta.heartbeat_reconnect_count) || 0) + 1
|
||||
|
||||
await supa.from('notification_channels').update({
|
||||
await tdb.from('notification_channels').update({
|
||||
connection_status: 'connected',
|
||||
last_health_check: now.toISOString(),
|
||||
metadata: cleanedMeta
|
||||
}).eq('id', channel.id)
|
||||
|
||||
// Resolve qualquer incident aberto desse channel (caso tenha sobrado de ciclo anterior)
|
||||
await supa.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
|
||||
await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
|
||||
|
||||
return {
|
||||
tenant_id: channel.tenant_id,
|
||||
tenant_id: tenantId,
|
||||
channel_id: channel.id,
|
||||
previous_status: channel.connection_status,
|
||||
new_status: 'connected',
|
||||
@@ -256,7 +256,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
|
||||
// Marca tentativa (mesmo que falhou) pra respeitar o cooldown
|
||||
newMeta.heartbeat_reconnect_last_at = now.toISOString()
|
||||
await supa.from('notification_channels').update({ metadata: newMeta }).eq('id', channel.id)
|
||||
await tdb.from('notification_channels').update({ metadata: newMeta }).eq('id', channel.id)
|
||||
}
|
||||
|
||||
// Passou do threshold (e reconnect falhou / não tentou) — abre incident (idempotente)
|
||||
@@ -265,7 +265,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
...(fetchError ? { error: fetchError } : {}),
|
||||
reconnect_attempted: reconnectAttempted
|
||||
}
|
||||
const { data: incidentId, error: incidentErr } = await supa.rpc('whatsapp_heartbeat_open_incident', {
|
||||
const { data: incidentId, error: incidentErr } = await tdb.rpc('whatsapp_heartbeat_open_incident', {
|
||||
p_channel_id: channel.id,
|
||||
p_kind: kind,
|
||||
p_last_state: state || fetchError,
|
||||
@@ -274,7 +274,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
|
||||
if (incidentErr) {
|
||||
return {
|
||||
tenant_id: channel.tenant_id,
|
||||
tenant_id: tenantId,
|
||||
channel_id: channel.id,
|
||||
previous_status: channel.connection_status,
|
||||
new_status: newStatus,
|
||||
@@ -285,8 +285,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
const newIncidentId = incidentId as unknown as string
|
||||
|
||||
if (alertsEnabled && newIncidentId) {
|
||||
await notifyChannelStakeholders(supa, {
|
||||
tenant_id: channel.tenant_id,
|
||||
await notifyChannelStakeholders(tdb, admin, tenantId, {
|
||||
channel_owner_id: channel.owner_id,
|
||||
incident_id: newIncidentId,
|
||||
channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
|
||||
@@ -296,7 +295,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
}
|
||||
|
||||
return {
|
||||
tenant_id: channel.tenant_id,
|
||||
tenant_id: tenantId,
|
||||
channel_id: channel.id,
|
||||
previous_status: channel.connection_status,
|
||||
new_status: newStatus,
|
||||
@@ -306,16 +305,15 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyChannelStakeholders(supa: SupabaseClient, params: {
|
||||
tenant_id: string
|
||||
async function notifyChannelStakeholders(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, params: {
|
||||
channel_owner_id: string
|
||||
incident_id: string
|
||||
channel_display: string
|
||||
kind: string
|
||||
minutes_unhealthy: number
|
||||
}): Promise<void> {
|
||||
// Checa se já notificou esse incident
|
||||
const { data: incident } = await supa
|
||||
// Checa se já notificou esse incident (tenant → tdb)
|
||||
const { data: incident } = await tdb
|
||||
.from('whatsapp_connection_incidents')
|
||||
.select('notified_at, notification_count')
|
||||
.eq('id', params.incident_id)
|
||||
@@ -329,10 +327,11 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
|
||||
const userIds = new Set<string>()
|
||||
if (params.channel_owner_id) userIds.add(params.channel_owner_id)
|
||||
|
||||
const { data: admins } = await supa
|
||||
// tenant_members é GLOBAL → admin, mantém filtro por tenant_id
|
||||
const { data: admins } = await admin
|
||||
.from('tenant_members')
|
||||
.select('user_id')
|
||||
.eq('tenant_id', params.tenant_id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('role', ['clinic_admin', 'tenant_admin'])
|
||||
.eq('status', 'active')
|
||||
|
||||
@@ -353,7 +352,6 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
|
||||
|
||||
const rows = Array.from(userIds).map((uid) => ({
|
||||
owner_id: uid,
|
||||
tenant_id: params.tenant_id,
|
||||
type: 'system_alert',
|
||||
ref_id: params.incident_id,
|
||||
ref_table: 'whatsapp_connection_incidents',
|
||||
@@ -365,52 +363,63 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
|
||||
}
|
||||
}))
|
||||
|
||||
await supa.from('notifications').insert(rows)
|
||||
await supa.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
|
||||
// notifications é tenant → tdb (sem tenant_id no payload)
|
||||
await tdb.from('notifications').insert(rows)
|
||||
await tdb.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
const supa = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
|
||||
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||
)
|
||||
const admin = adminClient()
|
||||
|
||||
try {
|
||||
// Canal específico (on-demand via UI do tenant) ou varredura completa
|
||||
// Canal específico (on-demand via UI do tenant) ou varredura completa.
|
||||
// O channel_id existe em apenas um schema; aplicamos o filtro em cada
|
||||
// tenant e só o schema dono retorna a linha.
|
||||
const url = new URL(req.url)
|
||||
const singleChannelId = url.searchParams.get('channel_id')
|
||||
|
||||
let query = supa
|
||||
.from('notification_channels')
|
||||
.select('id, tenant_id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
|
||||
.eq('provider', 'evolution_api')
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null)
|
||||
const now = new Date()
|
||||
const tasks: Array<Promise<Awaited<ReturnType<typeof checkOneChannel>>>> = []
|
||||
|
||||
if (singleChannelId) query = query.eq('id', singleChannelId)
|
||||
for (const t of await listTenantSchemas(admin)) {
|
||||
const tdb = admin.schema(t.schema)
|
||||
|
||||
const { data: channels, error: fetchErr } = await query
|
||||
let query = tdb
|
||||
.from('notification_channels')
|
||||
.select('id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
|
||||
.eq('provider', 'evolution_api')
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('is_active', true)
|
||||
.is('deleted_at', null)
|
||||
|
||||
if (fetchErr) return json({ error: fetchErr.message }, 500)
|
||||
if (!channels || channels.length === 0) {
|
||||
if (singleChannelId) query = query.eq('id', singleChannelId)
|
||||
|
||||
const { data: channels, error: fetchErr } = await query
|
||||
if (fetchErr) {
|
||||
console.error(`[heartbeat] channels query error (tenant ${t.tenantId}):`, fetchErr.message)
|
||||
continue
|
||||
}
|
||||
for (const ch of channels || []) {
|
||||
tasks.push(
|
||||
checkOneChannel(tdb, admin, t.tenantId, ch as ChannelRow, now).catch((e) => ({
|
||||
tenant_id: t.tenantId,
|
||||
channel_id: (ch as ChannelRow).id,
|
||||
previous_status: (ch as ChannelRow).connection_status,
|
||||
new_status: 'error',
|
||||
action: 'fetch_error' as const,
|
||||
error: (e as Error).message
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return json({ checked: 0, results: [] })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const results = await Promise.all(
|
||||
channels.map((ch) => checkOneChannel(supa, ch as ChannelRow, now).catch((e) => ({
|
||||
tenant_id: (ch as ChannelRow).tenant_id,
|
||||
channel_id: (ch as ChannelRow).id,
|
||||
previous_status: (ch as ChannelRow).connection_status,
|
||||
new_status: 'error',
|
||||
action: 'fetch_error' as const,
|
||||
error: (e as Error).message
|
||||
})))
|
||||
)
|
||||
const results = await Promise.all(tasks)
|
||||
|
||||
const summary = {
|
||||
checked: results.length,
|
||||
|
||||
Reference in New Issue
Block a user