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
+101
View File
@@ -0,0 +1,101 @@
/*
|--------------------------------------------------------------------------
| Agência PSI — Edge Functions: helper schema-per-tenant
|--------------------------------------------------------------------------
| As tabelas tenant-scoped vivem em schemas físicos `tenant_<slug>` SEM a
| coluna tenant_id (docs/F0_categorizacao.md). Edge functions resolvem o
| schema a partir do tenant_id (que já chega via URL/body/linha) e usam
| `tdb.from(...)` para tabelas tenant. Tabelas GLOBAIS (tenants,
| tenant_members, profiles, subscriptions, addon_*, whatsapp_credit_*,
| channel_routing, audit_logs...) e RPCs continuam via o client público.
|
| Como edge functions usam service_role, `.schema(x)` exige que o schema
| esteja exposto no PostgREST (config.toml, F5). Schemas tenant entram lá
| na criação do tenant.
|--------------------------------------------------------------------------
*/
import { createClient, type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
/** Espelha public.tenant_schema_name(slug). */
export function tenantSchemaName(slug: string | null | undefined): string | null {
if (typeof slug !== 'string') return null
if (!/^[a-z][a-z0-9_]{1,47}$/.test(slug)) return null
return `tenant_${slug}`
}
/** Client service_role no schema public (tabelas globais + RPCs). */
export function adminClient(): SupabaseClient {
return createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
}
/** tenant_id -> nome do schema (via tenants.slug). null se não existir. */
export async function schemaForTenant(admin: SupabaseClient, tenantId: string): Promise<string | null> {
if (!tenantId) return null
const { data, error } = await admin.from('tenants').select('slug').eq('id', tenantId).maybeSingle()
if (error) {
console.error('[tenant] schemaForTenant erro:', error.message)
return null
}
return tenantSchemaName(data?.slug ?? null)
}
/**
* tenant_id -> client ligado ao schema do tenant (para tabelas tenant).
* Lança se o tenant não existir/sem slug — chamar tabela tenant sem schema é bug.
*/
export async function tenantDbForId(admin: SupabaseClient, tenantId: string): Promise<SupabaseClient> {
const schema = await schemaForTenant(admin, tenantId)
if (!schema) throw new Error(`[tenant] schema indisponível para tenant ${tenantId}`)
return admin.schema(schema)
}
export type TenantRef = { tenantId: string; slug: string; schema: string }
/** Lista tenants ativos com schema provisionado — base dos crons que varrem todos. */
export async function listTenantSchemas(admin: SupabaseClient): Promise<TenantRef[]> {
// tenant_schemas é populada por clone_tenant_template (F1/F2); join garante slug atual
const { data, error } = await admin
.from('tenant_schemas')
.select('tenant_id, schema_name, tenants!inner(slug)')
if (error) {
console.error('[tenant] listTenantSchemas erro:', error.message)
return []
}
return (data ?? [])
.map((r: Record<string, unknown>) => {
const slug = (r.tenants as { slug?: string } | null)?.slug ?? null
const schema = tenantSchemaName(slug)
return schema ? { tenantId: r.tenant_id as string, slug: slug as string, schema } : null
})
.filter((x): x is TenantRef => x !== null)
}
/**
* Roteia um webhook inbound -> tenant, via public.channel_routing.
* Usado quando a function NÃO recebe tenant_id na URL (ex.: Meta Cloud API,
* que identifica o canal por phone_number_id). Os webhooks Twilio/Evolution
* deste projeto recebem tenant_id na própria URL e NÃO precisam disto.
*/
export async function resolveTenantByChannel(
admin: SupabaseClient,
keys: { senderAddress?: string | null; twilioPhone?: string | null; twilioSid?: string | null },
): Promise<(TenantRef & { channelId: string }) | null> {
let q = admin.from('channel_routing').select('channel_id, tenant_id, tenants!inner(slug)').limit(1)
if (keys.twilioSid) q = q.eq('twilio_subaccount_sid', keys.twilioSid)
else if (keys.twilioPhone) q = q.eq('twilio_phone_number', keys.twilioPhone)
else if (keys.senderAddress) q = q.eq('sender_address', keys.senderAddress)
else return null
const { data, error } = await q.maybeSingle()
if (error || !data) {
if (error) console.error('[tenant] resolveTenantByChannel erro:', error.message)
return null
}
const slug = (data.tenants as { slug?: string } | null)?.slug ?? null
const schema = tenantSchemaName(slug)
if (!schema) return null
return { tenantId: data.tenant_id as string, slug: slug as string, schema, channelId: data.channel_id as string }
}
+49 -61
View File
@@ -6,6 +6,11 @@
| e twilio-whatsapp-inbound. Cada provider injeta seu proprio SendFn —
| Evolution envia direto via API (sem deducao de credito), Twilio envolve
| o envio em deducao atomica com rollback.
|
| Schema-per-tenant: tabelas tenant (conversation_*, agenda_regras_semanais)
| são acessadas via `tdb` (client ligado ao schema tenant_<slug>, SEM coluna
| tenant_id). RPCs de crédito (deduct/add_whatsapp_credits) e tenant_members
| são globais → usam `supa` (public) + p_tenant_id explícito.
|--------------------------------------------------------------------------
*/
@@ -32,22 +37,21 @@ export function normalizeForMatch(s: string | null | undefined): string {
}
// ═══════════════════════════════════════════════════════════════════════
// Opt-out (LGPD)
// Opt-out (LGPD) — tabelas tenant via `tdb`
// ═══════════════════════════════════════════════════════════════════════
export async function detectOptoutKeyword(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
body: string | null
): Promise<string | null> {
if (!body) return null
const normalized = normalizeForMatch(body)
if (!normalized) return null
const { data } = await supa
// keywords de sistema + do tenant já vivem no schema do tenant (seed do template)
const { data } = await tdb
.from('conversation_optout_keywords')
.select('keyword')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
.eq('enabled', true)
if (!data || !data.length) return null
@@ -62,11 +66,10 @@ export async function detectOptoutKeyword(
return null
}
export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone: string): Promise<boolean> {
const { data } = await supa
export async function isOptedOut(tdb: SupabaseClient, phone: string): Promise<boolean> {
const { data } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.limit(1)
@@ -76,8 +79,7 @@ export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone:
const OPT_IN_KEYWORDS = ['voltar', 'retornar', 'reativar', 'restart']
export async function maybeOptIn(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
phone: string,
body: string | null
): Promise<boolean> {
@@ -86,10 +88,9 @@ export async function maybeOptIn(
if (!normalized) return false
for (const kw of OPT_IN_KEYWORDS) {
if (normalized === kw || new RegExp(`(^|\\s)${kw}(\\s|$)`).test(normalized)) {
const { data } = await supa
const { data } = await tdb
.from('conversation_optouts')
.update({ opted_back_in_at: new Date().toISOString() })
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.select('id')
@@ -101,8 +102,7 @@ export async function maybeOptIn(
}
export async function registerOptout(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
phone: string,
patientId: string | null,
originalMessage: string | null,
@@ -110,18 +110,16 @@ export async function registerOptout(
provider: ProviderLabel,
sendFn: SendFn
): Promise<void> {
const { data: existing } = await supa
const { data: existing } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.maybeSingle()
if (existing) return
await supa.from('conversation_optouts').insert({
tenant_id: tenantId,
await tdb.from('conversation_optouts').insert({
phone,
patient_id: patientId,
source: 'keyword',
@@ -133,8 +131,7 @@ export async function registerOptout(
try {
const res = await sendFn(phone, ackText)
if (res.ok) {
await supa.from('conversation_messages').insert({
tenant_id: tenantId,
await tdb.from('conversation_messages').insert({
patient_id: patientId,
channel: 'whatsapp',
direction: 'outbound',
@@ -156,7 +153,7 @@ export async function registerOptout(
}
// ═══════════════════════════════════════════════════════════════════════
// Auto-reply (schedule-aware, cooldown, respeita opt-out)
// Auto-reply (schedule-aware, cooldown, respeita opt-out) — via `tdb`
// ═══════════════════════════════════════════════════════════════════════
export type ScheduleWindow = { dow: number; start: string; end: string }
@@ -197,11 +194,10 @@ function isWithinWindows(windows: ScheduleWindow[]): boolean {
return false
}
async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promise<ScheduleWindow[]> {
const { data, error } = await supa
async function windowsFromAgenda(tdb: SupabaseClient): Promise<ScheduleWindow[]> {
const { data, error } = await tdb
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true)
if (error || !data) return []
return data.map((r) => ({
@@ -212,8 +208,7 @@ async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promis
}
export async function maybeSendAutoReply(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
threadKey: string,
fromPhone: string | null,
provider: ProviderLabel,
@@ -221,21 +216,20 @@ export async function maybeSendAutoReply(
): Promise<{ sent: boolean; reason?: string }> {
if (!fromPhone) return { sent: false, reason: 'no_phone' }
if (await isOptedOut(supa, tenantId, fromPhone)) {
if (await isOptedOut(tdb, fromPhone)) {
return { sent: false, reason: 'opted_out' }
}
const { data: settings } = await supa
const { data: settings } = await tdb
.from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle()
if (!settings || !settings.enabled) return { sent: false, reason: 'disabled' }
let withinHours = false
if (settings.schedule_mode === 'agenda') {
const windows = await windowsFromAgenda(supa, tenantId)
const windows = await windowsFromAgenda(tdb)
withinHours = isWithinWindows(windows)
} else if (settings.schedule_mode === 'business_hours') {
withinHours = isWithinWindows((settings.business_hours as ScheduleWindow[]) || [])
@@ -247,10 +241,9 @@ export async function maybeSendAutoReply(
if ((settings.cooldown_minutes ?? 0) > 0) {
const cutoff = new Date(Date.now() - settings.cooldown_minutes * 60 * 1000).toISOString()
const { data: recent } = await supa
const { data: recent } = await tdb
.from('conversation_autoreply_log')
.select('sent_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.gte('sent_at', cutoff)
.order('sent_at', { ascending: false })
@@ -265,8 +258,7 @@ export async function maybeSendAutoReply(
return { sent: false, reason: 'send_failed' }
}
await supa.from('conversation_messages').insert({
tenant_id: tenantId,
await tdb.from('conversation_messages').insert({
channel: 'whatsapp',
direction: 'outbound',
from_number: null,
@@ -279,8 +271,7 @@ export async function maybeSendAutoReply(
responded_at: new Date().toISOString()
})
await supa.from('conversation_autoreply_log').insert({
tenant_id: tenantId,
await tdb.from('conversation_autoreply_log').insert({
thread_key: threadKey
})
@@ -289,6 +280,7 @@ export async function maybeSendAutoReply(
// ═══════════════════════════════════════════════════════════════════════
// Twilio: send wrapper com deducao de credito + rollback
// Créditos são GLOBAIS (addon) → RPC via `supa` (public) + p_tenant_id.
// ═══════════════════════════════════════════════════════════════════════
export type TwilioChannel = {
@@ -333,7 +325,7 @@ async function sendViaTwilioRaw(
}
// Cria SendFn que:
// 1) deduz 1 credito do tenant via RPC atomica
// 1) deduz 1 credito do tenant via RPC atomica (GLOBAL)
// 2) envia via Twilio; se falhar, refunda o credito
// 3) retorna resultado ao caller
export function makeTwilioCreditedSendFn(
@@ -373,6 +365,7 @@ export function makeTwilioCreditedSendFn(
// ════════════════════════════════════════════════════════════════════════════
// Bot de auto-triagem (3.7)
// Tabelas conversation_* via `tdb`; pickAnyAdmin (tenant_members) via `supa`.
// ════════════════════════════════════════════════════════════════════════════
type BotStep = { prompt: string; variable: string; type?: string }
@@ -397,6 +390,7 @@ type BotConfig = {
* - não é opt-out
*/
export async function maybeProcessBot(
tdb: SupabaseClient,
supa: SupabaseClient,
tenantId: string,
threadKey: string,
@@ -408,10 +402,9 @@ export async function maybeProcessBot(
const text = String(body || '').trim()
// Carrega config
const { data: cfg } = await supa
const { data: cfg } = await tdb
.from('conversation_bots')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle()
if (!cfg || !cfg.enabled) return { processed: false, reason: 'disabled' }
@@ -421,39 +414,36 @@ export async function maybeProcessBot(
}
// Busca sessão ativa
const { data: active } = await supa
const { data: active } = await tdb
.from('conversation_bot_sessions')
.select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.eq('status', 'active')
.maybeSingle()
if (active) {
// Se humano já atribuiu a thread, abandona bot
const { data: assign } = await supa
const { data: assign } = await tdb
.from('conversation_assignments')
.select('assigned_to')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.maybeSingle()
if (assign?.assigned_to) {
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({ status: 'abandoned_manual', abandoned_at: new Date().toISOString() })
.eq('id', active.id)
return { processed: false, reason: 'human_took_over' }
}
return await advanceSession(supa, config, active, text, phone, sendFn)
return await advanceSession(tdb, supa, tenantId, config, active, text, phone, sendFn)
}
// Sem sessão ativa — decide se inicia
if (config.trigger_mode === 'new_contact') {
// Inicia só se ainda não existe nenhuma sessão (completada ou abandonada) pra essa thread
const { data: prev } = await supa
const { data: prev } = await tdb
.from('conversation_bot_sessions')
.select('id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.limit(1)
.maybeSingle()
@@ -468,21 +458,19 @@ export async function maybeProcessBot(
// 'all_unassigned' passa direto
// Inicia nova sessão
return await startSession(supa, config, tenantId, threadKey, phone, sendFn)
return await startSession(tdb, config, threadKey, phone, sendFn)
}
async function startSession(
supa: SupabaseClient,
tdb: SupabaseClient,
config: BotConfig,
tenantId: string,
threadKey: string,
phone: string,
sendFn: SendFn
): Promise<{ processed: boolean; status?: string; step?: number }> {
const { data: session, error: sessErr } = await supa
const { data: session, error: sessErr } = await tdb
.from('conversation_bot_sessions')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
contact_number: phone,
current_step: 0,
@@ -503,9 +491,11 @@ async function startSession(
}
async function advanceSession(
tdb: SupabaseClient,
supa: SupabaseClient,
tenantId: string,
config: BotConfig,
session: { id: string, current_step: number, collected_data: Record<string, unknown>, tenant_id: string, thread_key: string, contact_number: string | null },
session: { id: string, current_step: number, collected_data: Record<string, unknown>, thread_key: string, contact_number: string | null },
text: string,
phone: string,
sendFn: SendFn
@@ -514,7 +504,7 @@ async function advanceSession(
const currentStep = config.steps[step]
if (!currentStep) {
// Segurança: step fora do range → encerra
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({ status: 'completed', completed_at: new Date().toISOString() })
.eq('id', session.id)
return { processed: true, status: 'completed', step }
@@ -527,7 +517,7 @@ async function advanceSession(
if (isLast) {
// Finaliza
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({
collected_data: newData,
current_step: nextStep,
@@ -547,13 +537,12 @@ async function advanceSession(
return `${s.variable}: ${val}`
})
const summary = `🤖 Triagem automática concluída:\n\n${lines.join('\n')}`
await supa.from('conversation_notes').insert({
tenant_id: session.tenant_id,
await tdb.from('conversation_notes').insert({
thread_key: session.thread_key,
contact_number: session.contact_number,
body: summary,
// created_by obrigatório — usa um user "bot" fictício? Não temos. Pega qualquer admin.
created_by: await pickAnyAdmin(supa, session.tenant_id)
// created_by obrigatório — usa qualquer admin do tenant (tenant_members é global)
created_by: await pickAnyAdmin(supa, tenantId)
})
} catch (err) {
console.warn('[bot] failed to create summary note:', (err as Error)?.message)
@@ -563,7 +552,7 @@ async function advanceSession(
}
// Avança pra próxima pergunta
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({
collected_data: newData,
current_step: nextStep,
@@ -588,4 +577,3 @@ async function pickAnyAdmin(supa: SupabaseClient, tenantId: string): Promise<str
.maybeSingle()
return (data?.user_id as string) ?? '00000000-0000-0000-0000-000000000000'
}