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:
@@ -22,7 +22,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': '*',
|
||||
@@ -38,7 +39,6 @@ function json(body: unknown, status = 200) {
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
tenant_id: string
|
||||
enabled: boolean
|
||||
threshold_minutes: number
|
||||
respect_business_hours: boolean
|
||||
@@ -160,7 +160,7 @@ function businessMinutesElapsed(
|
||||
// Processamento por tenant
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise<{
|
||||
async function processRule(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, rule: Rule, now: Date): Promise<{
|
||||
tenant_id: string
|
||||
candidates: number
|
||||
opened: number
|
||||
@@ -170,11 +170,10 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
|
||||
// Query candidatas: threads onde:
|
||||
// - última mensagem é INBOUND
|
||||
// - (se assigned_only) assigned_to IS NOT NULL
|
||||
// Vou usar a view conversation_threads + filtro direction='inbound'.
|
||||
let query = supa
|
||||
// Vou usar a view conversation_threads (tenant) + filtro direction='inbound'.
|
||||
let query = tdb
|
||||
.from('conversation_threads')
|
||||
.select('tenant_id, thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction')
|
||||
.eq('tenant_id', rule.tenant_id)
|
||||
.select('thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction')
|
||||
.eq('last_message_direction', 'inbound')
|
||||
|
||||
if (rule.alert_scope === 'assigned_only') {
|
||||
@@ -183,7 +182,7 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
|
||||
|
||||
const { data: candidates, error } = await query
|
||||
if (error) {
|
||||
return { tenant_id: rule.tenant_id, candidates: 0, opened: 0, still_pending: 0, notified: 0, /* @ts-ignore */ error: error.message }
|
||||
return { tenant_id: tenantId, candidates: 0, opened: 0, still_pending: 0, notified: 0, /* @ts-ignore */ error: error.message }
|
||||
}
|
||||
|
||||
let opened = 0
|
||||
@@ -203,9 +202,9 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
|
||||
continue
|
||||
}
|
||||
|
||||
// Abre breach (idempotente)
|
||||
const { data: breachId, error: openErr } = await supa.rpc('sla_open_breach', {
|
||||
p_tenant_id: rule.tenant_id,
|
||||
// Abre breach (idempotente). RPC mantém p_tenant_id (F6 reescreve depois).
|
||||
const { data: breachId, error: openErr } = await admin.rpc('sla_open_breach', {
|
||||
p_tenant_id: tenantId,
|
||||
p_thread_key: row.thread_key,
|
||||
p_assigned_to: row.assigned_to,
|
||||
p_last_inbound_at: last,
|
||||
@@ -216,9 +215,8 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
|
||||
opened++
|
||||
|
||||
// Notificação (só se ainda não notificou esse breach)
|
||||
const didNotify = await notifyBreach(supa, {
|
||||
const didNotify = await notifyBreach(tdb, admin, tenantId, {
|
||||
breach_id: breachId as unknown as string,
|
||||
tenant_id: rule.tenant_id,
|
||||
thread_key: row.thread_key,
|
||||
patient_name: row.patient_name || row.contact_number || 'Paciente desconhecido',
|
||||
assigned_to: row.assigned_to as string | null,
|
||||
@@ -229,12 +227,11 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
|
||||
if (didNotify) notified++
|
||||
}
|
||||
|
||||
return { tenant_id: rule.tenant_id, candidates: (candidates || []).length, opened, still_pending: stillPending, notified }
|
||||
return { tenant_id: tenantId, candidates: (candidates || []).length, opened, still_pending: stillPending, notified }
|
||||
}
|
||||
|
||||
async function notifyBreach(supa: SupabaseClient, params: {
|
||||
async function notifyBreach(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, params: {
|
||||
breach_id: string
|
||||
tenant_id: string
|
||||
thread_key: string
|
||||
patient_name: string
|
||||
assigned_to: string | null
|
||||
@@ -242,8 +239,8 @@ async function notifyBreach(supa: SupabaseClient, params: {
|
||||
elapsed_minutes: number
|
||||
threshold_minutes: number
|
||||
}): Promise<boolean> {
|
||||
// Anti-spam: não renotifica se já notificou
|
||||
const { data: breach } = await supa
|
||||
// Anti-spam: não renotifica se já notificou (breach é tenant → tdb)
|
||||
const { data: breach } = await tdb
|
||||
.from('conversation_sla_breaches')
|
||||
.select('notified_at')
|
||||
.eq('id', params.breach_id)
|
||||
@@ -255,10 +252,11 @@ async function notifyBreach(supa: SupabaseClient, params: {
|
||||
if (params.assigned_to) userIds.add(params.assigned_to)
|
||||
|
||||
if (params.notify_admin) {
|
||||
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')
|
||||
for (const a of admins || []) userIds.add(a.user_id)
|
||||
@@ -271,7 +269,6 @@ async function notifyBreach(supa: SupabaseClient, params: {
|
||||
|
||||
const rows = Array.from(userIds).map((uid) => ({
|
||||
owner_id: uid,
|
||||
tenant_id: params.tenant_id,
|
||||
type: 'system_alert',
|
||||
ref_id: params.breach_id,
|
||||
ref_table: 'conversation_sla_breaches',
|
||||
@@ -285,10 +282,11 @@ async function notifyBreach(supa: SupabaseClient, params: {
|
||||
}
|
||||
}))
|
||||
|
||||
const { error: insertErr } = await supa.from('notifications').insert(rows)
|
||||
// notifications é tenant → tdb (sem tenant_id no payload)
|
||||
const { error: insertErr } = await tdb.from('notifications').insert(rows)
|
||||
if (insertErr) return false
|
||||
|
||||
await supa.rpc('sla_mark_notified', { p_breach_id: params.breach_id })
|
||||
await admin.rpc('sla_mark_notified', { p_breach_id: params.breach_id })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -299,31 +297,41 @@ async function notifyBreach(supa: SupabaseClient, params: {
|
||||
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 {
|
||||
const now = new Date()
|
||||
|
||||
// Regras habilitadas
|
||||
const { data: rules, error: rulesErr } = await supa
|
||||
.from('conversation_sla_rules')
|
||||
.select('tenant_id, enabled, threshold_minutes, respect_business_hours, business_hours_start, business_hours_end, business_days, alert_scope, notify_admin_on_breach')
|
||||
.eq('enabled', true)
|
||||
// Varre todos os tenants; cada schema tem suas próprias sla_rules (tenant)
|
||||
const tasks: Array<Promise<{ tenant_id: string; candidates: number; opened: number; still_pending: number; notified: number }>> = []
|
||||
|
||||
if (rulesErr) return json({ error: rulesErr.message }, 500)
|
||||
if (!rules || rules.length === 0) return json({ checked: 0, results: [] })
|
||||
for (const t of await listTenantSchemas(admin)) {
|
||||
const tdb = admin.schema(t.schema)
|
||||
|
||||
const results = await Promise.all(
|
||||
rules.map((r) => processRule(supa, r as Rule, now).catch((e) => ({
|
||||
tenant_id: (r as Rule).tenant_id,
|
||||
candidates: 0, opened: 0, still_pending: 0, notified: 0,
|
||||
error: (e as Error).message
|
||||
})))
|
||||
)
|
||||
// Regras habilitadas do tenant (tabela tenant → tdb, sem tenant_id)
|
||||
const { data: rules, error: rulesErr } = await tdb
|
||||
.from('conversation_sla_rules')
|
||||
.select('enabled, threshold_minutes, respect_business_hours, business_hours_start, business_hours_end, business_days, alert_scope, notify_admin_on_breach')
|
||||
.eq('enabled', true)
|
||||
|
||||
if (rulesErr) {
|
||||
console.error(`[sla] rules query error (tenant ${t.tenantId}):`, rulesErr.message)
|
||||
continue
|
||||
}
|
||||
for (const r of rules || []) {
|
||||
tasks.push(
|
||||
processRule(tdb, admin, t.tenantId, r as Rule, now).catch((e) => ({
|
||||
tenant_id: t.tenantId,
|
||||
candidates: 0, opened: 0, still_pending: 0, notified: 0,
|
||||
error: (e as Error).message
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0) return json({ checked: 0, results: [] })
|
||||
|
||||
const results = await Promise.all(tasks)
|
||||
|
||||
const summary = {
|
||||
checked: results.length,
|
||||
|
||||
Reference in New Issue
Block a user