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
@@ -20,7 +20,8 @@
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId, listTenantSchemas } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -96,7 +97,7 @@ Deno.serve(async (req: Request) => {
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
try {
const payload = await req.json().catch(() => null) as { event_id?: string; old_status?: string; new_status?: string } | null
const payload = await req.json().catch(() => null) as { event_id?: string; tenant_id?: string; old_status?: string; new_status?: string } | null
const eventId = payload?.event_id
const newStatus = String(payload?.new_status || '').toLowerCase()
if (!eventId || !newStatus) return json({ ok: false, error: 'invalid_payload' }, 400)
@@ -104,51 +105,77 @@ Deno.serve(async (req: Request) => {
const templateKey = STATUS_TEMPLATE_MAP[newStatus]
if (!templateKey) return json({ ok: true, skipped: 'status_not_mapped', status: newStatus })
const supa = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const admin = adminClient()
// Carrega evento + paciente
const { data: ev, error: evErr } = await supa
.from('agenda_eventos')
.select('id, tenant_id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)')
.eq('id', eventId)
.maybeSingle()
// Resolve o schema do tenant dono do evento. Preferencialmente o trigger
// passa tenant_id no body; senão varremos os schemas até achar o evento.
const evSelect = 'id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)'
if (evErr || !ev) return json({ ok: false, error: 'event_not_found' }, 404)
let tdb: SupabaseClient | null = null
let tenantId: string | null = payload?.tenant_id ?? null
let ev: Record<string, unknown> | null = null
const pat = Array.isArray(ev.patients) ? ev.patients[0] : ev.patients
if (tenantId) {
tdb = await tenantDbForId(admin, tenantId)
const { data, error: evErr } = await tdb
.from('agenda_eventos')
.select(evSelect)
.eq('id', eventId)
.maybeSingle()
if (evErr || !data) return json({ ok: false, error: 'event_not_found' }, 404)
ev = data as Record<string, unknown>
} else {
// Fallback: descobre o tenant procurando o evento em cada schema.
for (const t of await listTenantSchemas(admin)) {
const cand = admin.schema(t.schema)
const { data } = await cand
.from('agenda_eventos')
.select(evSelect)
.eq('id', eventId)
.maybeSingle()
if (data) {
tdb = cand
tenantId = t.tenantId
ev = data as Record<string, unknown>
break
}
}
if (!tdb || !ev || !tenantId) return json({ ok: false, error: 'event_not_found' }, 404)
}
const evRow = ev as {
inicio_em: string
modalidade: string
patients: { id: string; nome_completo: string | null; telefone: string | null } | Array<{ id: string; nome_completo: string | null; telefone: string | null }>
}
const pat = Array.isArray(evRow.patients) ? evRow.patients[0] : evRow.patients
if (!pat?.telefone) return json({ ok: true, skipped: 'no_phone' })
const phone = normalizePhoneBR(pat.telefone)
if (!/^\d{10,15}$/.test(phone)) return json({ ok: true, skipped: 'invalid_phone' })
// Opt-out: respeita
const { data: optout } = await supa
// Opt-out: respeita (conversation_optouts é tenant → tdb)
const { data: optout } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', ev.tenant_id)
.eq('contact_number', phone)
.is('opted_in_at', null)
.maybeSingle()
if (optout) return json({ ok: true, skipped: 'opt_out' })
// Canal WhatsApp ativo
const { data: channel } = await supa
// Canal WhatsApp ativo (tenant → tdb)
const { data: channel } = await tdb
.from('notification_channels')
.select('id, provider, credentials')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (!channel) return json({ ok: true, skipped: 'no_active_channel' })
// Template (tenant-specific → global default)
const { data: tpl } = await supa
// Template (notification_templates é tenant → tdb; defaults já semeados no schema)
const { data: tpl } = await tdb
.from('notification_templates')
.select('body_text')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp')
.eq('key', templateKey)
.is('deleted_at', null)
@@ -156,29 +183,17 @@ Deno.serve(async (req: Request) => {
.limit(1)
.maybeSingle()
let body_text = tpl?.body_text
if (!body_text) {
const { data: def } = await supa
.from('notification_templates')
.select('body_text')
.eq('channel', 'whatsapp')
.eq('key', templateKey)
.is('tenant_id', null)
.is('deleted_at', null)
.eq('is_active', true)
.limit(1)
.maybeSingle()
body_text = def?.body_text
}
const body_text = tpl?.body_text
if (!body_text) return json({ ok: true, skipped: 'template_not_found', template_key: templateKey })
const { data: tenant } = await supa.from('tenants').select('name').eq('id', ev.tenant_id).maybeSingle()
// Nome da clínica (tenants é GLOBAL → admin)
const { data: tenant } = await admin.from('tenants').select('name').eq('id', tenantId).maybeSingle()
const text = renderTemplate(body_text, {
nome_paciente: pat.nome_completo || 'paciente',
data_sessao: fmtDate(ev.inicio_em),
hora_sessao: fmtTime(ev.inicio_em),
modalidade: ev.modalidade === 'online' ? 'online' : 'presencial',
data_sessao: fmtDate(evRow.inicio_em),
hora_sessao: fmtTime(evRow.inicio_em),
modalidade: evRow.modalidade === 'online' ? 'online' : 'presencial',
nome_clinica: tenant?.name || '',
status: newStatus
})
@@ -197,9 +212,8 @@ Deno.serve(async (req: Request) => {
const sendRes = await sendViaEvolution(creds.api_url, creds.api_key, creds.instance_name, phone, text)
if (!sendRes.ok) return json({ ok: false, error: `send_failed: ${sendRes.error}` }, 500)
// Registra conversa (sem log unique — transições podem acontecer várias vezes)
await supa.from('conversation_messages').insert({
tenant_id: ev.tenant_id,
// Registra conversa (conversation_messages é tenant → tdb, sem tenant_id)
await tdb.from('conversation_messages').insert({
patient_id: pat.id,
channel: 'whatsapp',
direction: 'outbound',