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
@@ -476,7 +476,7 @@ describe('onSendManualReminder', () => {
_functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null }); _functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null });
const { onSendManualReminder, toast, sendingReminder } = setup({ composer }); const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
await onSendManualReminder(); await onSendManualReminder();
expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1' } }); expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1', tenant_id: 'tenant-1' } });
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' })); expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' }));
expect(sendingReminder.value).toBe(false); expect(sendingReminder.value).toBe(false);
}); });
@@ -471,7 +471,7 @@ export function useAgendaEventLifecycle({
sendingReminder.value = true; sendingReminder.value = true;
try { try {
const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', { const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', {
body: { event_id: composer.form.value.id } body: { event_id: composer.form.value.id, tenant_id: props.tenantId }
}); });
if (error || !data?.ok) { if (error || !data?.ok) {
const err = data?.error || error?.message || 'unknown_error'; const err = data?.error || error?.message || 'unknown_error';
+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 — | e twilio-whatsapp-inbound. Cada provider injeta seu proprio SendFn —
| Evolution envia direto via API (sem deducao de credito), Twilio envolve | Evolution envia direto via API (sem deducao de credito), Twilio envolve
| o envio em deducao atomica com rollback. | 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( export async function detectOptoutKeyword(
supa: SupabaseClient, tdb: SupabaseClient,
tenantId: string,
body: string | null body: string | null
): Promise<string | null> { ): Promise<string | null> {
if (!body) return null if (!body) return null
const normalized = normalizeForMatch(body) const normalized = normalizeForMatch(body)
if (!normalized) return null 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') .from('conversation_optout_keywords')
.select('keyword') .select('keyword')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
.eq('enabled', true) .eq('enabled', true)
if (!data || !data.length) return null if (!data || !data.length) return null
@@ -62,11 +66,10 @@ export async function detectOptoutKeyword(
return null return null
} }
export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone: string): Promise<boolean> { export async function isOptedOut(tdb: SupabaseClient, phone: string): Promise<boolean> {
const { data } = await supa const { data } = await tdb
.from('conversation_optouts') .from('conversation_optouts')
.select('id') .select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone) .eq('phone', phone)
.is('opted_back_in_at', null) .is('opted_back_in_at', null)
.limit(1) .limit(1)
@@ -76,8 +79,7 @@ export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone:
const OPT_IN_KEYWORDS = ['voltar', 'retornar', 'reativar', 'restart'] const OPT_IN_KEYWORDS = ['voltar', 'retornar', 'reativar', 'restart']
export async function maybeOptIn( export async function maybeOptIn(
supa: SupabaseClient, tdb: SupabaseClient,
tenantId: string,
phone: string, phone: string,
body: string | null body: string | null
): Promise<boolean> { ): Promise<boolean> {
@@ -86,10 +88,9 @@ export async function maybeOptIn(
if (!normalized) return false if (!normalized) return false
for (const kw of OPT_IN_KEYWORDS) { for (const kw of OPT_IN_KEYWORDS) {
if (normalized === kw || new RegExp(`(^|\\s)${kw}(\\s|$)`).test(normalized)) { if (normalized === kw || new RegExp(`(^|\\s)${kw}(\\s|$)`).test(normalized)) {
const { data } = await supa const { data } = await tdb
.from('conversation_optouts') .from('conversation_optouts')
.update({ opted_back_in_at: new Date().toISOString() }) .update({ opted_back_in_at: new Date().toISOString() })
.eq('tenant_id', tenantId)
.eq('phone', phone) .eq('phone', phone)
.is('opted_back_in_at', null) .is('opted_back_in_at', null)
.select('id') .select('id')
@@ -101,8 +102,7 @@ export async function maybeOptIn(
} }
export async function registerOptout( export async function registerOptout(
supa: SupabaseClient, tdb: SupabaseClient,
tenantId: string,
phone: string, phone: string,
patientId: string | null, patientId: string | null,
originalMessage: string | null, originalMessage: string | null,
@@ -110,18 +110,16 @@ export async function registerOptout(
provider: ProviderLabel, provider: ProviderLabel,
sendFn: SendFn sendFn: SendFn
): Promise<void> { ): Promise<void> {
const { data: existing } = await supa const { data: existing } = await tdb
.from('conversation_optouts') .from('conversation_optouts')
.select('id') .select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone) .eq('phone', phone)
.is('opted_back_in_at', null) .is('opted_back_in_at', null)
.maybeSingle() .maybeSingle()
if (existing) return if (existing) return
await supa.from('conversation_optouts').insert({ await tdb.from('conversation_optouts').insert({
tenant_id: tenantId,
phone, phone,
patient_id: patientId, patient_id: patientId,
source: 'keyword', source: 'keyword',
@@ -133,8 +131,7 @@ export async function registerOptout(
try { try {
const res = await sendFn(phone, ackText) const res = await sendFn(phone, ackText)
if (res.ok) { if (res.ok) {
await supa.from('conversation_messages').insert({ await tdb.from('conversation_messages').insert({
tenant_id: tenantId,
patient_id: patientId, patient_id: patientId,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', 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 } export type ScheduleWindow = { dow: number; start: string; end: string }
@@ -197,11 +194,10 @@ function isWithinWindows(windows: ScheduleWindow[]): boolean {
return false return false
} }
async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promise<ScheduleWindow[]> { async function windowsFromAgenda(tdb: SupabaseClient): Promise<ScheduleWindow[]> {
const { data, error } = await supa const { data, error } = await tdb
.from('agenda_regras_semanais') .from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo') .select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true) .eq('ativo', true)
if (error || !data) return [] if (error || !data) return []
return data.map((r) => ({ return data.map((r) => ({
@@ -212,8 +208,7 @@ async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promis
} }
export async function maybeSendAutoReply( export async function maybeSendAutoReply(
supa: SupabaseClient, tdb: SupabaseClient,
tenantId: string,
threadKey: string, threadKey: string,
fromPhone: string | null, fromPhone: string | null,
provider: ProviderLabel, provider: ProviderLabel,
@@ -221,21 +216,20 @@ export async function maybeSendAutoReply(
): Promise<{ sent: boolean; reason?: string }> { ): Promise<{ sent: boolean; reason?: string }> {
if (!fromPhone) return { sent: false, reason: 'no_phone' } 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' } return { sent: false, reason: 'opted_out' }
} }
const { data: settings } = await supa const { data: settings } = await tdb
.from('conversation_autoreply_settings') .from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window') .select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle() .maybeSingle()
if (!settings || !settings.enabled) return { sent: false, reason: 'disabled' } if (!settings || !settings.enabled) return { sent: false, reason: 'disabled' }
let withinHours = false let withinHours = false
if (settings.schedule_mode === 'agenda') { if (settings.schedule_mode === 'agenda') {
const windows = await windowsFromAgenda(supa, tenantId) const windows = await windowsFromAgenda(tdb)
withinHours = isWithinWindows(windows) withinHours = isWithinWindows(windows)
} else if (settings.schedule_mode === 'business_hours') { } else if (settings.schedule_mode === 'business_hours') {
withinHours = isWithinWindows((settings.business_hours as ScheduleWindow[]) || []) withinHours = isWithinWindows((settings.business_hours as ScheduleWindow[]) || [])
@@ -247,10 +241,9 @@ export async function maybeSendAutoReply(
if ((settings.cooldown_minutes ?? 0) > 0) { if ((settings.cooldown_minutes ?? 0) > 0) {
const cutoff = new Date(Date.now() - settings.cooldown_minutes * 60 * 1000).toISOString() 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') .from('conversation_autoreply_log')
.select('sent_at') .select('sent_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.gte('sent_at', cutoff) .gte('sent_at', cutoff)
.order('sent_at', { ascending: false }) .order('sent_at', { ascending: false })
@@ -265,8 +258,7 @@ export async function maybeSendAutoReply(
return { sent: false, reason: 'send_failed' } return { sent: false, reason: 'send_failed' }
} }
await supa.from('conversation_messages').insert({ await tdb.from('conversation_messages').insert({
tenant_id: tenantId,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
from_number: null, from_number: null,
@@ -279,8 +271,7 @@ export async function maybeSendAutoReply(
responded_at: new Date().toISOString() responded_at: new Date().toISOString()
}) })
await supa.from('conversation_autoreply_log').insert({ await tdb.from('conversation_autoreply_log').insert({
tenant_id: tenantId,
thread_key: threadKey thread_key: threadKey
}) })
@@ -289,6 +280,7 @@ export async function maybeSendAutoReply(
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Twilio: send wrapper com deducao de credito + rollback // Twilio: send wrapper com deducao de credito + rollback
// Créditos são GLOBAIS (addon) → RPC via `supa` (public) + p_tenant_id.
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
export type TwilioChannel = { export type TwilioChannel = {
@@ -333,7 +325,7 @@ async function sendViaTwilioRaw(
} }
// Cria SendFn que: // 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 // 2) envia via Twilio; se falhar, refunda o credito
// 3) retorna resultado ao caller // 3) retorna resultado ao caller
export function makeTwilioCreditedSendFn( export function makeTwilioCreditedSendFn(
@@ -373,6 +365,7 @@ export function makeTwilioCreditedSendFn(
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// Bot de auto-triagem (3.7) // Bot de auto-triagem (3.7)
// Tabelas conversation_* via `tdb`; pickAnyAdmin (tenant_members) via `supa`.
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
type BotStep = { prompt: string; variable: string; type?: string } type BotStep = { prompt: string; variable: string; type?: string }
@@ -397,6 +390,7 @@ type BotConfig = {
* - não é opt-out * - não é opt-out
*/ */
export async function maybeProcessBot( export async function maybeProcessBot(
tdb: SupabaseClient,
supa: SupabaseClient, supa: SupabaseClient,
tenantId: string, tenantId: string,
threadKey: string, threadKey: string,
@@ -408,10 +402,9 @@ export async function maybeProcessBot(
const text = String(body || '').trim() const text = String(body || '').trim()
// Carrega config // Carrega config
const { data: cfg } = await supa const { data: cfg } = await tdb
.from('conversation_bots') .from('conversation_bots')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.maybeSingle() .maybeSingle()
if (!cfg || !cfg.enabled) return { processed: false, reason: 'disabled' } if (!cfg || !cfg.enabled) return { processed: false, reason: 'disabled' }
@@ -421,39 +414,36 @@ export async function maybeProcessBot(
} }
// Busca sessão ativa // Busca sessão ativa
const { data: active } = await supa const { data: active } = await tdb
.from('conversation_bot_sessions') .from('conversation_bot_sessions')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.eq('status', 'active') .eq('status', 'active')
.maybeSingle() .maybeSingle()
if (active) { if (active) {
// Se humano já atribuiu a thread, abandona bot // Se humano já atribuiu a thread, abandona bot
const { data: assign } = await supa const { data: assign } = await tdb
.from('conversation_assignments') .from('conversation_assignments')
.select('assigned_to') .select('assigned_to')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.maybeSingle() .maybeSingle()
if (assign?.assigned_to) { 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() }) .update({ status: 'abandoned_manual', abandoned_at: new Date().toISOString() })
.eq('id', active.id) .eq('id', active.id)
return { processed: false, reason: 'human_took_over' } 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 // Sem sessão ativa — decide se inicia
if (config.trigger_mode === 'new_contact') { if (config.trigger_mode === 'new_contact') {
// Inicia só se ainda não existe nenhuma sessão (completada ou abandonada) pra essa thread // 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') .from('conversation_bot_sessions')
.select('id') .select('id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey) .eq('thread_key', threadKey)
.limit(1) .limit(1)
.maybeSingle() .maybeSingle()
@@ -468,21 +458,19 @@ export async function maybeProcessBot(
// 'all_unassigned' passa direto // 'all_unassigned' passa direto
// Inicia nova sessão // Inicia nova sessão
return await startSession(supa, config, tenantId, threadKey, phone, sendFn) return await startSession(tdb, config, threadKey, phone, sendFn)
} }
async function startSession( async function startSession(
supa: SupabaseClient, tdb: SupabaseClient,
config: BotConfig, config: BotConfig,
tenantId: string,
threadKey: string, threadKey: string,
phone: string, phone: string,
sendFn: SendFn sendFn: SendFn
): Promise<{ processed: boolean; status?: string; step?: number }> { ): 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') .from('conversation_bot_sessions')
.insert({ .insert({
tenant_id: tenantId,
thread_key: threadKey, thread_key: threadKey,
contact_number: phone, contact_number: phone,
current_step: 0, current_step: 0,
@@ -503,9 +491,11 @@ async function startSession(
} }
async function advanceSession( async function advanceSession(
tdb: SupabaseClient,
supa: SupabaseClient, supa: SupabaseClient,
tenantId: string,
config: BotConfig, 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, text: string,
phone: string, phone: string,
sendFn: SendFn sendFn: SendFn
@@ -514,7 +504,7 @@ async function advanceSession(
const currentStep = config.steps[step] const currentStep = config.steps[step]
if (!currentStep) { if (!currentStep) {
// Segurança: step fora do range → encerra // 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() }) .update({ status: 'completed', completed_at: new Date().toISOString() })
.eq('id', session.id) .eq('id', session.id)
return { processed: true, status: 'completed', step } return { processed: true, status: 'completed', step }
@@ -527,7 +517,7 @@ async function advanceSession(
if (isLast) { if (isLast) {
// Finaliza // Finaliza
await supa.from('conversation_bot_sessions') await tdb.from('conversation_bot_sessions')
.update({ .update({
collected_data: newData, collected_data: newData,
current_step: nextStep, current_step: nextStep,
@@ -547,13 +537,12 @@ async function advanceSession(
return `${s.variable}: ${val}` return `${s.variable}: ${val}`
}) })
const summary = `🤖 Triagem automática concluída:\n\n${lines.join('\n')}` const summary = `🤖 Triagem automática concluída:\n\n${lines.join('\n')}`
await supa.from('conversation_notes').insert({ await tdb.from('conversation_notes').insert({
tenant_id: session.tenant_id,
thread_key: session.thread_key, thread_key: session.thread_key,
contact_number: session.contact_number, contact_number: session.contact_number,
body: summary, body: summary,
// created_by obrigatório — usa um user "bot" fictício? Não temos. Pega qualquer admin. // created_by obrigatório — usa qualquer admin do tenant (tenant_members é global)
created_by: await pickAnyAdmin(supa, session.tenant_id) created_by: await pickAnyAdmin(supa, tenantId)
}) })
} catch (err) { } catch (err) {
console.warn('[bot] failed to create summary note:', (err as Error)?.message) 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 // Avança pra próxima pergunta
await supa.from('conversation_bot_sessions') await tdb.from('conversation_bot_sessions')
.update({ .update({
collected_data: newData, collected_data: newData,
current_step: nextStep, current_step: nextStep,
@@ -588,4 +577,3 @@ async function pickAnyAdmin(supa: SupabaseClient, tenantId: string): Promise<str
.maybeSingle() .maybeSingle()
return (data?.user_id as string) ?? '00000000-0000-0000-0000-000000000000' return (data?.user_id as string) ?? '00000000-0000-0000-0000-000000000000'
} }
@@ -11,7 +11,7 @@
| Output: { ok: true } ou { ok: false, error } | Output: { ok: true } ou { ok: false, error }
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { adminClient, tenantDbForId } from '../_shared/tenant.ts';
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -39,10 +39,11 @@ Deno.serve(async (req: Request) => {
if (!tenantId || !asaasPaymentId) return json({ ok: false, error: 'missing_fields' }, 400); if (!tenantId || !asaasPaymentId) return json({ ok: false, error: 'missing_fields' }, 400);
const supa = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); const admin = adminClient();
const tdb = await tenantDbForId(admin, tenantId);
// 1. Lê config + API key // 1. Lê config + API key
const { data: settings } = await supa.from('payment_settings').select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod').eq('tenant_id', tenantId).maybeSingle(); const { data: settings } = await tdb.from('payment_settings').select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod').maybeSingle();
if (!settings?.asaas_enabled) return json({ ok: false, error: 'gateway_not_enabled' }, 403); if (!settings?.asaas_enabled) return json({ ok: false, error: 'gateway_not_enabled' }, 403);
@@ -55,7 +56,7 @@ Deno.serve(async (req: Request) => {
if (!apiKey) return json({ ok: false, error: 'api_key_missing' }, 403); if (!apiKey) return json({ ok: false, error: 'api_key_missing' }, 403);
// 2. Verifica que payment pertence ao tenant // 2. Verifica que payment pertence ao tenant
const { data: payment } = await supa.from('asaas_payments').select('id, status, cancelled_at').eq('tenant_id', tenantId).eq('asaas_payment_id', asaasPaymentId).eq('environment', environment).maybeSingle(); const { data: payment } = await tdb.from('asaas_payments').select('id, status, cancelled_at').eq('asaas_payment_id', asaasPaymentId).eq('environment', environment).maybeSingle();
if (!payment) return json({ ok: false, error: 'payment_not_found' }, 404); if (!payment) return json({ ok: false, error: 'payment_not_found' }, 404);
if (payment.cancelled_at) return json({ ok: true, already_cancelled: true }); if (payment.cancelled_at) return json({ ok: true, already_cancelled: true });
@@ -30,7 +30,7 @@
| 500 — erro Asaas | 500 — erro Asaas
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { adminClient, tenantDbForId } from '../_shared/tenant.ts';
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -63,13 +63,13 @@ Deno.serve(async (req: Request) => {
return json({ ok: false, error: 'invalid_billing_type' }, 400); return json({ ok: false, error: 'invalid_billing_type' }, 400);
} }
const supa = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); const admin = adminClient();
const tdb = await tenantDbForId(admin, tenantId);
// 1. Verifica gateway habilitado + lê API key do tenant // 1. Verifica gateway habilitado + lê API key do tenant
const { data: settings } = await supa const { data: settings } = await tdb
.from('payment_settings') .from('payment_settings')
.select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod') .select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod')
.eq('tenant_id', tenantId)
.maybeSingle(); .maybeSingle();
if (!settings?.asaas_enabled) { if (!settings?.asaas_enabled) {
@@ -87,11 +87,10 @@ Deno.serve(async (req: Request) => {
} }
// 2. Lê financial_record + patient // 2. Lê financial_record + patient
const { data: record } = await supa const { data: record } = await tdb
.from('financial_records') .from('financial_records')
.select('id, tenant_id, patient_id, amount, due_date, description, status, deleted_at, agenda_evento_id') .select('id, patient_id, amount, due_date, description, status, deleted_at, agenda_evento_id')
.eq('id', recordId) .eq('id', recordId)
.eq('tenant_id', tenantId)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle(); .maybeSingle();
@@ -101,7 +100,7 @@ Deno.serve(async (req: Request) => {
} }
if (!record.patient_id) return json({ ok: false, error: 'record_has_no_patient' }, 400); if (!record.patient_id) return json({ ok: false, error: 'record_has_no_patient' }, 400);
const { data: patient } = await supa const { data: patient } = await tdb
.from('patients') .from('patients')
.select('id, nome_completo, email_principal, telefone, cpf') .select('id, nome_completo, email_principal, telefone, cpf')
.eq('id', record.patient_id) .eq('id', record.patient_id)
@@ -113,10 +112,9 @@ Deno.serve(async (req: Request) => {
// 3. Garante customer no Asaas (chama interna asaas-create-customer-patient OU inline) // 3. Garante customer no Asaas (chama interna asaas-create-customer-patient OU inline)
// TODO Fase B: chamar Edge Function asaas-create-customer-patient ou inline upsert. // TODO Fase B: chamar Edge Function asaas-create-customer-patient ou inline upsert.
// Por ora, busca cache local — se não existe, retorna erro. // Por ora, busca cache local — se não existe, retorna erro.
let { data: customer } = await supa let { data: customer } = await tdb
.from('asaas_customers') .from('asaas_customers')
.select('id, asaas_customer_id') .select('id, asaas_customer_id')
.eq('tenant_id', tenantId)
.eq('patient_id', patient.id) .eq('patient_id', patient.id)
.eq('environment', environment) .eq('environment', environment)
.is('deleted_at', null) .is('deleted_at', null)
@@ -9,7 +9,7 @@
| ⚠️ STUB — chamada real ao Asaas marcada TODO. | ⚠️ STUB — chamada real ao Asaas marcada TODO.
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; import { adminClient, tenantDbForId } from '../_shared/tenant.ts';
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -36,9 +36,10 @@ Deno.serve(async (req: Request) => {
const asaasPaymentId = String(body.asaas_payment_id || ''); const asaasPaymentId = String(body.asaas_payment_id || '');
if (!tenantId || !asaasPaymentId) return json({ ok: false, error: 'missing_fields' }, 400); if (!tenantId || !asaasPaymentId) return json({ ok: false, error: 'missing_fields' }, 400);
const supa = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); const admin = adminClient();
const tdb = await tenantDbForId(admin, tenantId);
const { data: settings } = await supa.from('payment_settings').select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod').eq('tenant_id', tenantId).maybeSingle(); const { data: settings } = await tdb.from('payment_settings').select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod').maybeSingle();
if (!settings?.asaas_enabled) return json({ ok: false, error: 'gateway_not_enabled' }, 403); if (!settings?.asaas_enabled) return json({ ok: false, error: 'gateway_not_enabled' }, 403);
const environment = settings.asaas_environment || 'sandbox'; const environment = settings.asaas_environment || 'sandbox';
+2 -5
View File
@@ -19,7 +19,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { adminClient } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -59,10 +59,7 @@ Deno.serve(async (req: Request) => {
if (!paymentId) return json({ ok: true, skipped: 'no_payment_id' }) if (!paymentId) return json({ ok: true, skipped: 'no_payment_id' })
const supa = createClient( const supa = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Localiza purchase (prefere externalReference = purchase.id) // Localiza purchase (prefere externalReference = purchase.id)
let purchase: Record<string, unknown> | null = null let purchase: Record<string, unknown> | null = null
@@ -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 = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -38,7 +39,6 @@ function json(body: unknown, status = 200) {
} }
type Rule = { type Rule = {
tenant_id: string
enabled: boolean enabled: boolean
threshold_minutes: number threshold_minutes: number
respect_business_hours: boolean respect_business_hours: boolean
@@ -160,7 +160,7 @@ function businessMinutesElapsed(
// Processamento por tenant // 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 tenant_id: string
candidates: number candidates: number
opened: number opened: number
@@ -170,11 +170,10 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
// Query candidatas: threads onde: // Query candidatas: threads onde:
// - última mensagem é INBOUND // - última mensagem é INBOUND
// - (se assigned_only) assigned_to IS NOT NULL // - (se assigned_only) assigned_to IS NOT NULL
// Vou usar a view conversation_threads + filtro direction='inbound'. // Vou usar a view conversation_threads (tenant) + filtro direction='inbound'.
let query = supa let query = tdb
.from('conversation_threads') .from('conversation_threads')
.select('tenant_id, thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction') .select('thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction')
.eq('tenant_id', rule.tenant_id)
.eq('last_message_direction', 'inbound') .eq('last_message_direction', 'inbound')
if (rule.alert_scope === 'assigned_only') { 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 const { data: candidates, error } = await query
if (error) { 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 let opened = 0
@@ -203,9 +202,9 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
continue continue
} }
// Abre breach (idempotente) // Abre breach (idempotente). RPC mantém p_tenant_id (F6 reescreve depois).
const { data: breachId, error: openErr } = await supa.rpc('sla_open_breach', { const { data: breachId, error: openErr } = await admin.rpc('sla_open_breach', {
p_tenant_id: rule.tenant_id, p_tenant_id: tenantId,
p_thread_key: row.thread_key, p_thread_key: row.thread_key,
p_assigned_to: row.assigned_to, p_assigned_to: row.assigned_to,
p_last_inbound_at: last, p_last_inbound_at: last,
@@ -216,9 +215,8 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
opened++ opened++
// Notificação (só se ainda não notificou esse breach) // 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, breach_id: breachId as unknown as string,
tenant_id: rule.tenant_id,
thread_key: row.thread_key, thread_key: row.thread_key,
patient_name: row.patient_name || row.contact_number || 'Paciente desconhecido', patient_name: row.patient_name || row.contact_number || 'Paciente desconhecido',
assigned_to: row.assigned_to as string | null, 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++ 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 breach_id: string
tenant_id: string
thread_key: string thread_key: string
patient_name: string patient_name: string
assigned_to: string | null assigned_to: string | null
@@ -242,8 +239,8 @@ async function notifyBreach(supa: SupabaseClient, params: {
elapsed_minutes: number elapsed_minutes: number
threshold_minutes: number threshold_minutes: number
}): Promise<boolean> { }): Promise<boolean> {
// Anti-spam: não renotifica se já notificou // Anti-spam: não renotifica se já notificou (breach é tenant → tdb)
const { data: breach } = await supa const { data: breach } = await tdb
.from('conversation_sla_breaches') .from('conversation_sla_breaches')
.select('notified_at') .select('notified_at')
.eq('id', params.breach_id) .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.assigned_to) userIds.add(params.assigned_to)
if (params.notify_admin) { 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') .from('tenant_members')
.select('user_id') .select('user_id')
.eq('tenant_id', params.tenant_id) .eq('tenant_id', tenantId)
.in('role', ['clinic_admin', 'tenant_admin']) .in('role', ['clinic_admin', 'tenant_admin'])
.eq('status', 'active') .eq('status', 'active')
for (const a of admins || []) userIds.add(a.user_id) 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) => ({ const rows = Array.from(userIds).map((uid) => ({
owner_id: uid, owner_id: uid,
tenant_id: params.tenant_id,
type: 'system_alert', type: 'system_alert',
ref_id: params.breach_id, ref_id: params.breach_id,
ref_table: 'conversation_sla_breaches', 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 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 return true
} }
@@ -299,31 +297,41 @@ async function notifyBreach(supa: SupabaseClient, params: {
Deno.serve(async (req) => { Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const supa = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { autoRefreshToken: false, persistSession: false } }
)
try { try {
const now = new Date() const now = new Date()
// Regras habilitadas // Varre todos os tenants; cada schema tem suas próprias sla_rules (tenant)
const { data: rules, error: rulesErr } = await supa const tasks: Array<Promise<{ tenant_id: string; candidates: number; opened: number; still_pending: number; notified: number }>> = []
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
// Regras habilitadas do tenant (tabela tenant → tdb, sem tenant_id)
const { data: rules, error: rulesErr } = await tdb
.from('conversation_sla_rules') .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') .select('enabled, threshold_minutes, respect_business_hours, business_hours_start, business_hours_end, business_days, alert_scope, notify_admin_on_breach')
.eq('enabled', true) .eq('enabled', true)
if (rulesErr) return json({ error: rulesErr.message }, 500) if (rulesErr) {
if (!rules || rules.length === 0) return json({ checked: 0, results: [] }) console.error(`[sla] rules query error (tenant ${t.tenantId}):`, rulesErr.message)
continue
const results = await Promise.all( }
rules.map((r) => processRule(supa, r as Rule, now).catch((e) => ({ for (const r of rules || []) {
tenant_id: (r as Rule).tenant_id, 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, candidates: 0, opened: 0, still_pending: 0, notified: 0,
error: (e as Error).message error: (e as Error).message
}))) }))
) )
}
}
if (tasks.length === 0) return json({ checked: 0, results: [] })
const results = await Promise.all(tasks)
const summary = { const summary = {
checked: results.length, checked: results.length,
@@ -13,7 +13,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { adminClient, listTenantSchemas } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -37,48 +37,56 @@ Deno.serve(async (req: Request) => {
const body = await req.json().catch(() => ({})) as { idle_minutes?: number } const body = await req.json().catch(() => ({})) as { idle_minutes?: number }
const idleMinutes = Math.max(5, Math.min(1440, Number(body.idle_minutes) || DEFAULT_IDLE_MINUTES)) const idleMinutes = Math.max(5, Math.min(1440, Number(body.idle_minutes) || DEFAULT_IDLE_MINUTES))
const supa = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const cutoff = new Date(Date.now() - idleMinutes * 60 * 1000).toISOString() const cutoff = new Date(Date.now() - idleMinutes * 60 * 1000).toISOString()
let checked = 0
let eligibleCount = 0
let converted = 0
let errors = 0
const results: Array<{ tenant_id: string; intake_id: string; ok: boolean; error?: string }> = []
// Varre todos os tenants; patient_intake_requests é tenant → tdb
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
// Busca candidatos: in_progress, last_progress_at antigo, tem minimo nome OU telefone // Busca candidatos: in_progress, last_progress_at antigo, tem minimo nome OU telefone
const { data: candidates, error: fetchErr } = await supa const { data: candidates, error: fetchErr } = await tdb
.from('patient_intake_requests') .from('patient_intake_requests')
.select('id, nome_completo, telefone, email_principal') .select('id, nome_completo, telefone, email_principal')
.eq('status', 'in_progress') .eq('status', 'in_progress')
.lt('last_progress_at', cutoff) .lt('last_progress_at', cutoff)
if (fetchErr) return json({ error: fetchErr.message }, 500) if (fetchErr) {
console.error(`[convert-abandoned-intakes] fetch error (tenant ${t.tenantId}):`, fetchErr.message)
const eligible = (candidates || []).filter((c) => c.nome_completo || c.telefone) continue
if (eligible.length === 0) {
return json({ checked: candidates?.length || 0, converted: 0, errors: 0 })
} }
let converted = 0 checked += candidates?.length || 0
let errors = 0
const results: Array<{ intake_id: string; ok: boolean; error?: string }> = [] const eligible = (candidates || []).filter((c) => c.nome_completo || c.telefone)
eligibleCount += eligible.length
for (const row of eligible) { for (const row of eligible) {
const { error: rpcErr } = await supa.rpc('convert_abandoned_intake_to_lead', { // RPC opera no schema do tenant → tdb.rpc (assinatura só com p_intake_id).
// TODO(F6): se a RPC passar a exigir p_tenant_id, adicionar t.tenantId aqui.
const { error: rpcErr } = await tdb.rpc('convert_abandoned_intake_to_lead', {
p_intake_id: row.id p_intake_id: row.id
}) })
if (rpcErr) { if (rpcErr) {
errors++ errors++
results.push({ intake_id: row.id, ok: false, error: rpcErr.message }) results.push({ tenant_id: t.tenantId, intake_id: row.id, ok: false, error: rpcErr.message })
} else { } else {
converted++ converted++
results.push({ intake_id: row.id, ok: true }) results.push({ tenant_id: t.tenantId, intake_id: row.id, ok: true })
}
} }
} }
return json({ return json({
checked: candidates?.length || 0, checked,
eligible: eligible.length, eligible: eligibleCount,
converted, converted,
errors, errors,
idle_minutes: idleMinutes, idle_minutes: idleMinutes,
@@ -11,6 +11,7 @@
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, listTenantSchemas } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -48,20 +49,29 @@ Deno.serve(async (req: Request) => {
const userId = authData.user.id const userId = authData.user.id
// Service role pra bypass RLS // Service role pra bypass RLS
const supaSvc = createClient( const supaSvc = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Busca o canal // schema-per-tenant: notification_channels vive no schema do tenant (sem
const { data: channel, error: chErr } = await supaSvc // coluna tenant_id) e o caller só manda channel_id. Varremos os schemas
// provisionados pra localizar o canal e descobrir o tenant dono.
let tdb = null
let tenantId: string | null = null
for (const ref of await listTenantSchemas(supaSvc)) {
const candidate = supaSvc.schema(ref.schema)
const { data, error } = await candidate
.from('notification_channels') .from('notification_channels')
.select('id, tenant_id') .select('id')
.eq('id', channelId) .eq('id', channelId)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (error) {
console.warn('[deactivate] busca canal em', ref.schema, ':', error.message)
continue
}
if (data) { tdb = candidate; tenantId = ref.tenantId; break }
}
if (chErr || !channel) return json({ ok: false, error: 'channel_not_found' }, 404) if (!tdb || !tenantId) return json({ ok: false, error: 'channel_not_found' }, 404)
// Autoriza: user deve ser saas_admin OU membro ativo do tenant dono do canal // Autoriza: user deve ser saas_admin OU membro ativo do tenant dono do canal
const { data: isAdmin } = await supaSvc.rpc('is_saas_admin') const { data: isAdmin } = await supaSvc.rpc('is_saas_admin')
@@ -70,7 +80,7 @@ Deno.serve(async (req: Request) => {
const { data: membership } = await supaSvc const { data: membership } = await supaSvc
.from('tenant_members') .from('tenant_members')
.select('id') .select('id')
.eq('tenant_id', channel.tenant_id) .eq('tenant_id', tenantId)
.eq('user_id', userId) .eq('user_id', userId)
.eq('status', 'active') .eq('status', 'active')
.maybeSingle() .maybeSingle()
@@ -78,8 +88,8 @@ Deno.serve(async (req: Request) => {
} }
if (!authorized) return json({ ok: false, error: 'forbidden' }, 403) if (!authorized) return json({ ok: false, error: 'forbidden' }, 403)
// Desativa (soft-delete) // Desativa (soft-delete) — tabela tenant
const { error: updErr } = await supaSvc const { error: updErr } = await tdb
.from('notification_channels') .from('notification_channels')
.update({ .update({
is_active: false, is_active: false,
@@ -28,6 +28,7 @@ import {
registerOptout, registerOptout,
type SendFn type SendFn
} from '../_shared/whatsapp-hooks.ts' } from '../_shared/whatsapp-hooks.ts'
import { tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -136,12 +137,11 @@ function base64ToBytes(b64: string): Uint8Array {
type EvolutionCreds = { apiUrl: string; apiKey: string; instance: string } type EvolutionCreds = { apiUrl: string; apiKey: string; instance: string }
// Busca credenciais Evolution do tenant em notification_channels // Busca credenciais Evolution do tenant em notification_channels (schema do tenant)
async function getTenantEvolutionCreds(supa: SupabaseClient, tenantId: string): Promise<EvolutionCreds | null> { async function getTenantEvolutionCreds(tdb: SupabaseClient): Promise<EvolutionCreds | null> {
const { data: channel, error } = await supa const { data: channel, error } = await tdb
.from('notification_channels') .from('notification_channels')
.select('credentials') .select('credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
@@ -259,6 +259,7 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
) )
const tdb = await tenantDbForId(supabase, tenantId)
const payload = await req.json().catch(() => null) const payload = await req.json().catch(() => null)
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
@@ -292,10 +293,9 @@ Deno.serve(async (req: Request) => {
patch.read_by_recipient_at = new Date().toISOString() patch.read_by_recipient_at = new Date().toISOString()
patch.delivered_at = patch.delivered_at ?? new Date().toISOString() patch.delivered_at = patch.delivered_at ?? new Date().toISOString()
} }
await supabase await tdb
.from('conversation_messages') .from('conversation_messages')
.update(patch) .update(patch)
.eq('tenant_id', tenantId)
.eq('provider_message_id', msgId) .eq('provider_message_id', msgId)
.eq('direction', 'outbound') .eq('direction', 'outbound')
} }
@@ -340,7 +340,7 @@ Deno.serve(async (req: Request) => {
let storedMediaUrl: string | null = parts.mediaUrl let storedMediaUrl: string | null = parts.mediaUrl
let mediaError: string | null = null let mediaError: string | null = null
if (parts.hasEncryptedMedia && messageObj && parts.mediaMime) { if (parts.hasEncryptedMedia && messageObj && parts.mediaMime) {
const creds = await getTenantEvolutionCreds(supabase, tenantId) const creds = await getTenantEvolutionCreds(tdb)
if (!creds) { if (!creds) {
mediaError = 'creds_not_found' mediaError = 'creds_not_found'
storedMediaUrl = null storedMediaUrl = null
@@ -391,10 +391,9 @@ Deno.serve(async (req: Request) => {
// Dedup outbound echo // Dedup outbound echo
if (fromMe && messageId) { if (fromMe && messageId) {
const { data: existing } = await supabase const { data: existing } = await tdb
.from('conversation_messages') .from('conversation_messages')
.select('id') .select('id')
.eq('tenant_id', tenantId)
.eq('provider_message_id', messageId) .eq('provider_message_id', messageId)
.eq('direction', 'outbound') .eq('direction', 'outbound')
.maybeSingle() .maybeSingle()
@@ -410,8 +409,7 @@ Deno.serve(async (req: Request) => {
const direction = fromMe ? 'outbound' : 'inbound' const direction = fromMe ? 'outbound' : 'inbound'
const kanbanStatus = fromMe ? 'awaiting_patient' : 'awaiting_us' const kanbanStatus = fromMe ? 'awaiting_patient' : 'awaiting_us'
const { error: insErr } = await supabase.from('conversation_messages').insert({ const { error: insErr } = await tdb.from('conversation_messages').insert({
tenant_id: tenantId,
patient_id: patientId, patient_id: patientId,
channel: 'whatsapp', channel: 'whatsapp',
direction, direction,
@@ -438,13 +436,13 @@ Deno.serve(async (req: Request) => {
if (!fromMe && !insErr && fromPhone) { if (!fromMe && !insErr && fromPhone) {
// SendFn injetado: Evolution nao deduz creditos (provider gratis/self-hosted) // SendFn injetado: Evolution nao deduz creditos (provider gratis/self-hosted)
const creds = await getTenantEvolutionCreds(supabase, tenantId) const creds = await getTenantEvolutionCreds(tdb)
const sendFn: SendFn = creds const sendFn: SendFn = creds
? (phone, text) => sendViaEvolution(creds, phone, text) ? (phone, text) => sendViaEvolution(creds, phone, text)
: async () => ({ ok: false, error: 'creds_missing' }) : async () => ({ ok: false, error: 'creds_missing' })
try { try {
const optedBackIn = await maybeOptIn(supabase, tenantId, fromPhone, cleanBody) const optedBackIn = await maybeOptIn(tdb, fromPhone, cleanBody)
if (optedBackIn) optoutAction = 'in' if (optedBackIn) optoutAction = 'in'
} catch (err) { } catch (err) {
console.error('[optout] opt-in check error:', err) console.error('[optout] opt-in check error:', err)
@@ -452,9 +450,9 @@ Deno.serve(async (req: Request) => {
if (!optoutAction) { if (!optoutAction) {
try { try {
const keyword = await detectOptoutKeyword(supabase, tenantId, cleanBody) const keyword = await detectOptoutKeyword(tdb, cleanBody)
if (keyword) { if (keyword) {
await registerOptout(supabase, tenantId, fromPhone, patientId, cleanBody, keyword, 'evolution', sendFn) await registerOptout(tdb, fromPhone, patientId, cleanBody, keyword, 'evolution', sendFn)
optoutAction = 'out' optoutAction = 'out'
} }
} catch (err) { } catch (err) {
@@ -471,14 +469,14 @@ Deno.serve(async (req: Request) => {
// a inbound (iniciou sessão ou avançou step), não manda auto-reply // a inbound (iniciou sessão ou avançou step), não manda auto-reply
// pra evitar resposta duplicada. // pra evitar resposta duplicada.
try { try {
botResult = await maybeProcessBot(supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn) botResult = await maybeProcessBot(tdb, supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn)
} catch (err) { } catch (err) {
console.error('[bot] unexpected error:', err) console.error('[bot] unexpected error:', err)
} }
if (!botResult?.processed) { if (!botResult?.processed) {
try { try {
autoReplyResult = await maybeSendAutoReply(supabase, tenantId, threadKey, fromPhone, 'evolution', sendFn) autoReplyResult = await maybeSendAutoReply(tdb, threadKey, fromPhone, 'evolution', sendFn)
} catch (err) { } catch (err) {
console.error('[auto-reply] unexpected error:', err) console.error('[auto-reply] unexpected error:', err)
} }
+119 -23
View File
@@ -7,17 +7,31 @@
| |
| Runtime: Deno (Supabase Edge Functions) | Runtime: Deno (Supabase Edge Functions)
| Linguagem: JavaScript puro | Linguagem: JavaScript puro
|
| ── Schema-per-tenant ──
| notification_logs / notification_preferences / patients vivem no schema
| físico `tenant_<slug>` (SEM coluna tenant_id). Este webhook NÃO recebe
| tenant_id na URL, então resolve o tenant assim:
| - Meta: `value.metadata.phone_number_id` → resolveTenantByChannel
| (channel_routing.sender_address). Cada `change` é resolvido pro seu tenant.
| - Status sem identificador de canal (Evolution messages.update, ou Meta
| quando o phone_number_id não casa): faz fan-out por listTenantSchemas,
| procurando o log pelo provider_message_id no schema de cada tenant.
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import {
adminClient,
resolveTenantByChannel,
listTenantSchemas,
} from '../_shared/tenant.ts'
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
const EVOLUTION_API_KEY = Deno.env.get('EVOLUTION_API_KEY') || '' const EVOLUTION_API_KEY = Deno.env.get('EVOLUTION_API_KEY') || ''
const META_VERIFY_TOKEN = Deno.env.get('META_VERIFY_TOKEN') || '' const META_VERIFY_TOKEN = Deno.env.get('META_VERIFY_TOKEN') || ''
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY) // Client service_role no public — usado pra globais (channel_routing, tenants)
// e como base pra derivar os clients de schema de cada tenant.
const admin = adminClient()
Deno.serve(async (req) => { Deno.serve(async (req) => {
@@ -58,6 +72,9 @@ Deno.serve(async (req) => {
* Eventos relevantes: * Eventos relevantes:
* - messages.update: status de entrega (enviado, entregue, lido) * - messages.update: status de entrega (enviado, entregue, lido)
* - messages.upsert: mensagem recebida (para detectar "SAIR") * - messages.upsert: mensagem recebida (para detectar "SAIR")
*
* Nota: o webhook Evolution não traz identificador de canal/tenant aqui, então
* tanto status quanto opt-out fazem fan-out por todos os schemas de tenant.
*/ */
async function handleEvolutionWebhook (req, body) { async function handleEvolutionWebhook (req, body) {
// Validação básica da API key // Validação básica da API key
@@ -96,7 +113,8 @@ async function handleEvolutionWebhook (req, body) {
return jsonResponse({ ok: true, skipped: `status ${status} ignorado` }) return jsonResponse({ ok: true, skipped: `status ${status} ignorado` })
} }
await updateLogStatus(messageId, mappedStatus) // Sem identificador de canal: procura o log em cada schema de tenant.
await updateLogStatusFanout(messageId, mappedStatus)
return jsonResponse({ ok: true, status: mappedStatus }) return jsonResponse({ ok: true, status: mappedStatus })
} }
@@ -152,6 +170,11 @@ function handleMetaVerification (url) {
/** /**
* Processa webhooks da Meta WhatsApp Business API. * Processa webhooks da Meta WhatsApp Business API.
*
* Cada `change` traz `value.metadata.phone_number_id` (o canal Meta do tenant).
* Resolvemos o tenant via channel_routing.sender_address pra obter o client do
* schema correto. Se não resolver (canal não cadastrado), status cai no fan-out
* por message_id; opt-out segue por telefone em todos os schemas.
*/ */
async function handleMetaWebhook (body) { async function handleMetaWebhook (body) {
const entries = body.entry || [] const entries = body.entry || []
@@ -162,6 +185,15 @@ async function handleMetaWebhook (body) {
for (const change of changes) { for (const change of changes) {
const value = change.value || {} const value = change.value || {}
// Identifica o canal Meta (phone_number_id) → tenant/schema
const phoneNumberId = value.metadata?.phone_number_id
? String(value.metadata.phone_number_id)
: null
const ref = phoneNumberId
? await resolveTenantByChannel(admin, { senderAddress: phoneNumberId })
: null
const tdb = ref ? admin.schema(ref.schema) : null
// ── Status de mensagem ──── // ── Status de mensagem ────
if (value.statuses) { if (value.statuses) {
for (const st of value.statuses) { for (const st of value.statuses) {
@@ -171,7 +203,12 @@ async function handleMetaWebhook (body) {
if (messageId && status) { if (messageId && status) {
const mappedStatus = status === 'failed' ? 'failed' : status const mappedStatus = status === 'failed' ? 'failed' : status
await updateLogStatus(messageId, mappedStatus, errors[0]?.message) if (tdb) {
await updateLogStatus(tdb, messageId, mappedStatus, errors[0]?.message)
} else {
// Canal não resolvido: procura o log em todos os schemas.
await updateLogStatusFanout(messageId, mappedStatus, errors[0]?.message)
}
} }
} }
} }
@@ -184,7 +221,8 @@ async function handleMetaWebhook (body) {
if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) { if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) {
console.log(`[meta] Opt-out detectado: ${phone}`) console.log(`[meta] Opt-out detectado: ${phone}`)
await handleOptOut(phone, null) // Se resolvemos o tenant, processa só nele; senão, fan-out.
await handleOptOut(phone, null, ref ? [ref] : null)
} }
// Botão de resposta rápida (quick reply) // Botão de resposta rápida (quick reply)
@@ -204,10 +242,8 @@ async function handleMetaWebhook (body) {
// ── Helpers compartilhados ────────────────────────────────── // ── Helpers compartilhados ──────────────────────────────────
/** /** Monta o patch de notification_logs a partir do status mapeado. */
* Atualiza o status no notification_logs com base no provider_message_id. function buildLogPatch (status, failureReason) {
*/
async function updateLogStatus (providerMessageId, status, failureReason) {
const now = new Date().toISOString() const now = new Date().toISOString()
const updateData = { provider_status: status } const updateData = { provider_status: status }
@@ -229,46 +265,101 @@ async function updateLogStatus (providerMessageId, status, failureReason) {
updateData.failure_reason = failureReason || 'Falha reportada pelo provedor' updateData.failure_reason = failureReason || 'Falha reportada pelo provedor'
break break
} }
return updateData
}
const { error } = await supabase /**
* Atualiza o status no notification_logs (schema do tenant já resolvido) com
* base no provider_message_id. Retorna a contagem afetada (null se erro).
*/
async function updateLogStatus (tdb, providerMessageId, status, failureReason) {
const updateData = buildLogPatch(status, failureReason)
const { data, error } = await tdb
.from('notification_logs') .from('notification_logs')
.update(updateData) .update(updateData)
.eq('provider_message_id', providerMessageId) .eq('provider_message_id', providerMessageId)
.select('id')
if (error) { if (error) {
console.warn(`[updateLogStatus] Erro ao atualizar ${providerMessageId}:`, error.message) console.warn(`[updateLogStatus] Erro ao atualizar ${providerMessageId}:`, error.message)
return null
} }
return data?.length ?? 0
}
/**
* Fan-out: sem canal/tenant conhecido, procura o provider_message_id no
* notification_logs de cada schema de tenant e atualiza onde encontrar.
* Para no primeiro schema que afetar uma linha (message_id é único globalmente).
*
* TODO: provider_message_id não tem índice global; com muitos tenants este loop
* fica O(n). Idealmente registrar (provider_message_id → tenant) num índice
* global no envio (notification_logs/channel_routing) pra resolver em O(1).
*/
async function updateLogStatusFanout (providerMessageId, status, failureReason) {
const updateData = buildLogPatch(status, failureReason)
const tenants = await listTenantSchemas(admin)
for (const t of tenants) {
const tdb = admin.schema(t.schema)
const { data, error } = await tdb
.from('notification_logs')
.update(updateData)
.eq('provider_message_id', providerMessageId)
.select('id')
if (error) {
console.warn(`[updateLogStatusFanout] erro no schema ${t.schema}:`, error.message)
continue
}
if (data && data.length > 0) {
return data.length
}
}
console.warn(`[updateLogStatusFanout] message ${providerMessageId} não encontrado em nenhum tenant`)
return 0
} }
/** /**
* Processa opt-out: desativa WhatsApp para o paciente e cancela pendentes. * Processa opt-out: desativa WhatsApp para o paciente e cancela pendentes.
* @param {string} phone - número de telefone (apenas dígitos) * @param {string} phone - número de telefone (apenas dígitos)
* @param {string|null} instanceName - nome da instância Evolution (para identificar owner) * @param {string|null} instanceName - nome da instância Evolution (legado, não usado pra resolver schema)
* @param {Array|null} tenantsOverride - se informado, restringe a esses tenants;
* senão faz fan-out por todos os schemas.
*/ */
async function handleOptOut (phone, instanceName) { async function handleOptOut (phone, instanceName, tenantsOverride = null) {
// Normaliza telefone // Normaliza telefone
const cleanPhone = String(phone).replace(/\D/g, '') const cleanPhone = String(phone).replace(/\D/g, '')
if (!cleanPhone) return if (!cleanPhone) return
// Busca paciente(s) com esse telefone const tenants = tenantsOverride ?? await listTenantSchemas(admin)
const { data: patients } = await supabase let matched = 0
for (const t of tenants) {
const tdb = admin.schema(t.schema)
// Busca paciente(s) com esse telefone no schema deste tenant
const { data: patients, error: patErr } = await tdb
.from('patients') .from('patients')
.select('id, owner_id, telefone') .select('id, owner_id, telefone')
.or(`telefone.like.%${cleanPhone}%`) .or(`telefone.like.%${cleanPhone}%`)
if (!patients || patients.length === 0) { if (patErr) {
console.warn(`[opt-out] Nenhum paciente encontrado para ${cleanPhone}`) console.warn(`[opt-out] erro buscando paciente no schema ${t.schema}:`, patErr.message)
return continue
} }
if (!patients || patients.length === 0) continue
for (const patient of patients) { for (const patient of patients) {
matched++
// Atualiza preferência (o trigger cancela pendentes automaticamente) // Atualiza preferência (o trigger cancela pendentes automaticamente)
const { error } = await supabase const { error } = await tdb
.from('notification_preferences') .from('notification_preferences')
.upsert({ .upsert({
owner_id: patient.owner_id, owner_id: patient.owner_id,
tenant_id: patient.owner_id, // será ajustado pelo context
patient_id: patient.id, patient_id: patient.id,
whatsapp_opt_in: false, whatsapp_opt_in: false,
lgpd_opt_out_date: new Date().toISOString(), lgpd_opt_out_date: new Date().toISOString(),
@@ -279,11 +370,16 @@ async function handleOptOut (phone, instanceName) {
}) })
if (error) { if (error) {
console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id}:`, error.message) console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id} (schema ${t.schema}):`, error.message)
} else { } else {
console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id}`) console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id} (schema ${t.schema})`)
} }
} }
}
if (matched === 0) {
console.warn(`[opt-out] Nenhum paciente encontrado para ${cleanPhone}`)
}
} }
+92 -46
View File
@@ -4,20 +4,26 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Processa a notification_queue para channel = 'email'. | Processa a notification_queue para channel = 'email'.
| |
| schema-per-tenant: notification_queue/channels/logs e email_templates_tenant
| vivem no schema físico de cada tenant (SEM coluna tenant_id). O cron VARRE
| todos os tenants; se vier body.tenant_id, processa só aquele (modo single).
| email_templates_global é GLOBAL (admin/public).
|
| Fluxo por item: | Fluxo por item:
| 1. Busca pendentes (channel='email', status='pendente', scheduled_at <= now) | 1. Busca pendentes (channel='email', status='pendente', scheduled_at <= now)
| 2. Marca como 'processando' (lock otimista) | 2. Marca como 'processando' (lock otimista)
| 3. Busca canal SMTP em notification_channels | 3. Busca canal SMTP em notification_channels (tdb)
| 4. Resolve template: COALESCE(tenant, global) | 4. Resolve template: COALESCE(tenant, global)
| 5. Renderiza variáveis e condicionais {{#if}} | 5. Renderiza variáveis e condicionais {{#if}}
| 6. Envia via SMTP (Deno raw TCP com STARTTLS) | 6. Envia via SMTP (Deno raw TCP com STARTTLS)
| 7. Atualiza queue e insere em notification_logs | 7. Atualiza queue e insere em notification_logs (tdb)
| 8. Em erro: retry com backoff ou marca 'falhou' | 8. Em erro: retry com backoff ou marca 'falhou'
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { SmtpClient } from 'https://deno.land/x/smtp@v0.7.0/mod.ts' import { SmtpClient } from 'https://deno.land/x/smtp@v0.7.0/mod.ts'
import { adminClient, listTenantSchemas, tenantDbForId, schemaForTenant } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -112,22 +118,15 @@ async function sendEmail(
return { messageId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } return { messageId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }
} }
// ── Main handler ─────────────────────────────────────────────── // ── Processa a fila de UM tenant ───────────────────────────────
Deno.serve(async (req: Request) => { type Result = { id: string; status: string; error?: string }
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
async function processTenantQueue(admin: SupabaseClient, tdb: SupabaseClient): Promise<Result[]> {
const now = new Date().toISOString() const now = new Date().toISOString()
// 1. Busca itens pendentes de email // 1. Busca itens pendentes de email deste tenant
const { data: items, error: fetchErr } = await supabase const { data: items, error: fetchErr } = await tdb
.from('notification_queue') .from('notification_queue')
.select('*') .select('*')
.eq('channel', 'email') .eq('channel', 'email')
@@ -137,25 +136,14 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true }) .order('scheduled_at', { ascending: true })
.limit(20) .limit(20)
if (fetchErr) { if (fetchErr) throw new Error(fetchErr.message)
return new Response( if (!items || items.length === 0) return []
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items || items.length === 0) { const results: Result[] = []
return new Response(
JSON.stringify({ message: 'Nenhum email na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const results: Array<{ id: string; status: string; error?: string }> = []
for (const item of items) { for (const item of items) {
// 2. Lock otimista — marca como processando // 2. Lock otimista — marca como processando
const { error: lockErr } = await supabase const { error: lockErr } = await tdb
.from('notification_queue') .from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 }) .update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id) .eq('id', item.id)
@@ -167,8 +155,8 @@ Deno.serve(async (req: Request) => {
} }
try { try {
// 3. Busca canal SMTP // 3. Busca canal SMTP (tdb)
const { data: channel, error: chErr } = await supabase const { data: channel, error: chErr } = await tdb
.from('notification_channels') .from('notification_channels')
.select('credentials, sender_address, provider') .select('credentials, sender_address, provider')
.eq('owner_id', item.owner_id) .eq('owner_id', item.owner_id)
@@ -181,24 +169,22 @@ Deno.serve(async (req: Request) => {
throw new Error('Canal SMTP não encontrado ou inativo para este owner') throw new Error('Canal SMTP não encontrado ou inativo para este owner')
} }
// 4. Resolve template: tenant → global fallback // 4. Resolve template: tenant (tdb) → global (admin) fallback
const { data: tenantTpl } = await supabase const { data: tenantTpl } = await tdb
.from('email_templates_tenant') .from('email_templates_tenant')
.select('subject, body_html, body_text, enabled') .select('subject, body_html, body_text, enabled')
.eq('tenant_id', item.tenant_id)
.eq('owner_id', item.owner_id) .eq('owner_id', item.owner_id)
.eq('template_key', item.template_key) .eq('template_key', item.template_key)
.maybeSingle() .maybeSingle()
// Se tenant desabilitou o template → ignorar // Se tenant desabilitou o template → ignorar
if (tenantTpl && tenantTpl.enabled === false) { if (tenantTpl && tenantTpl.enabled === false) {
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ status: 'ignorado' }) .update({ status: 'ignorado' })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -216,8 +202,8 @@ Deno.serve(async (req: Request) => {
continue continue
} }
// Busca global // Busca global (admin/public)
const { data: globalTpl } = await supabase const { data: globalTpl } = await admin
.from('email_templates_global') .from('email_templates_global')
.select('subject, body_html, body_text') .select('subject, body_html, body_text')
.eq('key', item.template_key) .eq('key', item.template_key)
@@ -256,7 +242,7 @@ Deno.serve(async (req: Request) => {
) )
// 7. Sucesso — atualiza queue // 7. Sucesso — atualiza queue
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: 'enviado', status: 'enviado',
@@ -266,8 +252,7 @@ Deno.serve(async (req: Request) => {
.eq('id', item.id) .eq('id', item.id)
// Insere log de sucesso // Insere log de sucesso
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -297,7 +282,7 @@ Deno.serve(async (req: Request) => {
? null ? null
: new Date(Date.now() + retryDelay).toISOString() : new Date(Date.now() + retryDelay).toISOString()
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: isExhausted ? 'falhou' : 'pendente', status: isExhausted ? 'falhou' : 'pendente',
@@ -307,8 +292,7 @@ Deno.serve(async (req: Request) => {
.eq('id', item.id) .eq('id', item.id)
// Log de falha // Log de falha
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -326,6 +310,67 @@ Deno.serve(async (req: Request) => {
} }
} }
return results
}
// ── Main handler ───────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const admin = adminClient()
// Modo single-tenant se body.tenant_id; senão varre todos.
let bodyTenantId: string | null = null
try {
const body = await req.json()
bodyTenantId = body?.tenant_id ?? null
} catch {
// sem body / body inválido → modo varredura
}
const results: Result[] = []
const errors: Array<{ tenantId: string; error: string }> = []
try {
if (bodyTenantId) {
const schema = await schemaForTenant(admin, bodyTenantId)
if (!schema) {
return new Response(
JSON.stringify({ error: `schema indisponível para tenant ${bodyTenantId}` }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const tdb = await tenantDbForId(admin, bodyTenantId)
results.push(...await processTenantQueue(admin, tdb))
} else {
const tenants = await listTenantSchemas(admin)
for (const t of tenants) {
try {
const tdb = admin.schema(t.schema)
results.push(...await processTenantQueue(admin, tdb))
} catch (e) {
console.error(`[process-email-queue] tenant ${t.tenantId} falhou:`, e.message)
errors.push({ tenantId: t.tenantId, error: e.message })
}
}
}
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!results.length && !errors.length) {
return new Response(
JSON.stringify({ message: 'Nenhum email na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const sent = results.filter(r => r.status === 'enviado').length const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length const failed = results.filter(r => r.status === 'falhou').length
const retried = results.filter(r => r.status === 'retry').length const retried = results.filter(r => r.status === 'retry').length
@@ -339,6 +384,7 @@ Deno.serve(async (req: Request) => {
retried, retried,
ignored, ignored,
details: results, details: results,
tenantErrors: errors,
}), }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
) )
@@ -4,19 +4,24 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Processa a notification_queue para channel = 'whatsapp' via Evolution API. | Processa a notification_queue para channel = 'whatsapp' via Evolution API.
| |
| schema-per-tenant: notification_queue/channels/templates/logs vivem no
| schema físico de cada tenant (SEM coluna tenant_id). O cron VARRE todos os
| tenants ativos; se vier body.tenant_id, processa só aquele (modo single).
|
| Fluxo por item: | Fluxo por item:
| 1. Busca pendentes (channel='whatsapp', status='pendente', scheduled_at <= now) | 1. Busca pendentes (channel='whatsapp', status='pendente', scheduled_at <= now)
| 2. Marca como 'processando' (lock otimista) | 2. Marca como 'processando' (lock otimista)
| 3. Busca credenciais Evolution API em notification_channels | 3. Busca credenciais Evolution API em notification_channels (tdb)
| 4. Resolve template: tenant → global fallback | 4. Resolve template (todos os templates do schema pertencem ao tenant)
| 5. Renderiza variáveis {{var}} | 5. Renderiza variáveis {{var}}
| 6. Envia via Evolution API (sendText) | 6. Envia via Evolution API (sendText)
| 7. Atualiza queue + insere notification_logs | 7. Atualiza queue + insere notification_logs (tdb)
| 8. Em erro: retry com backoff ou marca 'falhou' | 8. Em erro: retry com backoff ou marca 'falhou'
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, listTenantSchemas, tenantDbForId, schemaForTenant } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -73,22 +78,15 @@ async function sendWhatsapp(
} }
} }
// ── Main handler ─────────────────────────────────────────────── // ── Processa a fila de UM tenant ───────────────────────────────
Deno.serve(async (req: Request) => { type Result = { id: string; status: string; error?: string }
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
async function processTenantQueue(tdb: SupabaseClient): Promise<Result[]> {
const now = new Date().toISOString() const now = new Date().toISOString()
// 1. Busca itens pendentes de WhatsApp // 1. Busca itens pendentes de WhatsApp deste tenant
const { data: items, error: fetchErr } = await supabase const { data: items, error: fetchErr } = await tdb
.from('notification_queue') .from('notification_queue')
.select('*') .select('*')
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
@@ -98,28 +96,17 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true }) .order('scheduled_at', { ascending: true })
.limit(20) .limit(20)
if (fetchErr) { if (fetchErr) throw new Error(fetchErr.message)
return new Response( if (!items?.length) return []
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items?.length) { const results: Result[] = []
return new Response(
JSON.stringify({ message: 'Nenhum WhatsApp na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const results: Array<{ id: string; status: string; error?: string }> = []
// Cache de credenciais por owner para evitar queries repetidas // Cache de credenciais por owner para evitar queries repetidas
const credentialsCache = new Map<string, EvolutionCredentials | null>() const credentialsCache = new Map<string, EvolutionCredentials | null>()
for (const item of items) { for (const item of items) {
// 2. Lock otimista // 2. Lock otimista
const { error: lockErr } = await supabase const { error: lockErr } = await tdb
.from('notification_queue') .from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 }) .update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id) .eq('id', item.id)
@@ -131,11 +118,11 @@ Deno.serve(async (req: Request) => {
} }
try { try {
// 3. Busca credenciais Evolution API (com cache) // 3. Busca credenciais Evolution API (com cache por owner)
let credentials = credentialsCache.get(item.owner_id) let credentials = credentialsCache.get(item.owner_id)
if (credentials === undefined) { if (credentials === undefined) {
const { data: channel } = await supabase const { data: channel } = await tdb
.from('notification_channels') .from('notification_channels')
.select('credentials') .select('credentials')
.eq('owner_id', item.owner_id) .eq('owner_id', item.owner_id)
@@ -144,49 +131,34 @@ Deno.serve(async (req: Request) => {
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
// Fallback: busca por tenant_id credentials = (channel?.credentials as EvolutionCredentials | null) ?? null
if (!channel?.credentials && item.tenant_id) { credentialsCache.set(item.owner_id, credentials)
const { data: tenantChannel } = await supabase
.from('notification_channels')
.select('credentials')
.eq('tenant_id', item.tenant_id)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
credentials = tenantChannel?.credentials as EvolutionCredentials | null
} else {
credentials = channel?.credentials as EvolutionCredentials | null
}
credentialsCache.set(item.owner_id, credentials ?? null)
} }
if (!credentials) { if (!credentials) {
throw new Error('Canal WhatsApp não encontrado ou inativo para este owner') throw new Error('Canal WhatsApp não encontrado ou inativo para este owner')
} }
// 4. Resolve template: tenant → global fallback // 4. Resolve template: todos os templates do schema pertencem ao tenant.
// Preferimos o do owner; se não houver, caímos no default do schema.
let templateBody: string | null = null let templateBody: string | null = null
const { data: tenantTpl } = await supabase const { data: ownerTpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text, is_active') .select('body_text, is_active')
.eq('tenant_id', item.tenant_id) .eq('owner_id', item.owner_id)
.eq('key', item.template_key) .eq('key', item.template_key)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (tenantTpl) { if (ownerTpl) {
templateBody = tenantTpl.body_text templateBody = ownerTpl.body_text
} else { } else {
const { data: globalTpl } = await supabase const { data: defaultTpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key) .eq('key', item.template_key)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_default', true) .eq('is_default', true)
@@ -194,7 +166,7 @@ Deno.serve(async (req: Request) => {
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
templateBody = globalTpl?.body_text || null templateBody = defaultTpl?.body_text || null
} }
if (!templateBody) { if (!templateBody) {
@@ -209,7 +181,7 @@ Deno.serve(async (req: Request) => {
const sendResult = await sendWhatsapp(credentials, item.recipient_address, message) const sendResult = await sendWhatsapp(credentials, item.recipient_address, message)
// 7. Sucesso // 7. Sucesso
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: 'enviado', status: 'enviado',
@@ -218,8 +190,7 @@ Deno.serve(async (req: Request) => {
}) })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -245,7 +216,7 @@ Deno.serve(async (req: Request) => {
const isExhausted = attempts >= maxAttempts const isExhausted = attempts >= maxAttempts
const retryMs = attempts * 2 * 60 * 1000 const retryMs = attempts * 2 * 60 * 1000
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: isExhausted ? 'falhou' : 'pendente', status: isExhausted ? 'falhou' : 'pendente',
@@ -254,8 +225,7 @@ Deno.serve(async (req: Request) => {
}) })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -273,12 +243,73 @@ Deno.serve(async (req: Request) => {
} }
} }
return results
}
// ── Main handler ───────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const admin = adminClient()
// Modo single-tenant se body.tenant_id; senão varre todos.
let bodyTenantId: string | null = null
try {
const body = await req.json()
bodyTenantId = body?.tenant_id ?? null
} catch {
// sem body / body inválido → modo varredura
}
const results: Result[] = []
const errors: Array<{ tenantId: string; error: string }> = []
try {
if (bodyTenantId) {
const schema = await schemaForTenant(admin, bodyTenantId)
if (!schema) {
return new Response(
JSON.stringify({ error: `schema indisponível para tenant ${bodyTenantId}` }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const tdb = await tenantDbForId(admin, bodyTenantId)
results.push(...await processTenantQueue(tdb))
} else {
const tenants = await listTenantSchemas(admin)
for (const t of tenants) {
try {
const tdb = admin.schema(t.schema)
results.push(...await processTenantQueue(tdb))
} catch (e) {
console.error(`[process-notification-queue] tenant ${t.tenantId} falhou:`, e.message)
errors.push({ tenantId: t.tenantId, error: e.message })
}
}
}
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!results.length && !errors.length) {
return new Response(
JSON.stringify({ message: 'Nenhum WhatsApp na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const sent = results.filter(r => r.status === 'enviado').length const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length const failed = results.filter(r => r.status === 'falhou').length
const retried = results.filter(r => r.status === 'retry').length const retried = results.filter(r => r.status === 'retry').length
return new Response( return new Response(
JSON.stringify({ processed: results.length, sent, failed, retried, details: results }), JSON.stringify({ processed: results.length, sent, failed, retried, details: results, tenantErrors: errors }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
) )
}) })
+105 -55
View File
@@ -9,19 +9,25 @@
| - Antes de enviar, debita 1 crédito do tenant via RPC | - Antes de enviar, debita 1 crédito do tenant via RPC
| - Sem crédito → marca como 'sem_credito' | - Sem crédito → marca como 'sem_credito'
| |
| schema-per-tenant: notification_queue/templates/logs vivem no schema físico
| de cada tenant (SEM coluna tenant_id). O cron VARRE todos os tenants; se vier
| body.tenant_id, processa só aquele (modo single). addon_credits é GLOBAL
| (admin) e a RPC debit_addon_credit continua em admin.rpc com p_tenant_id.
|
| Fluxo: | Fluxo:
| 1. Busca pendentes (channel='sms', status='pendente', scheduled_at <= now) | 1. Busca pendentes (channel='sms', status='pendente', scheduled_at <= now)
| 2. Lock otimista (status → processando) | 2. Lock otimista (status → processando)
| 3. Debita crédito SMS do tenant (addon_credits) | 3. Debita crédito SMS do tenant (RPC, admin + p_tenant_id)
| 4. Resolve template (tenant → global fallback) | 4. Resolve template (templates do schema pertencem ao tenant)
| 5. Renderiza variáveis {{var}} | 5. Renderiza variáveis {{var}}
| 6. Envia via Twilio REST API (credenciais da plataforma) | 6. Envia via Twilio REST API (credenciais da plataforma)
| 7. Atualiza queue + insere notification_logs | 7. Atualiza queue + insere notification_logs (tdb)
| 8. Retry com backoff em caso de erro | 8. Retry com backoff em caso de erro
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, listTenantSchemas, tenantDbForId, schemaForTenant } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -89,22 +95,19 @@ function mockSend(_to: string, _body: string): { sid: string; status: string } {
return { sid, status: 'sent' } return { sid, status: 'sent' }
} }
// ── Main handler ─────────────────────────────────────────────── // ── Processa a fila de UM tenant ───────────────────────────────
Deno.serve(async (req: Request) => { type Result = { id: string; status: string; error?: string }
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
async function processTenantQueue(
admin: SupabaseClient,
tdb: SupabaseClient,
tenantId: string
): Promise<Result[]> {
const now = new Date().toISOString() const now = new Date().toISOString()
// 1. Busca itens pendentes // 1. Busca itens pendentes deste tenant
const { data: items, error: fetchErr } = await supabase const { data: items, error: fetchErr } = await tdb
.from('notification_queue') .from('notification_queue')
.select('*') .select('*')
.eq('channel', 'sms') .eq('channel', 'sms')
@@ -113,28 +116,17 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true }) .order('scheduled_at', { ascending: true })
.limit(20) .limit(20)
if (fetchErr) { if (fetchErr) throw new Error(fetchErr.message)
return new Response( if (!items?.length) return []
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items?.length) { const results: Result[] = []
return new Response(
JSON.stringify({ message: 'Nenhum SMS na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const results: Array<{ id: string; status: string; error?: string }> = []
for (const item of items) { for (const item of items) {
// Filtra por max_attempts // Filtra por max_attempts
if (item.attempts >= (item.max_attempts || 5)) continue if (item.attempts >= (item.max_attempts || 5)) continue
// 2. Lock otimista // 2. Lock otimista
const { error: lockErr } = await supabase const { error: lockErr } = await tdb
.from('notification_queue') .from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 }) .update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id) .eq('id', item.id)
@@ -146,10 +138,10 @@ Deno.serve(async (req: Request) => {
} }
try { try {
// 3. Debita crédito SMS do tenant // 3. Debita crédito SMS do tenant (RPC global, p_tenant_id da iteração)
const { data: debitResult, error: debitErr } = await supabase const { data: debitResult, error: debitErr } = await admin
.rpc('debit_addon_credit', { .rpc('debit_addon_credit', {
p_tenant_id: item.tenant_id, p_tenant_id: tenantId,
p_addon_type: 'sms', p_addon_type: 'sms',
p_queue_id: item.id, p_queue_id: item.id,
p_description: `SMS para ${item.recipient_address}`, p_description: `SMS para ${item.recipient_address}`,
@@ -161,13 +153,12 @@ Deno.serve(async (req: Request) => {
if (!debitResult?.success) { if (!debitResult?.success) {
// Sem crédito — não envia, marca como sem_credito // Sem crédito — não envia, marca como sem_credito
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ status: 'sem_credito', last_error: debitResult?.reason || 'Sem créditos SMS' }) .update({ status: 'sem_credito', last_error: debitResult?.reason || 'Sem créditos SMS' })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -185,26 +176,26 @@ Deno.serve(async (req: Request) => {
continue continue
} }
// 4. Resolve template: tenant → global fallback // 4. Resolve template: templates do schema pertencem ao tenant.
// Preferimos override do owner; senão default do schema.
let template: { body_text: string } | null = null let template: { body_text: string } | null = null
const { data: tenantTpl } = await supabase const { data: ownerTpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text, is_active') .select('body_text, is_active')
.eq('tenant_id', item.tenant_id) .eq('owner_id', item.owner_id)
.eq('key', item.template_key) .eq('key', item.template_key)
.eq('channel', 'sms') .eq('channel', 'sms')
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (tenantTpl) { if (ownerTpl) {
template = tenantTpl template = ownerTpl
} else { } else {
const { data: globalTpl } = await supabase const { data: defaultTpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key) .eq('key', item.template_key)
.eq('channel', 'sms') .eq('channel', 'sms')
.eq('is_default', true) .eq('is_default', true)
@@ -212,7 +203,7 @@ Deno.serve(async (req: Request) => {
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
template = globalTpl template = defaultTpl
} }
if (!template) { if (!template) {
@@ -223,11 +214,11 @@ Deno.serve(async (req: Request) => {
const vars = item.resolved_vars || {} const vars = item.resolved_vars || {}
const message = renderTemplate(template.body_text, vars) const message = renderTemplate(template.body_text, vars)
// 6. Busca from_number override do tenant (se tiver) // 6. Busca from_number override do tenant (addon_credits é GLOBAL → admin)
const { data: creditRow } = await supabase const { data: creditRow } = await admin
.from('addon_credits') .from('addon_credits')
.select('from_number_override') .select('from_number_override')
.eq('tenant_id', item.tenant_id) .eq('tenant_id', tenantId)
.eq('addon_type', 'sms') .eq('addon_type', 'sms')
.maybeSingle() .maybeSingle()
@@ -243,7 +234,7 @@ Deno.serve(async (req: Request) => {
} }
// 8. Sucesso // 8. Sucesso
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: 'enviado', status: 'enviado',
@@ -252,8 +243,7 @@ Deno.serve(async (req: Request) => {
}) })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -279,7 +269,7 @@ Deno.serve(async (req: Request) => {
const isExhausted = attempts >= maxAttempts const isExhausted = attempts >= maxAttempts
const retryMs = attempts * 2 * 60 * 1000 const retryMs = attempts * 2 * 60 * 1000
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: isExhausted ? 'falhou' : 'pendente', status: isExhausted ? 'falhou' : 'pendente',
@@ -288,8 +278,7 @@ Deno.serve(async (req: Request) => {
}) })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -307,12 +296,73 @@ Deno.serve(async (req: Request) => {
} }
} }
return results
}
// ── Main handler ───────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const admin = adminClient()
// Modo single-tenant se body.tenant_id; senão varre todos.
let bodyTenantId: string | null = null
try {
const body = await req.json()
bodyTenantId = body?.tenant_id ?? null
} catch {
// sem body / body inválido → modo varredura
}
const results: Result[] = []
const errors: Array<{ tenantId: string; error: string }> = []
try {
if (bodyTenantId) {
const schema = await schemaForTenant(admin, bodyTenantId)
if (!schema) {
return new Response(
JSON.stringify({ error: `schema indisponível para tenant ${bodyTenantId}` }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const tdb = await tenantDbForId(admin, bodyTenantId)
results.push(...await processTenantQueue(admin, tdb, bodyTenantId))
} else {
const tenants = await listTenantSchemas(admin)
for (const t of tenants) {
try {
const tdb = admin.schema(t.schema)
results.push(...await processTenantQueue(admin, tdb, t.tenantId))
} catch (e) {
console.error(`[process-sms-queue] tenant ${t.tenantId} falhou:`, e.message)
errors.push({ tenantId: t.tenantId, error: e.message })
}
}
}
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!results.length && !errors.length) {
return new Response(
JSON.stringify({ message: 'Nenhum SMS na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const sent = results.filter(r => r.status === 'enviado').length const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length const failed = results.filter(r => r.status === 'falhou').length
const noCredit = results.filter(r => r.status === 'sem_credito').length const noCredit = results.filter(r => r.status === 'sem_credito').length
return new Response( return new Response(
JSON.stringify({ processed: results.length, sent, failed, no_credit: noCredit, details: results }), JSON.stringify({ processed: results.length, sent, failed, no_credit: noCredit, details: results, tenantErrors: errors }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
) )
}) })
@@ -5,11 +5,15 @@
| Processa a notification_queue para channel='whatsapp' e provider='twilio'. | Processa a notification_queue para channel='whatsapp' e provider='twilio'.
| Usa credenciais da SUBCONTA de cada tenant (modelo de subcontas). | Usa credenciais da SUBCONTA de cada tenant (modelo de subcontas).
| |
| schema-per-tenant: notification_queue/channels/templates/logs vivem no
| schema físico de cada tenant (SEM coluna tenant_id). O cron VARRE todos os
| tenants; se vier body.tenant_id, processa só aquele (modo single).
|
| Fluxo: | Fluxo:
| 1. Busca itens pendentes (channel='whatsapp', status='pendente') | 1. Busca itens pendentes (channel='whatsapp', status='pendente')
| 2. Filtra somente tenants com provider='twilio' em notification_channels | 2. Filtra somente tenants com provider='twilio' em notification_channels
| 3. Lock otimista (status → processando) | 3. Lock otimista (status → processando)
| 4. Resolve template (tenant → global fallback) | 4. Resolve template (templates do schema pertencem ao tenant)
| 5. Renderiza variáveis {{var}} | 5. Renderiza variáveis {{var}}
| 6. Envia via Twilio usando credenciais da SUBCONTA do tenant | 6. Envia via Twilio usando credenciais da SUBCONTA do tenant
| 7. Atualiza queue + insere notification_logs com estimated_cost_brl | 7. Atualiza queue + insere notification_logs com estimated_cost_brl
@@ -17,7 +21,8 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import type { SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, listTenantSchemas, tenantDbForId, schemaForTenant } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -82,22 +87,23 @@ function mockSend(to: string, body: string): { sid: string; status: string } {
return { sid, status: 'sent' } return { sid, status: 'sent' }
} }
// ── Main handler ────────────────────────────────────────────────────────── // ── Processa a fila de UM tenant ───────────────────────────────────────────
Deno.serve(async (req: Request) => { type Result = { id: string; status: string; error?: string }
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const supabase = createClient( interface TwilioChannel {
Deno.env.get('SUPABASE_URL')!, twilio_subaccount_sid: string
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! twilio_phone_number: string
) cost_per_message_usd: number
price_per_message_brl: number
const usdBrlRate = parseFloat(Deno.env.get('USD_BRL_RATE') ?? '5.5') credentials: Record<string, string>
}
async function processTenantQueue(tdb: SupabaseClient, usdBrlRate: number): Promise<Result[]> {
const now = new Date().toISOString() const now = new Date().toISOString()
// 1. Busca itens pendentes de WhatsApp // 1. Busca itens pendentes de WhatsApp deste tenant
const { data: items, error: fetchErr } = await supabase const { data: items, error: fetchErr } = await tdb
.from('notification_queue') .from('notification_queue')
.select('*') .select('*')
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
@@ -106,51 +112,33 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true }) .order('scheduled_at', { ascending: true })
.limit(20) .limit(20)
if (fetchErr) { if (fetchErr) throw new Error(fetchErr.message)
return new Response( if (!items?.length) return []
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!items?.length) { // Há exatamente um canal twilio whatsapp por tenant (no schema do tenant).
return new Response( // Resolve uma vez (lazy) e reusa.
JSON.stringify({ message: 'Nenhuma mensagem WhatsApp na fila', processed: 0 }), let channel: TwilioChannel | null | undefined = undefined
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } async function getChannel(): Promise<TwilioChannel | null> {
) if (channel !== undefined) return channel
} const { data } = await tdb
// Cache de canais por tenant para evitar N+1
const channelCache: Record<string, {
twilio_subaccount_sid: string
twilio_phone_number: string
cost_per_message_usd: number
price_per_message_brl: number
credentials: Record<string, string>
} | null> = {}
async function getChannel(tenantId: string) {
if (tenantId in channelCache) return channelCache[tenantId]
const { data } = await supabase
.from('notification_channels') .from('notification_channels')
.select('twilio_subaccount_sid, twilio_phone_number, cost_per_message_usd, price_per_message_brl, credentials') .select('twilio_subaccount_sid, twilio_phone_number, cost_per_message_usd, price_per_message_brl, credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('provider', 'twilio') .eq('provider', 'twilio')
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
channelCache[tenantId] = data channel = (data as TwilioChannel | null) ?? null
return data return channel
} }
const results: Array<{ id: string; status: string; error?: string }> = [] const results: Result[] = []
for (const item of items) { for (const item of items) {
if (item.attempts >= (item.max_attempts || 5)) continue if (item.attempts >= (item.max_attempts || 5)) continue
// 2. Lock otimista // 2. Lock otimista
const { error: lockErr } = await supabase const { error: lockErr } = await tdb
.from('notification_queue') .from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 }) .update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id) .eq('id', item.id)
@@ -163,41 +151,41 @@ Deno.serve(async (req: Request) => {
try { try {
// 3. Busca canal twilio do tenant // 3. Busca canal twilio do tenant
const channel = await getChannel(item.tenant_id) const ch = await getChannel()
if (!channel?.twilio_subaccount_sid) { if (!ch?.twilio_subaccount_sid) {
throw new Error('Tenant não tem subconta Twilio WhatsApp ativa') throw new Error('Tenant não tem subconta Twilio WhatsApp ativa')
} }
const subToken = channel.credentials?.subaccount_auth_token const subToken = ch.credentials?.subaccount_auth_token
if (!subToken) throw new Error('subaccount_auth_token não encontrado nas credenciais') if (!subToken) throw new Error('subaccount_auth_token não encontrado nas credenciais')
// 4. Resolve template: tenant → global fallback // 4. Resolve template: templates do schema pertencem ao tenant.
// Preferimos override do owner; senão default do schema.
let template: { body_text: string } | null = null let template: { body_text: string } | null = null
const { data: tenantTpl } = await supabase const { data: ownerTpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.eq('tenant_id', item.tenant_id) .eq('owner_id', item.owner_id)
.eq('key', item.template_key) .eq('key', item.template_key)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (tenantTpl) { if (ownerTpl) {
template = tenantTpl template = ownerTpl
} else { } else {
const { data: globalTpl } = await supabase const { data: defaultTpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key) .eq('key', item.template_key)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_default', true) .eq('is_default', true)
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
template = globalTpl template = defaultTpl
} }
if (!template) throw new Error(`Template WhatsApp não encontrado: ${item.template_key}`) if (!template) throw new Error(`Template WhatsApp não encontrado: ${item.template_key}`)
@@ -213,19 +201,19 @@ Deno.serve(async (req: Request) => {
sendResult = mockSend(item.recipient_address, message) sendResult = mockSend(item.recipient_address, message)
} else { } else {
sendResult = await sendWhatsAppViaTwilio( sendResult = await sendWhatsAppViaTwilio(
channel.twilio_subaccount_sid, ch.twilio_subaccount_sid,
subToken, subToken,
channel.twilio_phone_number, ch.twilio_phone_number,
item.recipient_address, item.recipient_address,
message message
) )
} }
// Custo estimado em BRL // Custo estimado em BRL
const costBrl = (channel.cost_per_message_usd ?? 0) * usdBrlRate const costBrl = (ch.cost_per_message_usd ?? 0) * usdBrlRate
// 7. Sucesso — atualiza fila // 7. Sucesso — atualiza fila
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: 'enviado', status: 'enviado',
@@ -235,8 +223,7 @@ Deno.serve(async (req: Request) => {
.eq('id', item.id) .eq('id', item.id)
// 7b. Insere no log // 7b. Insere no log
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -264,7 +251,7 @@ Deno.serve(async (req: Request) => {
const isExhausted = attempts >= maxAttempts const isExhausted = attempts >= maxAttempts
const retryMs = Math.min(attempts * 2 * 60 * 1000, 30 * 60 * 1000) // max 30min const retryMs = Math.min(attempts * 2 * 60 * 1000, 30 * 60 * 1000) // max 30min
await supabase await tdb
.from('notification_queue') .from('notification_queue')
.update({ .update({
status: isExhausted ? 'falhou' : 'pendente', status: isExhausted ? 'falhou' : 'pendente',
@@ -273,8 +260,7 @@ Deno.serve(async (req: Request) => {
}) })
.eq('id', item.id) .eq('id', item.id)
await supabase.from('notification_logs').insert({ await tdb.from('notification_logs').insert({
tenant_id: item.tenant_id,
owner_id: item.owner_id, owner_id: item.owner_id,
queue_id: item.id, queue_id: item.id,
agenda_evento_id: item.agenda_evento_id, agenda_evento_id: item.agenda_evento_id,
@@ -293,12 +279,72 @@ Deno.serve(async (req: Request) => {
} }
} }
return results
}
// ── Main handler ──────────────────────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const admin = adminClient()
const usdBrlRate = parseFloat(Deno.env.get('USD_BRL_RATE') ?? '5.5')
// Modo single-tenant se body.tenant_id; senão varre todos.
let bodyTenantId: string | null = null
try {
const body = await req.json()
bodyTenantId = body?.tenant_id ?? null
} catch {
// sem body / body inválido → modo varredura
}
const results: Result[] = []
const errors: Array<{ tenantId: string; error: string }> = []
try {
if (bodyTenantId) {
const schema = await schemaForTenant(admin, bodyTenantId)
if (!schema) {
return new Response(
JSON.stringify({ error: `schema indisponível para tenant ${bodyTenantId}` }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const tdb = await tenantDbForId(admin, bodyTenantId)
results.push(...await processTenantQueue(tdb, usdBrlRate))
} else {
const tenants = await listTenantSchemas(admin)
for (const t of tenants) {
try {
const tdb = admin.schema(t.schema)
results.push(...await processTenantQueue(tdb, usdBrlRate))
} catch (e) {
console.error(`[process-whatsapp-queue] tenant ${t.tenantId} falhou:`, e.message)
errors.push({ tenantId: t.tenantId, error: e.message })
}
}
}
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (!results.length && !errors.length) {
return new Response(
JSON.stringify({ message: 'Nenhuma mensagem WhatsApp na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const sent = results.filter(r => r.status === 'enviado').length const sent = results.filter(r => r.status === 'enviado').length
const failed = results.filter(r => r.status === 'falhou').length const failed = results.filter(r => r.status === 'falhou').length
const retry = results.filter(r => r.status === 'retry').length const retry = results.filter(r => r.status === 'retry').length
return new Response( return new Response(
JSON.stringify({ processed: results.length, sent, failed, retry, details: results }), JSON.stringify({ processed: results.length, sent, failed, retry, details: results, tenantErrors: errors }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
) )
}) })
@@ -23,7 +23,8 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { createClient, type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId, listTenantSchemas } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -71,27 +72,42 @@ Deno.serve(async (req: Request) => {
const userId = authData.user.id const userId = authData.user.id
// Service role pra bypass RLS // Service role pra bypass RLS
const supaSvc = createClient( const supaSvc = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Localiza o canal alvo // schema-per-tenant: notification_channels vive no schema do tenant (sem
let target: { id: string, tenant_id: string, channel: string, provider: string, metadata: Record<string, unknown> | null } | null = null // coluna tenant_id). Resolvemos o schema (tdb) + o tenant dono (targetTenantId).
let tdb: SupabaseClient | null = null
let targetTenantId: string | null = null
let target: { id: string, channel: string, provider: string, metadata: Record<string, unknown> | null } | null = null
if (channelId) { if (channelId) {
const { data } = await supaSvc // Só temos channel_id — varremos os schemas provisionados pra achar o canal.
// NÃO filtramos por deleted_at: o alvo geralmente está soft-deleted.
for (const ref of await listTenantSchemas(supaSvc)) {
const candidate = supaSvc.schema(ref.schema)
const { data, error } = await candidate
.from('notification_channels') .from('notification_channels')
.select('id, tenant_id, channel, provider, metadata') .select('id, channel, provider, metadata')
.eq('id', channelId) .eq('id', channelId)
.maybeSingle() .maybeSingle()
target = data if (error) {
console.warn('[reactivate] busca canal em', ref.schema, ':', error.message)
continue
}
if (data) { tdb = candidate; targetTenantId = ref.tenantId; target = data; break }
}
} else { } else {
// Busca o mais recente soft-deleted daquele tenant+provider // tenant_id + provider: schema conhecido. Busca o mais recente daquele provider.
const { data } = await supaSvc try {
tdb = await tenantDbForId(supaSvc, tenantId!)
} catch (e) {
console.error('[reactivate] schema indisponível:', (e as Error).message)
return json({ ok: false, error: 'tenant_invalido' }, 400)
}
targetTenantId = tenantId!
const { data } = await tdb
.from('notification_channels') .from('notification_channels')
.select('id, tenant_id, channel, provider, metadata') .select('id, channel, provider, metadata')
.eq('tenant_id', tenantId!)
.eq('provider', provider!) .eq('provider', provider!)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -100,7 +116,7 @@ Deno.serve(async (req: Request) => {
target = data target = data
} }
if (!target) return json({ ok: false, error: 'channel_not_found' }, 404) if (!tdb || !targetTenantId || !target) return json({ ok: false, error: 'channel_not_found' }, 404)
// Autoriza: saas_admin OU membro ativo do tenant // Autoriza: saas_admin OU membro ativo do tenant
const { data: isAdmin } = await supaSvc.rpc('is_saas_admin') const { data: isAdmin } = await supaSvc.rpc('is_saas_admin')
@@ -109,7 +125,7 @@ Deno.serve(async (req: Request) => {
const { data: membership } = await supaSvc const { data: membership } = await supaSvc
.from('tenant_members') .from('tenant_members')
.select('id') .select('id')
.eq('tenant_id', target.tenant_id) .eq('tenant_id', targetTenantId)
.eq('user_id', userId) .eq('user_id', userId)
.eq('status', 'active') .eq('status', 'active')
.maybeSingle() .maybeSingle()
@@ -117,20 +133,19 @@ Deno.serve(async (req: Request) => {
} }
if (!authorized) return json({ ok: false, error: 'forbidden' }, 403) if (!authorized) return json({ ok: false, error: 'forbidden' }, 403)
// Exclusividade: soft-deleta outros canais ativos do mesmo tenant+channel // Exclusividade: soft-deleta outros canais ativos do mesmo channel (tabela
// (se estava Twilio ativo e reativa Evolution, Twilio é desativado) // tenant — o escopo do tenant já é o próprio schema, sem tenant_id)
const nowIso = new Date().toISOString() const nowIso = new Date().toISOString()
const { data: others } = await supaSvc const { data: others } = await tdb
.from('notification_channels') .from('notification_channels')
.select('id') .select('id')
.eq('tenant_id', target.tenant_id)
.eq('channel', target.channel) .eq('channel', target.channel)
.neq('id', target.id) .neq('id', target.id)
.is('deleted_at', null) .is('deleted_at', null)
let deactivatedOthers = 0 let deactivatedOthers = 0
if (others && others.length > 0) { if (others && others.length > 0) {
const { error: deactErr } = await supaSvc const { error: deactErr } = await tdb
.from('notification_channels') .from('notification_channels')
.update({ is_active: false, deleted_at: nowIso }) .update({ is_active: false, deleted_at: nowIso })
.in('id', others.map((o) => o.id)) .in('id', others.map((o) => o.id))
@@ -146,7 +161,7 @@ Deno.serve(async (req: Request) => {
const cleanedMeta: Record<string, unknown> = { ...(target.metadata || {}) } const cleanedMeta: Record<string, unknown> = { ...(target.metadata || {}) }
delete cleanedMeta.first_unhealthy_at delete cleanedMeta.first_unhealthy_at
const { error: updErr } = await supaSvc const { error: updErr } = await tdb
.from('notification_channels') .from('notification_channels')
.update({ .update({
is_active: true, is_active: true,
@@ -166,7 +181,7 @@ Deno.serve(async (req: Request) => {
ok: true, ok: true,
channel_id: target.id, channel_id: target.id,
provider: target.provider, provider: target.provider,
tenant_id: target.tenant_id, tenant_id: targetTenantId,
deactivated_others: deactivatedOthers deactivated_others: deactivatedOthers
}) })
} catch (err) { } catch (err) {
@@ -17,6 +17,7 @@
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -94,9 +95,13 @@ Deno.serve(async (req: Request) => {
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405) if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
try { try {
const body = await req.json().catch(() => null) as { event_id?: string } | null const body = await req.json().catch(() => null) as { event_id?: string, tenant_id?: string } | null
const eventId = body?.event_id const eventId = body?.event_id
const tenantId = body?.tenant_id
if (!eventId) return json({ ok: false, error: 'event_id_required' }, 400) if (!eventId) return json({ ok: false, error: 'event_id_required' }, 400)
// tenant_id é obrigatório no schema-per-tenant: o evento vive no schema do
// tenant, então precisamos dele pra resolver o schema antes de qualquer query.
if (!tenantId) return json({ ok: false, error: 'tenant_id_required' }, 400)
// Auth: precisa de user (qualquer membro do tenant do evento) // Auth: precisa de user (qualquer membro do tenant do evento)
const authHeader = req.headers.get('Authorization') const authHeader = req.headers.get('Authorization')
@@ -111,25 +116,32 @@ Deno.serve(async (req: Request) => {
if (authErr || !authData?.user) return json({ ok: false, error: 'unauthorized' }, 401) if (authErr || !authData?.user) return json({ ok: false, error: 'unauthorized' }, 401)
const userId = authData.user.id const userId = authData.user.id
const supa = createClient( const supa = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Carrega evento + paciente // Client ligado ao schema do tenant (agenda_eventos, patients,
const { data: ev, error: evErr } = await supa // notification_channels/templates, conversation_messages, session_reminder_logs).
let tdb
try {
tdb = await tenantDbForId(supa, tenantId)
} catch (e) {
console.error('[send-session-reminder-manual] schema indisponível:', (e as Error).message)
return json({ ok: false, error: 'tenant_invalido' }, 400)
}
// Carrega evento + paciente (tabela tenant)
const { data: ev, error: evErr } = await tdb
.from('agenda_eventos') .from('agenda_eventos')
.select('id, tenant_id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)') .select('id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)')
.eq('id', eventId) .eq('id', eventId)
.maybeSingle() .maybeSingle()
if (evErr || !ev) return json({ ok: false, error: 'event_not_found' }, 404) if (evErr || !ev) return json({ ok: false, error: 'event_not_found' }, 404)
// Autoriza: user deve ser membro ativo do tenant do evento // Autoriza: user deve ser membro ativo do tenant (global tenant_members)
const { data: mem } = await supa const { data: mem } = await supa
.from('tenant_members') .from('tenant_members')
.select('id') .select('id')
.eq('tenant_id', ev.tenant_id) .eq('tenant_id', tenantId)
.eq('user_id', userId) .eq('user_id', userId)
.eq('status', 'active') .eq('status', 'active')
.maybeSingle() .maybeSingle()
@@ -141,25 +153,23 @@ Deno.serve(async (req: Request) => {
const phone = normalizePhoneBR(pat.telefone) const phone = normalizePhoneBR(pat.telefone)
if (!/^\d{10,15}$/.test(phone)) return json({ ok: false, error: 'invalid_phone' }, 400) if (!/^\d{10,15}$/.test(phone)) return json({ ok: false, error: 'invalid_phone' }, 400)
// Canal WhatsApp ativo do tenant // Canal WhatsApp ativo do tenant (tabela tenant)
const { data: channel } = await supa const { data: channel } = await tdb
.from('notification_channels') .from('notification_channels')
.select('id, provider, credentials, is_active') .select('id, provider, credentials, is_active')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (!channel) return json({ ok: false, error: 'no_active_channel' }, 400) if (!channel) return json({ ok: false, error: 'no_active_channel' }, 400)
// Tenant name // Tenant name (global tenants)
const { data: tenant } = await supa.from('tenants').select('name').eq('id', ev.tenant_id).maybeSingle() const { data: tenant } = await supa.from('tenants').select('name').eq('id', tenantId).maybeSingle()
// Template lembrete_sessao — tenta custom do tenant, fallback pro default // Template lembrete_sessao — tenta custom, fallback pro default (tabela tenant)
const { data: tpl } = await supa const { data: tpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('key', 'lembrete_sessao') .eq('key', 'lembrete_sessao')
.is('deleted_at', null) .is('deleted_at', null)
@@ -170,13 +180,13 @@ Deno.serve(async (req: Request) => {
let body_text = tpl?.body_text let body_text = tpl?.body_text
if (!body_text) { if (!body_text) {
// Fallback: template default global // Fallback: template default semeado no schema (is_custom=false)
const { data: def } = await supa const { data: def } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('key', 'lembrete_sessao') .eq('key', 'lembrete_sessao')
.is('tenant_id', null) .eq('is_custom', false)
.is('deleted_at', null) .is('deleted_at', null)
.eq('is_active', true) .eq('is_active', true)
.limit(1) .limit(1)
@@ -204,9 +214,8 @@ Deno.serve(async (req: Request) => {
const sendRes = await sendViaEvolution(creds.api_url, creds.api_key, creds.instance_name, phone, text) 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) if (!sendRes.ok) return json({ ok: false, error: `send_failed: ${sendRes.error}` }, 500)
// Registra conversa + log // Registra conversa + log (tabelas tenant)
const { data: msg } = await supa.from('conversation_messages').insert({ const { data: msg } = await tdb.from('conversation_messages').insert({
tenant_id: ev.tenant_id,
patient_id: pat.id, patient_id: pat.id,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
@@ -221,9 +230,8 @@ Deno.serve(async (req: Request) => {
}).select('id').single() }).select('id').single()
// Upsert: permitir re-disparo manual. UNIQUE (event_id, reminder_type) — sobrescreve anterior. // Upsert: permitir re-disparo manual. UNIQUE (event_id, reminder_type) — sobrescreve anterior.
await supa.from('session_reminder_logs').upsert({ await tdb.from('session_reminder_logs').upsert({
event_id: eventId, event_id: eventId,
tenant_id: ev.tenant_id,
reminder_type: 'manual', reminder_type: 'manual',
provider: 'evolution', provider: 'evolution',
to_phone: phone, to_phone: phone,
@@ -20,7 +20,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 = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -166,11 +167,10 @@ async function sendViaTwilio(
} }
// Verifica se paciente está opted-out // Verifica se paciente está opted-out
async function isOptedOut(supa: SupabaseClient, tenantId: string, phone: string): Promise<boolean> { async function isOptedOut(tdb: SupabaseClient, phone: string): Promise<boolean> {
const { data } = await supa const { data } = await tdb
.from('conversation_optouts') .from('conversation_optouts')
.select('id') .select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone) .eq('phone', phone)
.is('opted_back_in_at', null) .is('opted_back_in_at', null)
.limit(1) .limit(1)
@@ -185,9 +185,12 @@ type ProcessStats = {
errors: number errors: number
} }
// Processa eventos em uma janela especifica // Processa eventos em uma janela especifica (já escopado a um tenant via tdb)
async function processWindow( async function processWindow(
supa: SupabaseClient, tdb: SupabaseClient,
admin: SupabaseClient,
tenantId: string,
tenantName: string,
type: '24h' | '2h', type: '24h' | '2h',
minutesAhead: number, minutesAhead: number,
stats: ProcessStats stats: ProcessStats
@@ -196,11 +199,11 @@ async function processWindow(
const start = new Date(Date.now() + (minutesAhead - windowMin) * 60 * 1000).toISOString() const start = new Date(Date.now() + (minutesAhead - windowMin) * 60 * 1000).toISOString()
const end = new Date(Date.now() + (minutesAhead + windowMin) * 60 * 1000).toISOString() const end = new Date(Date.now() + (minutesAhead + windowMin) * 60 * 1000).toISOString()
// Busca eventos na janela com patient + tenant (nome da clinica via company_profiles/tenants) // Busca eventos na janela com patient (tenant tables via tdb; schema já filtra o tenant)
const { data: events, error } = await supa const { data: events, error } = await tdb
.from('agenda_eventos') .from('agenda_eventos')
.select(` .select(`
id, tenant_id, inicio_em, modalidade, patient_id, status, id, inicio_em, modalidade, patient_id, status,
patients:patient_id (id, nome_completo, telefone) patients:patient_id (id, nome_completo, telefone)
`) `)
.eq('status', 'agendado') .eq('status', 'agendado')
@@ -217,23 +220,22 @@ async function processWindow(
for (const ev of events || []) { for (const ev of events || []) {
try { try {
const eventId = ev.id as string const eventId = ev.id as string
const tenantId = ev.tenant_id as string
const pat = Array.isArray(ev.patients) ? ev.patients[0] : ev.patients const pat = Array.isArray(ev.patients) ? ev.patients[0] : ev.patients
if (!pat || !pat.telefone) { if (!pat || !pat.telefone) {
// Sem telefone — loga e pula // Sem telefone — loga e pula
await logSkip(supa, eventId, tenantId, type, 'no_phone') await logSkip(tdb, eventId, type, 'no_phone')
stats.skipped.no_phone = (stats.skipped.no_phone || 0) + 1 stats.skipped.no_phone = (stats.skipped.no_phone || 0) + 1
continue continue
} }
const phone = normalizePhoneBR(pat.telefone) const phone = normalizePhoneBR(pat.telefone)
if (!/^\d{10,15}$/.test(phone)) { if (!/^\d{10,15}$/.test(phone)) {
await logSkip(supa, eventId, tenantId, type, 'invalid_phone') await logSkip(tdb, eventId, type, 'invalid_phone')
stats.skipped.invalid_phone = (stats.skipped.invalid_phone || 0) + 1 stats.skipped.invalid_phone = (stats.skipped.invalid_phone || 0) + 1
continue continue
} }
// Ja enviado? (UNIQUE constraint previne dup mas checamos pra economizar) // Ja enviado? (UNIQUE constraint previne dup mas checamos pra economizar)
const { data: existing } = await supa const { data: existing } = await tdb
.from('session_reminder_logs') .from('session_reminder_logs')
.select('id') .select('id')
.eq('event_id', eventId) .eq('event_id', eventId)
@@ -244,11 +246,10 @@ async function processWindow(
continue continue
} }
// Settings do tenant // Settings do tenant (tabela tenant; uma linha por schema)
const { data: settings } = await supa const { data: settings } = await tdb
.from('session_reminder_settings') .from('session_reminder_settings')
.select('*') .select('*')
.eq('tenant_id', tenantId)
.maybeSingle() .maybeSingle()
if (!settings || !settings.enabled) { if (!settings || !settings.enabled) {
@@ -269,48 +270,40 @@ async function processWindow(
const startHHMM = String(settings.quiet_hours_start || '22:00').slice(0, 5) const startHHMM = String(settings.quiet_hours_start || '22:00').slice(0, 5)
const endHHMM = String(settings.quiet_hours_end || '08:00').slice(0, 5) const endHHMM = String(settings.quiet_hours_end || '08:00').slice(0, 5)
if (isQuietHoursNow(startHHMM, endHHMM)) { if (isQuietHoursNow(startHHMM, endHHMM)) {
await logSkip(supa, eventId, tenantId, type, 'quiet_hours') await logSkip(tdb, eventId, type, 'quiet_hours')
stats.skipped.quiet_hours = (stats.skipped.quiet_hours || 0) + 1 stats.skipped.quiet_hours = (stats.skipped.quiet_hours || 0) + 1
continue continue
} }
} }
// Opt-out // Opt-out
if (settings.respect_opt_out && await isOptedOut(supa, tenantId, phone)) { if (settings.respect_opt_out && await isOptedOut(tdb, phone)) {
await logSkip(supa, eventId, tenantId, type, 'opted_out') await logSkip(tdb, eventId, type, 'opted_out')
stats.skipped.opted_out = (stats.skipped.opted_out || 0) + 1 stats.skipped.opted_out = (stats.skipped.opted_out || 0) + 1
continue continue
} }
// Canal ativo // Canal ativo
const { data: channel } = await supa const { data: channel } = await tdb
.from('notification_channels') .from('notification_channels')
.select('provider, credentials, twilio_phone_number, is_active') .select('provider, credentials, twilio_phone_number, is_active')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (!channel || !channel.is_active) { if (!channel || !channel.is_active) {
await logSkip(supa, eventId, tenantId, type, 'no_channel') await logSkip(tdb, eventId, type, 'no_channel')
stats.skipped.no_channel = (stats.skipped.no_channel || 0) + 1 stats.skipped.no_channel = (stats.skipped.no_channel || 0) + 1
continue continue
} }
// Nome da clinica (tenants.name) // Monta mensagem (nome da clínica vem do tenant global, resolvido no loop)
const { data: tenant } = await supa
.from('tenants')
.select('name')
.eq('id', tenantId)
.maybeSingle()
// Monta mensagem
const tpl = type === '24h' ? settings.template_24h : settings.template_2h const tpl = type === '24h' ? settings.template_24h : settings.template_2h
const text = renderTemplate(tpl, { const text = renderTemplate(tpl, {
nome_paciente: pat.nome_completo || 'Paciente', nome_paciente: pat.nome_completo || 'Paciente',
data_sessao: fmtDateDayMonth(ev.inicio_em), data_sessao: fmtDateDayMonth(ev.inicio_em),
hora_sessao: fmtTime(ev.inicio_em), hora_sessao: fmtTime(ev.inicio_em),
modalidade: ev.modalidade === 'online' ? 'online' : 'presencial', modalidade: ev.modalidade === 'online' ? 'online' : 'presencial',
nome_clinica: tenant?.name || '' nome_clinica: tenantName || ''
}) })
// Envia (Evolution only por enquanto) // Envia (Evolution only por enquanto)
@@ -319,23 +312,22 @@ async function processWindow(
if (providerKind === 'evolution') { if (providerKind === 'evolution') {
const creds = (channel.credentials ?? {}) as Record<string, string> const creds = (channel.credentials ?? {}) as Record<string, string>
if (!creds.api_url || !creds.api_key || !creds.instance_name) { if (!creds.api_url || !creds.api_key || !creds.instance_name) {
await logSkip(supa, eventId, tenantId, type, 'creds_missing') await logSkip(tdb, eventId, type, 'creds_missing')
stats.skipped.creds_missing = (stats.skipped.creds_missing || 0) + 1 stats.skipped.creds_missing = (stats.skipped.creds_missing || 0) + 1
continue continue
} }
const sendRes = await sendViaEvolution(creds.api_url, creds.api_key, creds.instance_name, phone, text) const sendRes = await sendViaEvolution(creds.api_url, creds.api_key, creds.instance_name, phone, text)
if (!sendRes.ok) { if (!sendRes.ok) {
console.error('[reminders] send failed for event', eventId, sendRes.error) console.error('[reminders] send failed for event', eventId, sendRes.error)
await supa.from('session_reminder_logs').insert({ await tdb.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type, event_id: eventId, reminder_type: type,
provider: 'evolution', skip_reason: `send_failed: ${sendRes.error}`, to_phone: phone provider: 'evolution', skip_reason: `send_failed: ${sendRes.error}`, to_phone: phone
}) })
stats.errors++ stats.errors++
continue continue
} }
// Registra outbound message + log // Registra outbound message + log
const { data: msg } = await supa.from('conversation_messages').insert({ const { data: msg } = await tdb.from('conversation_messages').insert({
tenant_id: tenantId,
patient_id: pat.id, patient_id: pat.id,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
@@ -349,8 +341,8 @@ async function processWindow(
responded_at: new Date().toISOString() responded_at: new Date().toISOString()
}).select('id').single() }).select('id').single()
await supa.from('session_reminder_logs').insert({ await tdb.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type, event_id: eventId, reminder_type: type,
provider: 'evolution', to_phone: phone, provider: 'evolution', to_phone: phone,
provider_message_id: sendRes.messageId ?? null, provider_message_id: sendRes.messageId ?? null,
conversation_message_id: msg?.id ?? null conversation_message_id: msg?.id ?? null
@@ -358,10 +350,9 @@ async function processWindow(
stats.sent++ stats.sent++
} else if (providerKind === 'twilio') { } else if (providerKind === 'twilio') {
// Busca creds twilio (colunas dedicadas + credentials JSONB com auth_token) // Busca creds twilio (colunas dedicadas + credentials JSONB com auth_token)
const { data: fullChannel } = await supa const { data: fullChannel } = await tdb
.from('notification_channels') .from('notification_channels')
.select('twilio_subaccount_sid, twilio_phone_number, credentials') .select('twilio_subaccount_sid, twilio_phone_number, credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
@@ -371,13 +362,13 @@ async function processWindow(
const twFrom = fullChannel?.twilio_phone_number as string const twFrom = fullChannel?.twilio_phone_number as string
if (!subSid || !subToken || !twFrom) { if (!subSid || !subToken || !twFrom) {
await logSkip(supa, eventId, tenantId, type, 'twilio_creds_missing') await logSkip(tdb, eventId, type, 'twilio_creds_missing')
stats.skipped.twilio_creds_missing = (stats.skipped.twilio_creds_missing || 0) + 1 stats.skipped.twilio_creds_missing = (stats.skipped.twilio_creds_missing || 0) + 1
continue continue
} }
// Deduz 1 crédito ANTES (atômico via RPC) // Deduz 1 crédito ANTES (atômico via RPC; whatsapp_credit_* é GLOBAL → admin)
const { error: dedErr } = await supa.rpc('deduct_whatsapp_credits', { const { error: dedErr } = await admin.rpc('deduct_whatsapp_credits', {
p_tenant_id: tenantId, p_tenant_id: tenantId,
p_amount: 1, p_amount: 1,
p_conversation_message_id: null, p_conversation_message_id: null,
@@ -385,15 +376,15 @@ async function processWindow(
}) })
if (dedErr) { if (dedErr) {
const reason = String(dedErr.message || '').includes('insufficient_credits') ? 'insufficient_credits' : 'deduct_error' const reason = String(dedErr.message || '').includes('insufficient_credits') ? 'insufficient_credits' : 'deduct_error'
await logSkip(supa, eventId, tenantId, type, reason) await logSkip(tdb, eventId, type, reason)
stats.skipped[reason] = (stats.skipped[reason] || 0) + 1 stats.skipped[reason] = (stats.skipped[reason] || 0) + 1
continue continue
} }
const sendRes = await sendViaTwilio(subSid, subToken, twFrom, phone, text) const sendRes = await sendViaTwilio(subSid, subToken, twFrom, phone, text)
if (!sendRes.ok) { if (!sendRes.ok) {
// Refund // Refund (GLOBAL → admin)
await supa.rpc('add_whatsapp_credits', { await admin.rpc('add_whatsapp_credits', {
p_tenant_id: tenantId, p_tenant_id: tenantId,
p_amount: 1, p_amount: 1,
p_kind: 'refund', p_kind: 'refund',
@@ -402,16 +393,15 @@ async function processWindow(
p_note: `Refund lembrete falhou: ${sendRes.error?.slice(0, 200)}` p_note: `Refund lembrete falhou: ${sendRes.error?.slice(0, 200)}`
}) })
console.error('[reminders] twilio send failed:', sendRes.error) console.error('[reminders] twilio send failed:', sendRes.error)
await supa.from('session_reminder_logs').insert({ await tdb.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type, event_id: eventId, reminder_type: type,
provider: 'twilio', skip_reason: `send_failed: ${sendRes.error}`, to_phone: phone provider: 'twilio', skip_reason: `send_failed: ${sendRes.error}`, to_phone: phone
}) })
stats.errors++ stats.errors++
continue continue
} }
const { data: msg } = await supa.from('conversation_messages').insert({ const { data: msg } = await tdb.from('conversation_messages').insert({
tenant_id: tenantId,
patient_id: pat.id, patient_id: pat.id,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
@@ -426,15 +416,15 @@ async function processWindow(
delivery_status: sendRes.status === 'delivered' ? 'delivered' : 'sent' delivery_status: sendRes.status === 'delivered' ? 'delivered' : 'sent'
}).select('id').single() }).select('id').single()
await supa.from('session_reminder_logs').insert({ await tdb.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type, event_id: eventId, reminder_type: type,
provider: 'twilio', to_phone: phone, provider: 'twilio', to_phone: phone,
provider_message_id: sendRes.messageId ?? null, provider_message_id: sendRes.messageId ?? null,
conversation_message_id: msg?.id ?? null conversation_message_id: msg?.id ?? null
}) })
stats.sent++ stats.sent++
} else { } else {
await logSkip(supa, eventId, tenantId, type, 'unknown_provider') await logSkip(tdb, eventId, type, 'unknown_provider')
stats.skipped.unknown_provider = (stats.skipped.unknown_provider || 0) + 1 stats.skipped.unknown_provider = (stats.skipped.unknown_provider || 0) + 1
} }
} catch (err) { } catch (err) {
@@ -445,10 +435,10 @@ async function processWindow(
} }
async function logSkip( async function logSkip(
supa: SupabaseClient, eventId: string, tenantId: string, type: '24h' | '2h', reason: string tdb: SupabaseClient, eventId: string, type: '24h' | '2h', reason: string
) { ) {
await supa.from('session_reminder_logs').insert({ await tdb.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type, event_id: eventId, reminder_type: type,
provider: 'skipped', skip_reason: reason provider: 'skipped', skip_reason: reason
}) })
} }
@@ -457,17 +447,27 @@ Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try { try {
const supabase = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const stats: ProcessStats = { considered: 0, sent: 0, skipped: {}, errors: 0 } const stats: ProcessStats = { considered: 0, sent: 0, skipped: {}, errors: 0 }
// Varre todos os tenants com schema provisionado
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
// Nome da clínica (tenants é GLOBAL → admin)
const { data: tenant } = await admin
.from('tenants')
.select('name')
.eq('id', t.tenantId)
.maybeSingle()
const tenantName = tenant?.name || ''
// 24h antes // 24h antes
await processWindow(supabase, '24h', 24 * 60, stats) await processWindow(tdb, admin, t.tenantId, tenantName, '24h', 24 * 60, stats)
// 2h antes // 2h antes
await processWindow(supabase, '2h', 2 * 60, stats) await processWindow(tdb, admin, t.tenantId, tenantName, '2h', 2 * 60, stats)
}
return json({ ok: true, stats }) return json({ ok: true, stats })
} catch (err) { } catch (err) {
@@ -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 = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', '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) if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
try { 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 eventId = payload?.event_id
const newStatus = String(payload?.new_status || '').toLowerCase() const newStatus = String(payload?.new_status || '').toLowerCase()
if (!eventId || !newStatus) return json({ ok: false, error: 'invalid_payload' }, 400) 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] const templateKey = STATUS_TEMPLATE_MAP[newStatus]
if (!templateKey) return json({ ok: true, skipped: 'status_not_mapped', status: newStatus }) if (!templateKey) return json({ ok: true, skipped: 'status_not_mapped', status: newStatus })
const supa = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Carrega evento + paciente // Resolve o schema do tenant dono do evento. Preferencialmente o trigger
const { data: ev, error: evErr } = await supa // 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)'
let tdb: SupabaseClient | null = null
let tenantId: string | null = payload?.tenant_id ?? null
let ev: Record<string, unknown> | null = null
if (tenantId) {
tdb = await tenantDbForId(admin, tenantId)
const { data, error: evErr } = await tdb
.from('agenda_eventos') .from('agenda_eventos')
.select('id, tenant_id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)') .select(evSelect)
.eq('id', eventId) .eq('id', eventId)
.maybeSingle() .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)
}
if (evErr || !ev) return json({ ok: false, error: 'event_not_found' }, 404) const evRow = ev as {
inicio_em: string
const pat = Array.isArray(ev.patients) ? ev.patients[0] : ev.patients 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' }) if (!pat?.telefone) return json({ ok: true, skipped: 'no_phone' })
const phone = normalizePhoneBR(pat.telefone) const phone = normalizePhoneBR(pat.telefone)
if (!/^\d{10,15}$/.test(phone)) return json({ ok: true, skipped: 'invalid_phone' }) if (!/^\d{10,15}$/.test(phone)) return json({ ok: true, skipped: 'invalid_phone' })
// Opt-out: respeita // Opt-out: respeita (conversation_optouts é tenant → tdb)
const { data: optout } = await supa const { data: optout } = await tdb
.from('conversation_optouts') .from('conversation_optouts')
.select('id') .select('id')
.eq('tenant_id', ev.tenant_id)
.eq('contact_number', phone) .eq('contact_number', phone)
.is('opted_in_at', null) .is('opted_in_at', null)
.maybeSingle() .maybeSingle()
if (optout) return json({ ok: true, skipped: 'opt_out' }) if (optout) return json({ ok: true, skipped: 'opt_out' })
// Canal WhatsApp ativo // Canal WhatsApp ativo (tenant → tdb)
const { data: channel } = await supa const { data: channel } = await tdb
.from('notification_channels') .from('notification_channels')
.select('id, provider, credentials') .select('id, provider, credentials')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_active', true) .eq('is_active', true)
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (!channel) return json({ ok: true, skipped: 'no_active_channel' }) if (!channel) return json({ ok: true, skipped: 'no_active_channel' })
// Template (tenant-specific → global default) // Template (notification_templates é tenant → tdb; defaults já semeados no schema)
const { data: tpl } = await supa const { data: tpl } = await tdb
.from('notification_templates') .from('notification_templates')
.select('body_text') .select('body_text')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('key', templateKey) .eq('key', templateKey)
.is('deleted_at', null) .is('deleted_at', null)
@@ -156,29 +183,17 @@ Deno.serve(async (req: Request) => {
.limit(1) .limit(1)
.maybeSingle() .maybeSingle()
let body_text = tpl?.body_text const 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
}
if (!body_text) return json({ ok: true, skipped: 'template_not_found', template_key: templateKey }) 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, { const text = renderTemplate(body_text, {
nome_paciente: pat.nome_completo || 'paciente', nome_paciente: pat.nome_completo || 'paciente',
data_sessao: fmtDate(ev.inicio_em), data_sessao: fmtDate(evRow.inicio_em),
hora_sessao: fmtTime(ev.inicio_em), hora_sessao: fmtTime(evRow.inicio_em),
modalidade: ev.modalidade === 'online' ? 'online' : 'presencial', modalidade: evRow.modalidade === 'online' ? 'online' : 'presencial',
nome_clinica: tenant?.name || '', nome_clinica: tenant?.name || '',
status: newStatus 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) 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) 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) // Registra conversa (conversation_messages é tenant → tdb, sem tenant_id)
await supa.from('conversation_messages').insert({ await tdb.from('conversation_messages').insert({
tenant_id: ev.tenant_id,
patient_id: pat.id, patient_id: pat.id,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
@@ -19,6 +19,7 @@
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -143,10 +144,17 @@ Deno.serve(async (req: Request) => {
const { data: authData, error: authErr } = await supaAuthed.auth.getUser() const { data: authData, error: authErr } = await supaAuthed.auth.getUser()
if (authErr || !authData?.user) return json({ ok: false, error: 'auth' }, 401) if (authErr || !authData?.user) return json({ ok: false, error: 'auth' }, 401)
const supaSvc = createClient( const supaSvc = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // Client ligado ao schema do tenant (tabelas tenant: notification_channels,
) // conversation_messages). Lança se o tenant não existir/sem schema.
let tdb
try {
tdb = await tenantDbForId(supaSvc, tenant_id)
} catch (e) {
console.error('[send-whatsapp-message] schema indisponível:', (e as Error).message)
return json({ ok: false, error: 'tenant_invalido' }, 400)
}
const userId = authData.user.id const userId = authData.user.id
// Valida membership // Valida membership
@@ -162,11 +170,10 @@ Deno.serve(async (req: Request) => {
if (!membership) return json({ ok: false, error: 'forbidden' }, 403) if (!membership) return json({ ok: false, error: 'forbidden' }, 403)
} }
// Busca canal (Evolution ou Twilio) // Busca canal (Evolution ou Twilio) — tabela tenant
const { data: channel, error: chErr } = await supaSvc const { data: channel, error: chErr } = await tdb
.from('notification_channels') .from('notification_channels')
.select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active') .select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active')
.eq('tenant_id', tenant_id)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
@@ -229,8 +236,7 @@ Deno.serve(async (req: Request) => {
} }
// Registra mensagem + vincula a dedução (se possível — já foi feita sem msg_id) // Registra mensagem + vincula a dedução (se possível — já foi feita sem msg_id)
const { data: inserted, error: insErr } = await supaSvc.from('conversation_messages').insert({ const { data: inserted, error: insErr } = await tdb.from('conversation_messages').insert({
tenant_id,
patient_id: resolvedPatientId, patient_id: resolvedPatientId,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
@@ -294,8 +300,7 @@ Deno.serve(async (req: Request) => {
const providerMessageId = (evoJson as { key?: { id?: string } } | null)?.key?.id ?? null const providerMessageId = (evoJson as { key?: { id?: string } } | null)?.key?.id ?? null
const { data: inserted, error: insErr } = await supaSvc.from('conversation_messages').insert({ const { data: inserted, error: insErr } = await tdb.from('conversation_messages').insert({
tenant_id,
patient_id: resolvedPatientId, patient_id: resolvedPatientId,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'outbound', direction: 'outbound',
@@ -14,7 +14,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -45,13 +45,12 @@ Deno.serve(async (req: Request) => {
) )
} }
const supabase = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL')!, // email_templates_tenant é TENANT → tdb (schema do tenant)
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const tdb = await tenantDbForId(admin, tenant_id)
)
// 1. Busca todos os templates globais ativos // 1. Busca todos os templates globais ativos (GLOBAL → admin)
const { data: globals, error: globalsErr } = await supabase const { data: globals, error: globalsErr } = await admin
.from('email_templates_global') .from('email_templates_global')
.select('key, version') .select('key, version')
.eq('is_active', true) .eq('is_active', true)
@@ -64,11 +63,10 @@ Deno.serve(async (req: Request) => {
) )
} }
// 2. Busca templates existentes do tenant // 2. Busca templates existentes do tenant (TENANT → tdb, sem tenant_id)
const { data: tenantTemplates, error: tenantErr } = await supabase const { data: tenantTemplates, error: tenantErr } = await tdb
.from('email_templates_tenant') .from('email_templates_tenant')
.select('template_key, synced_version') .select('template_key, synced_version')
.eq('tenant_id', tenant_id)
.eq('owner_id', owner_id) .eq('owner_id', owner_id)
if (tenantErr) throw tenantErr if (tenantErr) throw tenantErr
@@ -84,11 +82,10 @@ Deno.serve(async (req: Request) => {
const existingVersion = tenantMap.get(global.key) const existingVersion = tenantMap.get(global.key)
if (existingVersion === undefined) { if (existingVersion === undefined) {
// Não existe → INSERT com campos null (herda do global) // Não existe → INSERT com campos null (herda do global). Tenant → tdb, sem tenant_id.
const { error: insertErr } = await supabase const { error: insertErr } = await tdb
.from('email_templates_tenant') .from('email_templates_tenant')
.insert({ .insert({
tenant_id,
owner_id, owner_id,
template_key: global.key, template_key: global.key,
subject: null, subject: null,
@@ -104,11 +101,10 @@ Deno.serve(async (req: Request) => {
} }
synced++ synced++
} else if (existingVersion < global.version) { } else if (existingVersion < global.version) {
// Existe mas desatualizado → UPDATE apenas synced_version // Existe mas desatualizado → UPDATE apenas synced_version (tenant → tdb)
const { error: updateErr } = await supabase const { error: updateErr } = await tdb
.from('email_templates_tenant') .from('email_templates_tenant')
.update({ synced_version: global.version }) .update({ synced_version: global.version })
.eq('tenant_id', tenant_id)
.eq('owner_id', owner_id) .eq('owner_id', owner_id)
.eq('template_key', global.key) .eq('template_key', global.key)
@@ -31,6 +31,7 @@ import {
registerOptout, registerOptout,
type TwilioChannel type TwilioChannel
} from '../_shared/whatsapp-hooks.ts' } from '../_shared/whatsapp-hooks.ts'
import { tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -73,6 +74,7 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
) )
const tdb = await tenantDbForId(supabase, tenantId)
const formData = await req.formData() const formData = await req.formData()
const from = stripWhatsappPrefix(formData.get('From') as string) const from = stripWhatsappPrefix(formData.get('From') as string)
@@ -111,8 +113,7 @@ Deno.serve(async (req: Request) => {
WaId: formData.get('WaId') ?? null, WaId: formData.get('WaId') ?? null,
} }
const { error: insErr } = await supabase.from('conversation_messages').insert({ const { error: insErr } = await tdb.from('conversation_messages').insert({
tenant_id: tenantId,
patient_id: patientId, patient_id: patientId,
channel: 'whatsapp', channel: 'whatsapp',
direction: 'inbound', direction: 'inbound',
@@ -144,23 +145,22 @@ Deno.serve(async (req: Request) => {
try { try {
// Busca canal Twilio (uma vez) — reutilizado por registerOptout/autoReply // Busca canal Twilio (uma vez) — reutilizado por registerOptout/autoReply
const { data: channel } = await supabase const { data: channel } = await tdb
.from('notification_channels') .from('notification_channels')
.select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active') .select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.is('deleted_at', null) .is('deleted_at', null)
.maybeSingle() .maybeSingle()
if (channel && channel.is_active && channel.provider === 'twilio') { if (channel && channel.is_active && channel.provider === 'twilio') {
// 1) Opt-IN (voltar) tem prioridade // 1) Opt-IN (voltar) tem prioridade
if (await maybeOptIn(supabase, tenantId, from, cleanBody)) { if (await maybeOptIn(tdb, from, cleanBody)) {
optoutAction = 'in' optoutAction = 'in'
} }
// 2) Opt-OUT por keyword — envia ack via Twilio (deduz 1 credito) // 2) Opt-OUT por keyword — envia ack via Twilio (deduz 1 credito)
if (!optoutAction) { if (!optoutAction) {
const keyword = await detectOptoutKeyword(supabase, tenantId, cleanBody) const keyword = await detectOptoutKeyword(tdb, cleanBody)
if (keyword) { if (keyword) {
const optoutSendFn = makeTwilioCreditedSendFn( const optoutSendFn = makeTwilioCreditedSendFn(
supabase, supabase,
@@ -169,8 +169,7 @@ Deno.serve(async (req: Request) => {
'Opt-out ack WhatsApp' 'Opt-out ack WhatsApp'
) )
await registerOptout( await registerOptout(
supabase, tdb,
tenantId,
from, from,
patientId, patientId,
cleanBody, cleanBody,
@@ -193,6 +192,7 @@ Deno.serve(async (req: Request) => {
'Bot de triagem WhatsApp' 'Bot de triagem WhatsApp'
) )
const botRes = await maybeProcessBot( const botRes = await maybeProcessBot(
tdb,
supabase, supabase,
tenantId, tenantId,
threadKey, threadKey,
@@ -214,8 +214,7 @@ Deno.serve(async (req: Request) => {
'Auto-reply WhatsApp' 'Auto-reply WhatsApp'
) )
autoReplyResult = await maybeSendAutoReply( autoReplyResult = await maybeSendAutoReply(
supabase, tdb,
tenantId,
threadKey, threadKey,
from, from,
'twilio', 'twilio',
@@ -24,7 +24,8 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { createClient, type SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId, listTenantSchemas, type TenantRef } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -181,10 +182,32 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
{ global: { headers: { Authorization: authHeader } } } { global: { headers: { Authorization: authHeader } } }
) )
const supabaseAdmin = createClient( const supabaseAdmin = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // ── schema-per-tenant: resolve um canal por id varrendo os schemas tenant ──
) // As ações deprovision/suspend/reactivate/test_send recebem só channel_id e
// notification_channels vive no schema do tenant (sem coluna tenant_id). Sem o
// tenant_id no payload, varremos os schemas provisionados pra localizar o canal.
// Retorna { tdb, channel, ref } ou null se não achar em nenhum schema.
async function findChannelById(channelId: string): Promise<
{ tdb: SupabaseClient; channel: Record<string, unknown>; ref: TenantRef } | null
> {
const refs = await listTenantSchemas(supabaseAdmin)
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
const { data, error } = await tdb
.from('notification_channels')
.select('*')
.eq('id', channelId)
.maybeSingle()
if (error) {
console.warn(`[provision] busca canal em ${ref.schema}:`, error.message)
continue
}
if (data) return { tdb, channel: data as Record<string, unknown>, ref }
}
return null
}
// Verifica autenticação // Verifica autenticação
const { data: { user }, error: authErr } = await supabase.auth.getUser() const { data: { user }, error: authErr } = await supabase.auth.getUser()
@@ -232,12 +255,12 @@ Deno.serve(async (req: Request) => {
return !!data return !!data
} }
// ── Busca canal do tenant ──────────────────────────────────────── // ── Busca canal do tenant (tabela tenant → schema do tenant) ─────
async function getChannel(tid: string) { async function getChannel(tid: string) {
const { data, error } = await supabaseAdmin const tdb = await tenantDbForId(supabaseAdmin, tid)
const { data, error } = await tdb
.from('notification_channels') .from('notification_channels')
.select('*') .select('*')
.eq('tenant_id', tid)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('provider', 'twilio') .eq('provider', 'twilio')
.is('deleted_at', null) .is('deleted_at', null)
@@ -290,6 +313,7 @@ Deno.serve(async (req: Request) => {
} }
const ownerId = await getOwnerId(tenantId) const ownerId = await getOwnerId(tenantId)
const tdb = await tenantDbForId(supabaseAdmin, tenantId)
// 1. Cria subconta Twilio // 1. Cria subconta Twilio
const subaccountData = await twilio.createSubaccount( const subaccountData = await twilio.createSubaccount(
@@ -316,9 +340,8 @@ Deno.serve(async (req: Request) => {
const numData = await twilio.buyNumber(subSid, finalNumber, webhookUrl, subToken) const numData = await twilio.buyNumber(subSid, finalNumber, webhookUrl, subToken)
phoneSid = numData.sid as string phoneSid = numData.sid as string
// 4. Salva no banco // 4. Salva no banco (notification_channels é tabela tenant — sem tenant_id)
const channelData = { const channelData = {
tenant_id: tenantId,
owner_id: ownerId, owner_id: ownerId,
channel: 'whatsapp', channel: 'whatsapp',
provider: 'twilio', provider: 'twilio',
@@ -338,7 +361,7 @@ Deno.serve(async (req: Request) => {
let savedChannel let savedChannel
if (existing?.id) { if (existing?.id) {
const { data, error } = await supabaseAdmin const { data, error } = await tdb
.from('notification_channels') .from('notification_channels')
.update(channelData) .update(channelData)
.eq('id', existing.id) .eq('id', existing.id)
@@ -347,7 +370,7 @@ Deno.serve(async (req: Request) => {
if (error) throw error if (error) throw error
savedChannel = data savedChannel = data
} else { } else {
const { data, error } = await supabaseAdmin const { data, error } = await tdb
.from('notification_channels') .from('notification_channels')
.insert(channelData) .insert(channelData)
.select('*') .select('*')
@@ -377,30 +400,27 @@ Deno.serve(async (req: Request) => {
if (!channelId) return err('channel_id obrigatório', 400) if (!channelId) return err('channel_id obrigatório', 400)
try { try {
const { data: ch, error } = await supabaseAdmin const found = await findChannelById(channelId)
.from('notification_channels') if (!found) return err('Canal não encontrado', 404)
.select('*') const { tdb, channel: ch } = found
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400) if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const subToken = ch.credentials?.subaccount_auth_token as string const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
// 1. Libera número // 1. Libera número
if (ch.twilio_phone_sid && subToken) { if (ch.twilio_phone_sid && subToken) {
try { try {
await twilio.releaseNumber(ch.twilio_subaccount_sid, ch.twilio_phone_sid, subToken) await twilio.releaseNumber(ch.twilio_subaccount_sid as string, ch.twilio_phone_sid as string, subToken)
} catch (e) { } catch (e) {
console.warn('[deprovision] Erro ao liberar número (ignorado):', e.message) console.warn('[deprovision] Erro ao liberar número (ignorado):', (e as Error).message)
} }
} }
// 2. Fecha subconta // 2. Fecha subconta
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, 'closed') await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid as string, 'closed')
// 3. Soft-delete do canal // 3. Soft-delete do canal (tabela tenant)
const { error: delErr } = await supabaseAdmin const { error: delErr } = await tdb
.from('notification_channels') .from('notification_channels')
.update({ .update({
deleted_at: new Date().toISOString(), deleted_at: new Date().toISOString(),
@@ -427,18 +447,15 @@ Deno.serve(async (req: Request) => {
if (!channelId) return err('channel_id obrigatório', 400) if (!channelId) return err('channel_id obrigatório', 400)
try { try {
const { data: ch, error } = await supabaseAdmin const found = await findChannelById(channelId)
.from('notification_channels') if (!found) return err('Canal não encontrado', 404)
.select('*') const { tdb, channel: ch } = found
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400) if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
const twilioStatus = action === 'suspend' ? 'suspended' : 'active' const twilioStatus = action === 'suspend' ? 'suspended' : 'active'
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, twilioStatus) await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid as string, twilioStatus)
await supabaseAdmin await tdb
.from('notification_channels') .from('notification_channels')
.update({ .update({
is_active: action === 'reactivate', is_active: action === 'reactivate',
@@ -468,32 +485,42 @@ Deno.serve(async (req: Request) => {
const endDate = now.toISOString().split('T')[0] const endDate = now.toISOString().split('T')[0]
try { try {
// Busca todos os canais twilio (ou somente o especificado) // schema-per-tenant: notification_channels/logs e twilio_subaccount_usage
const query = supabaseAdmin // vivem no schema de cada tenant. Varremos todos os schemas provisionados
// (ou paramos no que contém channelId, se especificado).
const refs = await listTenantSchemas(supabaseAdmin)
const synced = []
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
// Canais twilio do schema (ou somente o especificado)
let chQuery = tdb
.from('notification_channels') .from('notification_channels')
.select('id, tenant_id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl') .select('id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl')
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('provider', 'twilio') .eq('provider', 'twilio')
.is('deleted_at', null) .is('deleted_at', null)
.not('twilio_subaccount_sid', 'is', null) .not('twilio_subaccount_sid', 'is', null)
if (channelId) query.eq('id', channelId) if (channelId) chQuery = chQuery.eq('id', channelId)
const { data: channels, error } = await query const { data: channels, error } = await chQuery
if (error) throw error if (error) {
console.warn(`[sync_usage] canais em ${ref.schema}:`, error.message)
const synced = [] continue
}
for (const ch of channels ?? []) { for (const ch of channels ?? []) {
try { try {
const subToken = ch.credentials?.subaccount_auth_token as string const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
if (!subToken) continue if (!subToken) continue
// Busca mensagens enviadas no mês via notification_logs // Busca mensagens enviadas no mês via notification_logs (tabela tenant)
const { data: logs } = await supabaseAdmin const { data: logs } = await tdb
.from('notification_logs') .from('notification_logs')
.select('status, estimated_cost_brl') .select('status, estimated_cost_brl')
.eq('tenant_id', ch.tenant_id)
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('provider', 'twilio') .eq('provider', 'twilio')
.gte('created_at', startDate) .gte('created_at', startDate)
@@ -506,11 +533,10 @@ Deno.serve(async (req: Request) => {
const costUsd = costBrl / usdBrlRate const costUsd = costBrl / usdBrlRate
const revBrl = sent * (ch.price_per_message_brl ?? 0) const revBrl = sent * (ch.price_per_message_brl ?? 0)
// Upsert no twilio_subaccount_usage // Upsert no twilio_subaccount_usage (tabela tenant)
const { error: upsertErr } = await supabaseAdmin const { error: upsertErr } = await tdb
.from('twilio_subaccount_usage') .from('twilio_subaccount_usage')
.upsert({ .upsert({
tenant_id: ch.tenant_id,
channel_id: ch.id, channel_id: ch.id,
twilio_subaccount_sid: ch.twilio_subaccount_sid, twilio_subaccount_sid: ch.twilio_subaccount_sid,
period_start: startDate, period_start: startDate,
@@ -530,10 +556,14 @@ Deno.serve(async (req: Request) => {
if (upsertErr) throw upsertErr if (upsertErr) throw upsertErr
synced.push({ channel_id: ch.id, sent, delivered, failed }) synced.push({ channel_id: ch.id, sent, delivered, failed })
} catch (e) { } catch (e) {
console.warn(`[sync_usage] canal ${ch.id}:`, e.message) console.warn(`[sync_usage] canal ${ch.id}:`, (e as Error).message)
} }
} }
// Se filtramos por channelId e já achamos, não precisa varrer o resto
if (channelId && synced.length > 0) break
}
return ok({ success: true, synced }) return ok({ success: true, synced })
} catch (e) { } catch (e) {
console.error('[sync_usage] erro:', e) console.error('[sync_usage] erro:', e)
@@ -588,32 +618,29 @@ Deno.serve(async (req: Request) => {
// Tenant pode testar o seu próprio canal; admin pode testar qualquer um // Tenant pode testar o seu próprio canal; admin pode testar qualquer um
try { try {
const { data: ch, error } = await supabaseAdmin const found = await findChannelById(channelId)
.from('notification_channels') if (!found) return err('Canal não encontrado', 404)
.select('*') const { channel: ch, ref } = found
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
// Verifica permissão: próprio tenant ou admin // Verifica permissão: próprio tenant (via ref.tenantId) ou admin
const admin = await isSaasAdmin() const admin = await isSaasAdmin()
if (!admin) { if (!admin) {
const { data: member } = await supabaseAdmin const { data: member } = await supabaseAdmin
.from('tenant_members') .from('tenant_members')
.select('tenant_id') .select('tenant_id')
.eq('tenant_id', ch.tenant_id) .eq('tenant_id', ref.tenantId)
.eq('user_id', user.id) .eq('user_id', user.id)
.maybeSingle() .maybeSingle()
if (!member) return err('Sem permissão', 403) if (!member) return err('Sem permissão', 403)
} }
const subToken = ch.credentials?.subaccount_auth_token as string const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
if (!ch.twilio_subaccount_sid || !subToken) return err('Canal não provisionado', 400) if (!ch.twilio_subaccount_sid || !subToken) return err('Canal não provisionado', 400)
const result = await twilio.sendWhatsApp( const result = await twilio.sendWhatsApp(
ch.twilio_subaccount_sid, ch.twilio_subaccount_sid as string,
subToken, subToken,
ch.twilio_phone_number, ch.twilio_phone_number as string,
toNumber, toNumber,
message message
) )
@@ -13,7 +13,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -42,10 +42,7 @@ Deno.serve(async (req: Request) => {
} }
try { try {
const supabase = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const url = new URL(req.url) const url = new URL(req.url)
const tenantId = url.searchParams.get('tenant_id') const tenantId = url.searchParams.get('tenant_id')
@@ -85,19 +82,26 @@ Deno.serve(async (req: Request) => {
: `Twilio status: ${messageStatus}` : `Twilio status: ${messageStatus}`
} }
const query = supabase // notification_logs vive no schema do tenant — precisamos do tenant_id da URL
// pra resolver o schema. Sem ele não há como localizar o log.
if (!tenantId) {
console.warn(`[webhook] ${messageSid}${messageStatus} sem tenant_id na URL; não atualiza log`)
} else {
try {
const tdb = await tenantDbForId(admin, tenantId)
const { error } = await tdb
.from('notification_logs') .from('notification_logs')
.update(updateData) .update(updateData)
.eq('provider_message_id', messageSid) .eq('provider_message_id', messageSid)
if (tenantId) query.eq('tenant_id', tenantId)
const { error } = await query
if (error) { if (error) {
console.error('[webhook] Erro ao atualizar log:', error.message) console.error('[webhook] Erro ao atualizar log:', error.message)
} else { } else {
console.log(`[webhook] ${messageSid}${messageStatus} (tenant: ${tenantId ?? 'unknown'})`) console.log(`[webhook] ${messageSid}${messageStatus} (tenant: ${tenantId})`)
}
} catch (e) {
console.error('[webhook] schema indisponível pra tenant', tenantId, ':', (e as Error).message)
}
} }
// Twilio espera 200 TwiML vazio ou texto simples // Twilio espera 200 TwiML vazio ou texto simples
@@ -25,7 +25,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 = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@@ -89,7 +90,6 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
interface ChannelRow { interface ChannelRow {
id: string id: string
tenant_id: string
owner_id: string owner_id: string
provider: string provider: string
credentials: Record<string, string> credentials: Record<string, string>
@@ -98,7 +98,7 @@ interface ChannelRow {
metadata: Record<string, unknown> | null metadata: Record<string, unknown> | null
} }
async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: Date): Promise<{ async function checkOneChannel(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, channel: ChannelRow, now: Date): Promise<{
tenant_id: string tenant_id: string
channel_id: string channel_id: string
previous_status: string | null previous_status: string | null
@@ -114,10 +114,10 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
if (!apiUrl || !apiKey || !instance) { if (!apiUrl || !apiKey || !instance) {
// Credencial incompleta — não alertamos, só marca error e segue // Credencial incompleta — não alertamos, só marca error e segue
await supa.from('notification_channels') await tdb.from('notification_channels')
.update({ connection_status: 'error', last_health_check: now.toISOString() }) .update({ connection_status: 'error', last_health_check: now.toISOString() })
.eq('id', channel.id) .eq('id', channel.id)
return { tenant_id: channel.tenant_id, channel_id: channel.id, previous_status: channel.connection_status, new_status: 'error', action: 'config_missing' } return { tenant_id: tenantId, channel_id: channel.id, previous_status: channel.connection_status, new_status: 'error', action: 'config_missing' }
} }
const base = rewriteForContainer(apiUrl) const base = rewriteForContainer(apiUrl)
@@ -160,10 +160,10 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
if (firstUnhealthyAtRaw) delete newMeta.first_unhealthy_at if (firstUnhealthyAtRaw) delete newMeta.first_unhealthy_at
patch.metadata = newMeta patch.metadata = newMeta
await supa.from('notification_channels').update(patch).eq('id', channel.id) await tdb.from('notification_channels').update(patch).eq('id', channel.id)
const { data: resolved } = await supa.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id }) const { data: resolved } = await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
return { return {
tenant_id: channel.tenant_id, tenant_id: tenantId,
channel_id: channel.id, channel_id: channel.id,
previous_status: channel.connection_status, previous_status: channel.connection_status,
new_status: newStatus, new_status: newStatus,
@@ -177,13 +177,13 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
newMeta.first_unhealthy_at = now.toISOString() newMeta.first_unhealthy_at = now.toISOString()
} }
patch.metadata = newMeta patch.metadata = newMeta
await supa.from('notification_channels').update(patch).eq('id', channel.id) await tdb.from('notification_channels').update(patch).eq('id', channel.id)
const minutesUnhealthy = firstUnhealthyAt ? (now.getTime() - firstUnhealthyAt.getTime()) / 60000 : 0 const minutesUnhealthy = firstUnhealthyAt ? (now.getTime() - firstUnhealthyAt.getTime()) / 60000 : 0
if (minutesUnhealthy < thresholdMinutes) { if (minutesUnhealthy < thresholdMinutes) {
return { return {
tenant_id: channel.tenant_id, tenant_id: tenantId,
channel_id: channel.id, channel_id: channel.id,
previous_status: channel.connection_status, previous_status: channel.connection_status,
new_status: newStatus, new_status: newStatus,
@@ -231,17 +231,17 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
cleanedMeta.heartbeat_reconnect_last_at = now.toISOString() cleanedMeta.heartbeat_reconnect_last_at = now.toISOString()
cleanedMeta.heartbeat_reconnect_count = (Number(cleanedMeta.heartbeat_reconnect_count) || 0) + 1 cleanedMeta.heartbeat_reconnect_count = (Number(cleanedMeta.heartbeat_reconnect_count) || 0) + 1
await supa.from('notification_channels').update({ await tdb.from('notification_channels').update({
connection_status: 'connected', connection_status: 'connected',
last_health_check: now.toISOString(), last_health_check: now.toISOString(),
metadata: cleanedMeta metadata: cleanedMeta
}).eq('id', channel.id) }).eq('id', channel.id)
// Resolve qualquer incident aberto desse channel (caso tenha sobrado de ciclo anterior) // Resolve qualquer incident aberto desse channel (caso tenha sobrado de ciclo anterior)
await supa.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id }) await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
return { return {
tenant_id: channel.tenant_id, tenant_id: tenantId,
channel_id: channel.id, channel_id: channel.id,
previous_status: channel.connection_status, previous_status: channel.connection_status,
new_status: 'connected', new_status: 'connected',
@@ -256,7 +256,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
// Marca tentativa (mesmo que falhou) pra respeitar o cooldown // Marca tentativa (mesmo que falhou) pra respeitar o cooldown
newMeta.heartbeat_reconnect_last_at = now.toISOString() newMeta.heartbeat_reconnect_last_at = now.toISOString()
await supa.from('notification_channels').update({ metadata: newMeta }).eq('id', channel.id) await tdb.from('notification_channels').update({ metadata: newMeta }).eq('id', channel.id)
} }
// Passou do threshold (e reconnect falhou / não tentou) — abre incident (idempotente) // Passou do threshold (e reconnect falhou / não tentou) — abre incident (idempotente)
@@ -265,7 +265,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
...(fetchError ? { error: fetchError } : {}), ...(fetchError ? { error: fetchError } : {}),
reconnect_attempted: reconnectAttempted reconnect_attempted: reconnectAttempted
} }
const { data: incidentId, error: incidentErr } = await supa.rpc('whatsapp_heartbeat_open_incident', { const { data: incidentId, error: incidentErr } = await tdb.rpc('whatsapp_heartbeat_open_incident', {
p_channel_id: channel.id, p_channel_id: channel.id,
p_kind: kind, p_kind: kind,
p_last_state: state || fetchError, p_last_state: state || fetchError,
@@ -274,7 +274,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
if (incidentErr) { if (incidentErr) {
return { return {
tenant_id: channel.tenant_id, tenant_id: tenantId,
channel_id: channel.id, channel_id: channel.id,
previous_status: channel.connection_status, previous_status: channel.connection_status,
new_status: newStatus, new_status: newStatus,
@@ -285,8 +285,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
const newIncidentId = incidentId as unknown as string const newIncidentId = incidentId as unknown as string
if (alertsEnabled && newIncidentId) { if (alertsEnabled && newIncidentId) {
await notifyChannelStakeholders(supa, { await notifyChannelStakeholders(tdb, admin, tenantId, {
tenant_id: channel.tenant_id,
channel_owner_id: channel.owner_id, channel_owner_id: channel.owner_id,
incident_id: newIncidentId, incident_id: newIncidentId,
channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'), channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
@@ -296,7 +295,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
} }
return { return {
tenant_id: channel.tenant_id, tenant_id: tenantId,
channel_id: channel.id, channel_id: channel.id,
previous_status: channel.connection_status, previous_status: channel.connection_status,
new_status: newStatus, new_status: newStatus,
@@ -306,16 +305,15 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
} }
} }
async function notifyChannelStakeholders(supa: SupabaseClient, params: { async function notifyChannelStakeholders(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, params: {
tenant_id: string
channel_owner_id: string channel_owner_id: string
incident_id: string incident_id: string
channel_display: string channel_display: string
kind: string kind: string
minutes_unhealthy: number minutes_unhealthy: number
}): Promise<void> { }): Promise<void> {
// Checa se já notificou esse incident // Checa se já notificou esse incident (tenant → tdb)
const { data: incident } = await supa const { data: incident } = await tdb
.from('whatsapp_connection_incidents') .from('whatsapp_connection_incidents')
.select('notified_at, notification_count') .select('notified_at, notification_count')
.eq('id', params.incident_id) .eq('id', params.incident_id)
@@ -329,10 +327,11 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
const userIds = new Set<string>() const userIds = new Set<string>()
if (params.channel_owner_id) userIds.add(params.channel_owner_id) if (params.channel_owner_id) userIds.add(params.channel_owner_id)
const { data: admins } = await supa // tenant_members é GLOBAL → admin, mantém filtro por tenant_id
const { data: admins } = await admin
.from('tenant_members') .from('tenant_members')
.select('user_id') .select('user_id')
.eq('tenant_id', params.tenant_id) .eq('tenant_id', tenantId)
.in('role', ['clinic_admin', 'tenant_admin']) .in('role', ['clinic_admin', 'tenant_admin'])
.eq('status', 'active') .eq('status', 'active')
@@ -353,7 +352,6 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
const rows = Array.from(userIds).map((uid) => ({ const rows = Array.from(userIds).map((uid) => ({
owner_id: uid, owner_id: uid,
tenant_id: params.tenant_id,
type: 'system_alert', type: 'system_alert',
ref_id: params.incident_id, ref_id: params.incident_id,
ref_table: 'whatsapp_connection_incidents', ref_table: 'whatsapp_connection_incidents',
@@ -365,27 +363,32 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
} }
})) }))
await supa.from('notifications').insert(rows) // notifications é tenant → tdb (sem tenant_id no payload)
await supa.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id }) await tdb.from('notifications').insert(rows)
await tdb.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
} }
Deno.serve(async (req) => { Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const supa = createClient( const admin = adminClient()
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { autoRefreshToken: false, persistSession: false } }
)
try { try {
// Canal específico (on-demand via UI do tenant) ou varredura completa // Canal específico (on-demand via UI do tenant) ou varredura completa.
// O channel_id existe em apenas um schema; aplicamos o filtro em cada
// tenant e só o schema dono retorna a linha.
const url = new URL(req.url) const url = new URL(req.url)
const singleChannelId = url.searchParams.get('channel_id') const singleChannelId = url.searchParams.get('channel_id')
let query = supa const now = new Date()
const tasks: Array<Promise<Awaited<ReturnType<typeof checkOneChannel>>>> = []
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
let query = tdb
.from('notification_channels') .from('notification_channels')
.select('id, tenant_id, owner_id, provider, credentials, connection_status, last_health_check, metadata') .select('id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
.eq('provider', 'evolution_api') .eq('provider', 'evolution_api')
.eq('channel', 'whatsapp') .eq('channel', 'whatsapp')
.eq('is_active', true) .eq('is_active', true)
@@ -394,23 +397,29 @@ Deno.serve(async (req) => {
if (singleChannelId) query = query.eq('id', singleChannelId) if (singleChannelId) query = query.eq('id', singleChannelId)
const { data: channels, error: fetchErr } = await query const { data: channels, error: fetchErr } = await query
if (fetchErr) {
if (fetchErr) return json({ error: fetchErr.message }, 500) console.error(`[heartbeat] channels query error (tenant ${t.tenantId}):`, fetchErr.message)
if (!channels || channels.length === 0) { continue
return json({ checked: 0, results: [] })
} }
for (const ch of channels || []) {
const now = new Date() tasks.push(
const results = await Promise.all( checkOneChannel(tdb, admin, t.tenantId, ch as ChannelRow, now).catch((e) => ({
channels.map((ch) => checkOneChannel(supa, ch as ChannelRow, now).catch((e) => ({ tenant_id: t.tenantId,
tenant_id: (ch as ChannelRow).tenant_id,
channel_id: (ch as ChannelRow).id, channel_id: (ch as ChannelRow).id,
previous_status: (ch as ChannelRow).connection_status, previous_status: (ch as ChannelRow).connection_status,
new_status: 'error', new_status: 'error',
action: 'fetch_error' as const, action: 'fetch_error' as const,
error: (e as Error).message error: (e as Error).message
}))) }))
) )
}
}
if (tasks.length === 0) {
return json({ checked: 0, results: [] })
}
const results = await Promise.all(tasks)
const summary = { const summary = {
checked: results.length, checked: results.length,