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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user