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
+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'
}