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 });
const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
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(sendingReminder.value).toBe(false);
});
@@ -471,7 +471,7 @@ export function useAgendaEventLifecycle({
sendingReminder.value = true;
try {
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) {
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 —
| Evolution envia direto via API (sem deducao de credito), Twilio envolve
| o envio em deducao atomica com rollback.
|
| Schema-per-tenant: tabelas tenant (conversation_*, agenda_regras_semanais)
| são acessadas via `tdb` (client ligado ao schema tenant_<slug>, SEM coluna
| tenant_id). RPCs de crédito (deduct/add_whatsapp_credits) e tenant_members
| são globais → usam `supa` (public) + p_tenant_id explícito.
|--------------------------------------------------------------------------
*/
@@ -32,22 +37,21 @@ export function normalizeForMatch(s: string | null | undefined): string {
}
// ═══════════════════════════════════════════════════════════════════════
// Opt-out (LGPD)
// Opt-out (LGPD) — tabelas tenant via `tdb`
// ═══════════════════════════════════════════════════════════════════════
export async function detectOptoutKeyword(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
body: string | null
): Promise<string | null> {
if (!body) return null
const normalized = normalizeForMatch(body)
if (!normalized) return null
const { data } = await supa
// keywords de sistema + do tenant já vivem no schema do tenant (seed do template)
const { data } = await tdb
.from('conversation_optout_keywords')
.select('keyword')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
.eq('enabled', true)
if (!data || !data.length) return null
@@ -62,11 +66,10 @@ export async function detectOptoutKeyword(
return null
}
export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone: string): Promise<boolean> {
const { data } = await supa
export async function isOptedOut(tdb: SupabaseClient, phone: string): Promise<boolean> {
const { data } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.limit(1)
@@ -76,8 +79,7 @@ export async function isOptedOut(supa: SupabaseClient, tenantId: string, phone:
const OPT_IN_KEYWORDS = ['voltar', 'retornar', 'reativar', 'restart']
export async function maybeOptIn(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
phone: string,
body: string | null
): Promise<boolean> {
@@ -86,10 +88,9 @@ export async function maybeOptIn(
if (!normalized) return false
for (const kw of OPT_IN_KEYWORDS) {
if (normalized === kw || new RegExp(`(^|\\s)${kw}(\\s|$)`).test(normalized)) {
const { data } = await supa
const { data } = await tdb
.from('conversation_optouts')
.update({ opted_back_in_at: new Date().toISOString() })
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.select('id')
@@ -101,8 +102,7 @@ export async function maybeOptIn(
}
export async function registerOptout(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
phone: string,
patientId: string | null,
originalMessage: string | null,
@@ -110,18 +110,16 @@ export async function registerOptout(
provider: ProviderLabel,
sendFn: SendFn
): Promise<void> {
const { data: existing } = await supa
const { data: existing } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.maybeSingle()
if (existing) return
await supa.from('conversation_optouts').insert({
tenant_id: tenantId,
await tdb.from('conversation_optouts').insert({
phone,
patient_id: patientId,
source: 'keyword',
@@ -133,8 +131,7 @@ export async function registerOptout(
try {
const res = await sendFn(phone, ackText)
if (res.ok) {
await supa.from('conversation_messages').insert({
tenant_id: tenantId,
await tdb.from('conversation_messages').insert({
patient_id: patientId,
channel: 'whatsapp',
direction: 'outbound',
@@ -156,7 +153,7 @@ export async function registerOptout(
}
// ═══════════════════════════════════════════════════════════════════════
// Auto-reply (schedule-aware, cooldown, respeita opt-out)
// Auto-reply (schedule-aware, cooldown, respeita opt-out) — via `tdb`
// ═══════════════════════════════════════════════════════════════════════
export type ScheduleWindow = { dow: number; start: string; end: string }
@@ -197,11 +194,10 @@ function isWithinWindows(windows: ScheduleWindow[]): boolean {
return false
}
async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promise<ScheduleWindow[]> {
const { data, error } = await supa
async function windowsFromAgenda(tdb: SupabaseClient): Promise<ScheduleWindow[]> {
const { data, error } = await tdb
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true)
if (error || !data) return []
return data.map((r) => ({
@@ -212,8 +208,7 @@ async function windowsFromAgenda(supa: SupabaseClient, tenantId: string): Promis
}
export async function maybeSendAutoReply(
supa: SupabaseClient,
tenantId: string,
tdb: SupabaseClient,
threadKey: string,
fromPhone: string | null,
provider: ProviderLabel,
@@ -221,21 +216,20 @@ export async function maybeSendAutoReply(
): Promise<{ sent: boolean; reason?: string }> {
if (!fromPhone) return { sent: false, reason: 'no_phone' }
if (await isOptedOut(supa, tenantId, fromPhone)) {
if (await isOptedOut(tdb, fromPhone)) {
return { sent: false, reason: 'opted_out' }
}
const { data: settings } = await supa
const { data: settings } = await tdb
.from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle()
if (!settings || !settings.enabled) return { sent: false, reason: 'disabled' }
let withinHours = false
if (settings.schedule_mode === 'agenda') {
const windows = await windowsFromAgenda(supa, tenantId)
const windows = await windowsFromAgenda(tdb)
withinHours = isWithinWindows(windows)
} else if (settings.schedule_mode === 'business_hours') {
withinHours = isWithinWindows((settings.business_hours as ScheduleWindow[]) || [])
@@ -247,10 +241,9 @@ export async function maybeSendAutoReply(
if ((settings.cooldown_minutes ?? 0) > 0) {
const cutoff = new Date(Date.now() - settings.cooldown_minutes * 60 * 1000).toISOString()
const { data: recent } = await supa
const { data: recent } = await tdb
.from('conversation_autoreply_log')
.select('sent_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.gte('sent_at', cutoff)
.order('sent_at', { ascending: false })
@@ -265,8 +258,7 @@ export async function maybeSendAutoReply(
return { sent: false, reason: 'send_failed' }
}
await supa.from('conversation_messages').insert({
tenant_id: tenantId,
await tdb.from('conversation_messages').insert({
channel: 'whatsapp',
direction: 'outbound',
from_number: null,
@@ -279,8 +271,7 @@ export async function maybeSendAutoReply(
responded_at: new Date().toISOString()
})
await supa.from('conversation_autoreply_log').insert({
tenant_id: tenantId,
await tdb.from('conversation_autoreply_log').insert({
thread_key: threadKey
})
@@ -289,6 +280,7 @@ export async function maybeSendAutoReply(
// ═══════════════════════════════════════════════════════════════════════
// Twilio: send wrapper com deducao de credito + rollback
// Créditos são GLOBAIS (addon) → RPC via `supa` (public) + p_tenant_id.
// ═══════════════════════════════════════════════════════════════════════
export type TwilioChannel = {
@@ -333,7 +325,7 @@ async function sendViaTwilioRaw(
}
// Cria SendFn que:
// 1) deduz 1 credito do tenant via RPC atomica
// 1) deduz 1 credito do tenant via RPC atomica (GLOBAL)
// 2) envia via Twilio; se falhar, refunda o credito
// 3) retorna resultado ao caller
export function makeTwilioCreditedSendFn(
@@ -373,6 +365,7 @@ export function makeTwilioCreditedSendFn(
// ════════════════════════════════════════════════════════════════════════════
// Bot de auto-triagem (3.7)
// Tabelas conversation_* via `tdb`; pickAnyAdmin (tenant_members) via `supa`.
// ════════════════════════════════════════════════════════════════════════════
type BotStep = { prompt: string; variable: string; type?: string }
@@ -397,6 +390,7 @@ type BotConfig = {
* - não é opt-out
*/
export async function maybeProcessBot(
tdb: SupabaseClient,
supa: SupabaseClient,
tenantId: string,
threadKey: string,
@@ -408,10 +402,9 @@ export async function maybeProcessBot(
const text = String(body || '').trim()
// Carrega config
const { data: cfg } = await supa
const { data: cfg } = await tdb
.from('conversation_bots')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle()
if (!cfg || !cfg.enabled) return { processed: false, reason: 'disabled' }
@@ -421,39 +414,36 @@ export async function maybeProcessBot(
}
// Busca sessão ativa
const { data: active } = await supa
const { data: active } = await tdb
.from('conversation_bot_sessions')
.select('*')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.eq('status', 'active')
.maybeSingle()
if (active) {
// Se humano já atribuiu a thread, abandona bot
const { data: assign } = await supa
const { data: assign } = await tdb
.from('conversation_assignments')
.select('assigned_to')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.maybeSingle()
if (assign?.assigned_to) {
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({ status: 'abandoned_manual', abandoned_at: new Date().toISOString() })
.eq('id', active.id)
return { processed: false, reason: 'human_took_over' }
}
return await advanceSession(supa, config, active, text, phone, sendFn)
return await advanceSession(tdb, supa, tenantId, config, active, text, phone, sendFn)
}
// Sem sessão ativa — decide se inicia
if (config.trigger_mode === 'new_contact') {
// Inicia só se ainda não existe nenhuma sessão (completada ou abandonada) pra essa thread
const { data: prev } = await supa
const { data: prev } = await tdb
.from('conversation_bot_sessions')
.select('id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.limit(1)
.maybeSingle()
@@ -468,21 +458,19 @@ export async function maybeProcessBot(
// 'all_unassigned' passa direto
// Inicia nova sessão
return await startSession(supa, config, tenantId, threadKey, phone, sendFn)
return await startSession(tdb, config, threadKey, phone, sendFn)
}
async function startSession(
supa: SupabaseClient,
tdb: SupabaseClient,
config: BotConfig,
tenantId: string,
threadKey: string,
phone: string,
sendFn: SendFn
): Promise<{ processed: boolean; status?: string; step?: number }> {
const { data: session, error: sessErr } = await supa
const { data: session, error: sessErr } = await tdb
.from('conversation_bot_sessions')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
contact_number: phone,
current_step: 0,
@@ -503,9 +491,11 @@ async function startSession(
}
async function advanceSession(
tdb: SupabaseClient,
supa: SupabaseClient,
tenantId: string,
config: BotConfig,
session: { id: string, current_step: number, collected_data: Record<string, unknown>, tenant_id: string, thread_key: string, contact_number: string | null },
session: { id: string, current_step: number, collected_data: Record<string, unknown>, thread_key: string, contact_number: string | null },
text: string,
phone: string,
sendFn: SendFn
@@ -514,7 +504,7 @@ async function advanceSession(
const currentStep = config.steps[step]
if (!currentStep) {
// Segurança: step fora do range → encerra
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({ status: 'completed', completed_at: new Date().toISOString() })
.eq('id', session.id)
return { processed: true, status: 'completed', step }
@@ -527,7 +517,7 @@ async function advanceSession(
if (isLast) {
// Finaliza
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({
collected_data: newData,
current_step: nextStep,
@@ -547,13 +537,12 @@ async function advanceSession(
return `${s.variable}: ${val}`
})
const summary = `🤖 Triagem automática concluída:\n\n${lines.join('\n')}`
await supa.from('conversation_notes').insert({
tenant_id: session.tenant_id,
await tdb.from('conversation_notes').insert({
thread_key: session.thread_key,
contact_number: session.contact_number,
body: summary,
// created_by obrigatório — usa um user "bot" fictício? Não temos. Pega qualquer admin.
created_by: await pickAnyAdmin(supa, session.tenant_id)
// created_by obrigatório — usa qualquer admin do tenant (tenant_members é global)
created_by: await pickAnyAdmin(supa, tenantId)
})
} catch (err) {
console.warn('[bot] failed to create summary note:', (err as Error)?.message)
@@ -563,7 +552,7 @@ async function advanceSession(
}
// Avança pra próxima pergunta
await supa.from('conversation_bot_sessions')
await tdb.from('conversation_bot_sessions')
.update({
collected_data: newData,
current_step: nextStep,
@@ -588,4 +577,3 @@ async function pickAnyAdmin(supa: SupabaseClient, tenantId: string): Promise<str
.maybeSingle()
return (data?.user_id as string) ?? '00000000-0000-0000-0000-000000000000'
}
@@ -11,7 +11,7 @@
| 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 = {
'Access-Control-Allow-Origin': '*',
@@ -39,10 +39,11 @@ Deno.serve(async (req: Request) => {
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
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);
@@ -55,7 +56,7 @@ Deno.serve(async (req: Request) => {
if (!apiKey) return json({ ok: false, error: 'api_key_missing' }, 403);
// 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.cancelled_at) return json({ ok: true, already_cancelled: true });
@@ -30,7 +30,7 @@
| 500 — erro Asaas
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import { adminClient, tenantDbForId } from '../_shared/tenant.ts';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -63,13 +63,13 @@ Deno.serve(async (req: Request) => {
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
const { data: settings } = await supa
const { data: settings } = await tdb
.from('payment_settings')
.select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod')
.eq('tenant_id', tenantId)
.maybeSingle();
if (!settings?.asaas_enabled) {
@@ -87,11 +87,10 @@ Deno.serve(async (req: Request) => {
}
// 2. Lê financial_record + patient
const { data: record } = await supa
const { data: record } = await tdb
.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('tenant_id', tenantId)
.is('deleted_at', null)
.maybeSingle();
@@ -101,7 +100,7 @@ Deno.serve(async (req: Request) => {
}
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')
.select('id, nome_completo, email_principal, telefone, cpf')
.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)
// TODO Fase B: chamar Edge Function asaas-create-customer-patient ou inline upsert.
// Por ora, busca cache local — se não existe, retorna erro.
let { data: customer } = await supa
let { data: customer } = await tdb
.from('asaas_customers')
.select('id, asaas_customer_id')
.eq('tenant_id', tenantId)
.eq('patient_id', patient.id)
.eq('environment', environment)
.is('deleted_at', null)
@@ -9,7 +9,7 @@
| ⚠️ 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 = {
'Access-Control-Allow-Origin': '*',
@@ -36,9 +36,10 @@ Deno.serve(async (req: Request) => {
const asaasPaymentId = String(body.asaas_payment_id || '');
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);
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 = {
'Access-Control-Allow-Origin': '*',
@@ -59,10 +59,7 @@ Deno.serve(async (req: Request) => {
if (!paymentId) return json({ ok: true, skipped: 'no_payment_id' })
const supa = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const supa = adminClient()
// Localiza purchase (prefere externalReference = purchase.id)
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 = {
'Access-Control-Allow-Origin': '*',
@@ -38,7 +39,6 @@ function json(body: unknown, status = 200) {
}
type Rule = {
tenant_id: string
enabled: boolean
threshold_minutes: number
respect_business_hours: boolean
@@ -160,7 +160,7 @@ function businessMinutesElapsed(
// 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
candidates: number
opened: number
@@ -170,11 +170,10 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
// Query candidatas: threads onde:
// - última mensagem é INBOUND
// - (se assigned_only) assigned_to IS NOT NULL
// Vou usar a view conversation_threads + filtro direction='inbound'.
let query = supa
// Vou usar a view conversation_threads (tenant) + filtro direction='inbound'.
let query = tdb
.from('conversation_threads')
.select('tenant_id, thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction')
.eq('tenant_id', rule.tenant_id)
.select('thread_key, patient_id, patient_name, contact_number, assigned_to, last_message_at, last_message_direction')
.eq('last_message_direction', 'inbound')
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
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
@@ -203,9 +202,9 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
continue
}
// Abre breach (idempotente)
const { data: breachId, error: openErr } = await supa.rpc('sla_open_breach', {
p_tenant_id: rule.tenant_id,
// Abre breach (idempotente). RPC mantém p_tenant_id (F6 reescreve depois).
const { data: breachId, error: openErr } = await admin.rpc('sla_open_breach', {
p_tenant_id: tenantId,
p_thread_key: row.thread_key,
p_assigned_to: row.assigned_to,
p_last_inbound_at: last,
@@ -216,9 +215,8 @@ async function processRule(supa: SupabaseClient, rule: Rule, now: Date): Promise
opened++
// 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,
tenant_id: rule.tenant_id,
thread_key: row.thread_key,
patient_name: row.patient_name || row.contact_number || 'Paciente desconhecido',
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++
}
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
tenant_id: string
thread_key: string
patient_name: string
assigned_to: string | null
@@ -242,8 +239,8 @@ async function notifyBreach(supa: SupabaseClient, params: {
elapsed_minutes: number
threshold_minutes: number
}): Promise<boolean> {
// Anti-spam: não renotifica se já notificou
const { data: breach } = await supa
// Anti-spam: não renotifica se já notificou (breach é tenant → tdb)
const { data: breach } = await tdb
.from('conversation_sla_breaches')
.select('notified_at')
.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.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')
.select('user_id')
.eq('tenant_id', params.tenant_id)
.eq('tenant_id', tenantId)
.in('role', ['clinic_admin', 'tenant_admin'])
.eq('status', 'active')
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) => ({
owner_id: uid,
tenant_id: params.tenant_id,
type: 'system_alert',
ref_id: params.breach_id,
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
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
}
@@ -299,31 +297,41 @@ async function notifyBreach(supa: SupabaseClient, params: {
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const supa = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { autoRefreshToken: false, persistSession: false } }
)
const admin = adminClient()
try {
const now = new Date()
// Regras habilitadas
const { data: rules, error: rulesErr } = await supa
.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')
.eq('enabled', true)
// Varre todos os tenants; cada schema tem suas próprias sla_rules (tenant)
const tasks: Array<Promise<{ tenant_id: string; candidates: number; opened: number; still_pending: number; notified: number }>> = []
if (rulesErr) return json({ error: rulesErr.message }, 500)
if (!rules || rules.length === 0) return json({ checked: 0, results: [] })
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
const results = await Promise.all(
rules.map((r) => processRule(supa, r as Rule, now).catch((e) => ({
tenant_id: (r as Rule).tenant_id,
candidates: 0, opened: 0, still_pending: 0, notified: 0,
error: (e as Error).message
})))
)
// Regras habilitadas do tenant (tabela tenant → tdb, sem tenant_id)
const { data: rules, error: rulesErr } = await tdb
.from('conversation_sla_rules')
.select('enabled, threshold_minutes, respect_business_hours, business_hours_start, business_hours_end, business_days, alert_scope, notify_admin_on_breach')
.eq('enabled', true)
if (rulesErr) {
console.error(`[sla] rules query error (tenant ${t.tenantId}):`, rulesErr.message)
continue
}
for (const r of rules || []) {
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,
error: (e as Error).message
}))
)
}
}
if (tasks.length === 0) return json({ checked: 0, results: [] })
const results = await Promise.all(tasks)
const summary = {
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 = {
'Access-Control-Allow-Origin': '*',
@@ -37,48 +37,56 @@ Deno.serve(async (req: Request) => {
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 supa = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const admin = adminClient()
const cutoff = new Date(Date.now() - idleMinutes * 60 * 1000).toISOString()
// Busca candidatos: in_progress, last_progress_at antigo, tem minimo nome OU telefone
const { data: candidates, error: fetchErr } = await supa
.from('patient_intake_requests')
.select('id, nome_completo, telefone, email_principal')
.eq('status', 'in_progress')
.lt('last_progress_at', cutoff)
if (fetchErr) return json({ error: fetchErr.message }, 500)
const eligible = (candidates || []).filter((c) => c.nome_completo || c.telefone)
if (eligible.length === 0) {
return json({ checked: candidates?.length || 0, converted: 0, errors: 0 })
}
let checked = 0
let eligibleCount = 0
let converted = 0
let errors = 0
const results: Array<{ intake_id: string; ok: boolean; error?: string }> = []
const results: Array<{ tenant_id: string; intake_id: string; ok: boolean; error?: string }> = []
for (const row of eligible) {
const { error: rpcErr } = await supa.rpc('convert_abandoned_intake_to_lead', {
p_intake_id: row.id
})
if (rpcErr) {
errors++
results.push({ intake_id: row.id, ok: false, error: rpcErr.message })
} else {
converted++
results.push({ intake_id: row.id, ok: true })
// 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
const { data: candidates, error: fetchErr } = await tdb
.from('patient_intake_requests')
.select('id, nome_completo, telefone, email_principal')
.eq('status', 'in_progress')
.lt('last_progress_at', cutoff)
if (fetchErr) {
console.error(`[convert-abandoned-intakes] fetch error (tenant ${t.tenantId}):`, fetchErr.message)
continue
}
checked += candidates?.length || 0
const eligible = (candidates || []).filter((c) => c.nome_completo || c.telefone)
eligibleCount += eligible.length
for (const row of eligible) {
// 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
})
if (rpcErr) {
errors++
results.push({ tenant_id: t.tenantId, intake_id: row.id, ok: false, error: rpcErr.message })
} else {
converted++
results.push({ tenant_id: t.tenantId, intake_id: row.id, ok: true })
}
}
}
return json({
checked: candidates?.length || 0,
eligible: eligible.length,
checked,
eligible: eligibleCount,
converted,
errors,
idle_minutes: idleMinutes,
@@ -11,6 +11,7 @@
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, listTenantSchemas } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -48,20 +49,29 @@ Deno.serve(async (req: Request) => {
const userId = authData.user.id
// Service role pra bypass RLS
const supaSvc = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const supaSvc = adminClient()
// Busca o canal
const { data: channel, error: chErr } = await supaSvc
.from('notification_channels')
.select('id, tenant_id')
.eq('id', channelId)
.is('deleted_at', null)
.maybeSingle()
// schema-per-tenant: notification_channels vive no schema do tenant (sem
// 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')
.select('id')
.eq('id', channelId)
.is('deleted_at', null)
.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
const { data: isAdmin } = await supaSvc.rpc('is_saas_admin')
@@ -70,7 +80,7 @@ Deno.serve(async (req: Request) => {
const { data: membership } = await supaSvc
.from('tenant_members')
.select('id')
.eq('tenant_id', channel.tenant_id)
.eq('tenant_id', tenantId)
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
@@ -78,8 +88,8 @@ Deno.serve(async (req: Request) => {
}
if (!authorized) return json({ ok: false, error: 'forbidden' }, 403)
// Desativa (soft-delete)
const { error: updErr } = await supaSvc
// Desativa (soft-delete) — tabela tenant
const { error: updErr } = await tdb
.from('notification_channels')
.update({
is_active: false,
@@ -28,6 +28,7 @@ import {
registerOptout,
type SendFn
} from '../_shared/whatsapp-hooks.ts'
import { tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -136,12 +137,11 @@ function base64ToBytes(b64: string): Uint8Array {
type EvolutionCreds = { apiUrl: string; apiKey: string; instance: string }
// Busca credenciais Evolution do tenant em notification_channels
async function getTenantEvolutionCreds(supa: SupabaseClient, tenantId: string): Promise<EvolutionCreds | null> {
const { data: channel, error } = await supa
// Busca credenciais Evolution do tenant em notification_channels (schema do tenant)
async function getTenantEvolutionCreds(tdb: SupabaseClient): Promise<EvolutionCreds | null> {
const { data: channel, error } = await tdb
.from('notification_channels')
.select('credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp')
.is('deleted_at', null)
.maybeSingle()
@@ -259,6 +259,7 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const tdb = await tenantDbForId(supabase, tenantId)
const payload = await req.json().catch(() => null)
if (!payload || typeof payload !== 'object') {
@@ -292,10 +293,9 @@ Deno.serve(async (req: Request) => {
patch.read_by_recipient_at = new Date().toISOString()
patch.delivered_at = patch.delivered_at ?? new Date().toISOString()
}
await supabase
await tdb
.from('conversation_messages')
.update(patch)
.eq('tenant_id', tenantId)
.eq('provider_message_id', msgId)
.eq('direction', 'outbound')
}
@@ -340,7 +340,7 @@ Deno.serve(async (req: Request) => {
let storedMediaUrl: string | null = parts.mediaUrl
let mediaError: string | null = null
if (parts.hasEncryptedMedia && messageObj && parts.mediaMime) {
const creds = await getTenantEvolutionCreds(supabase, tenantId)
const creds = await getTenantEvolutionCreds(tdb)
if (!creds) {
mediaError = 'creds_not_found'
storedMediaUrl = null
@@ -391,10 +391,9 @@ Deno.serve(async (req: Request) => {
// Dedup outbound echo
if (fromMe && messageId) {
const { data: existing } = await supabase
const { data: existing } = await tdb
.from('conversation_messages')
.select('id')
.eq('tenant_id', tenantId)
.eq('provider_message_id', messageId)
.eq('direction', 'outbound')
.maybeSingle()
@@ -410,8 +409,7 @@ Deno.serve(async (req: Request) => {
const direction = fromMe ? 'outbound' : 'inbound'
const kanbanStatus = fromMe ? 'awaiting_patient' : 'awaiting_us'
const { error: insErr } = await supabase.from('conversation_messages').insert({
tenant_id: tenantId,
const { error: insErr } = await tdb.from('conversation_messages').insert({
patient_id: patientId,
channel: 'whatsapp',
direction,
@@ -438,13 +436,13 @@ Deno.serve(async (req: Request) => {
if (!fromMe && !insErr && fromPhone) {
// 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
? (phone, text) => sendViaEvolution(creds, phone, text)
: async () => ({ ok: false, error: 'creds_missing' })
try {
const optedBackIn = await maybeOptIn(supabase, tenantId, fromPhone, cleanBody)
const optedBackIn = await maybeOptIn(tdb, fromPhone, cleanBody)
if (optedBackIn) optoutAction = 'in'
} catch (err) {
console.error('[optout] opt-in check error:', err)
@@ -452,9 +450,9 @@ Deno.serve(async (req: Request) => {
if (!optoutAction) {
try {
const keyword = await detectOptoutKeyword(supabase, tenantId, cleanBody)
const keyword = await detectOptoutKeyword(tdb, cleanBody)
if (keyword) {
await registerOptout(supabase, tenantId, fromPhone, patientId, cleanBody, keyword, 'evolution', sendFn)
await registerOptout(tdb, fromPhone, patientId, cleanBody, keyword, 'evolution', sendFn)
optoutAction = 'out'
}
} 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
// pra evitar resposta duplicada.
try {
botResult = await maybeProcessBot(supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn)
botResult = await maybeProcessBot(tdb, supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn)
} catch (err) {
console.error('[bot] unexpected error:', err)
}
if (!botResult?.processed) {
try {
autoReplyResult = await maybeSendAutoReply(supabase, tenantId, threadKey, fromPhone, 'evolution', sendFn)
autoReplyResult = await maybeSendAutoReply(tdb, threadKey, fromPhone, 'evolution', sendFn)
} catch (err) {
console.error('[auto-reply] unexpected error:', err)
}
+139 -43
View File
@@ -7,17 +7,31 @@
|
| Runtime: Deno (Supabase Edge Functions)
| 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 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) => {
@@ -58,6 +72,9 @@ Deno.serve(async (req) => {
* Eventos relevantes:
* - messages.update: status de entrega (enviado, entregue, lido)
* - 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) {
// Validação básica da API key
@@ -96,7 +113,8 @@ async function handleEvolutionWebhook (req, body) {
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 })
}
@@ -152,6 +170,11 @@ function handleMetaVerification (url) {
/**
* 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) {
const entries = body.entry || []
@@ -162,6 +185,15 @@ async function handleMetaWebhook (body) {
for (const change of changes) {
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 ────
if (value.statuses) {
for (const st of value.statuses) {
@@ -171,7 +203,12 @@ async function handleMetaWebhook (body) {
if (messageId && 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)) {
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)
@@ -204,10 +242,8 @@ async function handleMetaWebhook (body) {
// ── Helpers compartilhados ──────────────────────────────────
/**
* Atualiza o status no notification_logs com base no provider_message_id.
*/
async function updateLogStatus (providerMessageId, status, failureReason) {
/** Monta o patch de notification_logs a partir do status mapeado. */
function buildLogPatch (status, failureReason) {
const now = new Date().toISOString()
const updateData = { provider_status: status }
@@ -229,60 +265,120 @@ async function updateLogStatus (providerMessageId, status, failureReason) {
updateData.failure_reason = failureReason || 'Falha reportada pelo provedor'
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')
.update(updateData)
.eq('provider_message_id', providerMessageId)
.select('id')
if (error) {
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.
* @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
const cleanPhone = String(phone).replace(/\D/g, '')
if (!cleanPhone) return
// Busca paciente(s) com esse telefone
const { data: patients } = await supabase
.from('patients')
.select('id, owner_id, telefone')
.or(`telefone.like.%${cleanPhone}%`)
const tenants = tenantsOverride ?? await listTenantSchemas(admin)
let matched = 0
if (!patients || patients.length === 0) {
console.warn(`[opt-out] Nenhum paciente encontrado para ${cleanPhone}`)
return
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')
.select('id, owner_id, telefone')
.or(`telefone.like.%${cleanPhone}%`)
if (patErr) {
console.warn(`[opt-out] erro buscando paciente no schema ${t.schema}:`, patErr.message)
continue
}
if (!patients || patients.length === 0) continue
for (const patient of patients) {
matched++
// Atualiza preferência (o trigger cancela pendentes automaticamente)
const { error } = await tdb
.from('notification_preferences')
.upsert({
owner_id: patient.owner_id,
patient_id: patient.id,
whatsapp_opt_in: false,
lgpd_opt_out_date: new Date().toISOString(),
lgpd_opt_out_reason: 'Paciente respondeu SAIR no WhatsApp',
}, {
onConflict: 'owner_id,patient_id',
ignoreDuplicates: false,
})
if (error) {
console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id} (schema ${t.schema}):`, error.message)
} else {
console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id} (schema ${t.schema})`)
}
}
}
for (const patient of patients) {
// Atualiza preferência (o trigger cancela pendentes automaticamente)
const { error } = await supabase
.from('notification_preferences')
.upsert({
owner_id: patient.owner_id,
tenant_id: patient.owner_id, // será ajustado pelo context
patient_id: patient.id,
whatsapp_opt_in: false,
lgpd_opt_out_date: new Date().toISOString(),
lgpd_opt_out_reason: 'Paciente respondeu SAIR no WhatsApp',
}, {
onConflict: 'owner_id,patient_id',
ignoreDuplicates: false,
})
if (error) {
console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id}:`, error.message)
} else {
console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id}`)
}
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'.
|
| 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:
| 1. Busca pendentes (channel='email', status='pendente', scheduled_at <= now)
| 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)
| 5. Renderiza variáveis e condicionais {{#if}}
| 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'
|--------------------------------------------------------------------------
*/
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 { adminClient, listTenantSchemas, tenantDbForId, schemaForTenant } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -112,22 +118,15 @@ async function sendEmail(
return { messageId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }
}
// ── Main handler ───────────────────────────────────────────────
// ── Processa a fila de UM tenant ───────────────────────────────
Deno.serve(async (req: Request) => {
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')!
)
type Result = { id: string; status: string; error?: string }
async function processTenantQueue(admin: SupabaseClient, tdb: SupabaseClient): Promise<Result[]> {
const now = new Date().toISOString()
// 1. Busca itens pendentes de email
const { data: items, error: fetchErr } = await supabase
// 1. Busca itens pendentes de email deste tenant
const { data: items, error: fetchErr } = await tdb
.from('notification_queue')
.select('*')
.eq('channel', 'email')
@@ -137,25 +136,14 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (fetchErr) throw new Error(fetchErr.message)
if (!items || items.length === 0) return []
if (!items || items.length === 0) {
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 }> = []
const results: Result[] = []
for (const item of items) {
// 2. Lock otimista — marca como processando
const { error: lockErr } = await supabase
const { error: lockErr } = await tdb
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
@@ -167,8 +155,8 @@ Deno.serve(async (req: Request) => {
}
try {
// 3. Busca canal SMTP
const { data: channel, error: chErr } = await supabase
// 3. Busca canal SMTP (tdb)
const { data: channel, error: chErr } = await tdb
.from('notification_channels')
.select('credentials, sender_address, provider')
.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')
}
// 4. Resolve template: tenant → global fallback
const { data: tenantTpl } = await supabase
// 4. Resolve template: tenant (tdb) → global (admin) fallback
const { data: tenantTpl } = await tdb
.from('email_templates_tenant')
.select('subject, body_html, body_text, enabled')
.eq('tenant_id', item.tenant_id)
.eq('owner_id', item.owner_id)
.eq('template_key', item.template_key)
.maybeSingle()
// Se tenant desabilitou o template → ignorar
if (tenantTpl && tenantTpl.enabled === false) {
await supabase
await tdb
.from('notification_queue')
.update({ status: 'ignorado' })
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
@@ -216,8 +202,8 @@ Deno.serve(async (req: Request) => {
continue
}
// Busca global
const { data: globalTpl } = await supabase
// Busca global (admin/public)
const { data: globalTpl } = await admin
.from('email_templates_global')
.select('subject, body_html, body_text')
.eq('key', item.template_key)
@@ -256,7 +242,7 @@ Deno.serve(async (req: Request) => {
)
// 7. Sucesso — atualiza queue
await supabase
await tdb
.from('notification_queue')
.update({
status: 'enviado',
@@ -266,8 +252,7 @@ Deno.serve(async (req: Request) => {
.eq('id', item.id)
// Insere log de sucesso
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
@@ -297,7 +282,7 @@ Deno.serve(async (req: Request) => {
? null
: new Date(Date.now() + retryDelay).toISOString()
await supabase
await tdb
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
@@ -307,8 +292,7 @@ Deno.serve(async (req: Request) => {
.eq('id', item.id)
// Log de falha
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.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 failed = results.filter(r => r.status === 'falhou').length
const retried = results.filter(r => r.status === 'retry').length
@@ -339,6 +384,7 @@ Deno.serve(async (req: Request) => {
retried,
ignored,
details: results,
tenantErrors: errors,
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
@@ -4,19 +4,24 @@
|--------------------------------------------------------------------------
| 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:
| 1. Busca pendentes (channel='whatsapp', status='pendente', scheduled_at <= now)
| 2. Marca como 'processando' (lock otimista)
| 3. Busca credenciais Evolution API em notification_channels
| 4. Resolve template: tenant → global fallback
| 3. Busca credenciais Evolution API em notification_channels (tdb)
| 4. Resolve template (todos os templates do schema pertencem ao tenant)
| 5. Renderiza variáveis {{var}}
| 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'
|--------------------------------------------------------------------------
*/
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 = {
'Access-Control-Allow-Origin': '*',
@@ -73,22 +78,15 @@ async function sendWhatsapp(
}
}
// ── Main handler ───────────────────────────────────────────────
// ── Processa a fila de UM tenant ───────────────────────────────
Deno.serve(async (req: Request) => {
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')!
)
type Result = { id: string; status: string; error?: string }
async function processTenantQueue(tdb: SupabaseClient): Promise<Result[]> {
const now = new Date().toISOString()
// 1. Busca itens pendentes de WhatsApp
const { data: items, error: fetchErr } = await supabase
// 1. Busca itens pendentes de WhatsApp deste tenant
const { data: items, error: fetchErr } = await tdb
.from('notification_queue')
.select('*')
.eq('channel', 'whatsapp')
@@ -98,28 +96,17 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (fetchErr) throw new Error(fetchErr.message)
if (!items?.length) return []
if (!items?.length) {
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 }> = []
const results: Result[] = []
// Cache de credenciais por owner para evitar queries repetidas
const credentialsCache = new Map<string, EvolutionCredentials | null>()
for (const item of items) {
// 2. Lock otimista
const { error: lockErr } = await supabase
const { error: lockErr } = await tdb
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
@@ -131,11 +118,11 @@ Deno.serve(async (req: Request) => {
}
try {
// 3. Busca credenciais Evolution API (com cache)
// 3. Busca credenciais Evolution API (com cache por owner)
let credentials = credentialsCache.get(item.owner_id)
if (credentials === undefined) {
const { data: channel } = await supabase
const { data: channel } = await tdb
.from('notification_channels')
.select('credentials')
.eq('owner_id', item.owner_id)
@@ -144,49 +131,34 @@ Deno.serve(async (req: Request) => {
.is('deleted_at', null)
.maybeSingle()
// Fallback: busca por tenant_id
if (!channel?.credentials && item.tenant_id) {
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)
credentials = (channel?.credentials as EvolutionCredentials | null) ?? null
credentialsCache.set(item.owner_id, credentials)
}
if (!credentials) {
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
const { data: tenantTpl } = await supabase
const { data: ownerTpl } = await tdb
.from('notification_templates')
.select('body_text, is_active')
.eq('tenant_id', item.tenant_id)
.eq('owner_id', item.owner_id)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (tenantTpl) {
templateBody = tenantTpl.body_text
if (ownerTpl) {
templateBody = ownerTpl.body_text
} else {
const { data: globalTpl } = await supabase
const { data: defaultTpl } = await tdb
.from('notification_templates')
.select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_default', true)
@@ -194,7 +166,7 @@ Deno.serve(async (req: Request) => {
.is('deleted_at', null)
.maybeSingle()
templateBody = globalTpl?.body_text || null
templateBody = defaultTpl?.body_text || null
}
if (!templateBody) {
@@ -209,7 +181,7 @@ Deno.serve(async (req: Request) => {
const sendResult = await sendWhatsapp(credentials, item.recipient_address, message)
// 7. Sucesso
await supabase
await tdb
.from('notification_queue')
.update({
status: 'enviado',
@@ -218,8 +190,7 @@ Deno.serve(async (req: Request) => {
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
@@ -245,7 +216,7 @@ Deno.serve(async (req: Request) => {
const isExhausted = attempts >= maxAttempts
const retryMs = attempts * 2 * 60 * 1000
await supabase
await tdb
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
@@ -254,8 +225,7 @@ Deno.serve(async (req: Request) => {
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.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 failed = results.filter(r => r.status === 'falhou').length
const retried = results.filter(r => r.status === 'retry').length
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' } }
)
})
+105 -55
View File
@@ -9,19 +9,25 @@
| - Antes de enviar, debita 1 crédito do tenant via RPC
| - 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:
| 1. Busca pendentes (channel='sms', status='pendente', scheduled_at <= now)
| 2. Lock otimista (status → processando)
| 3. Debita crédito SMS do tenant (addon_credits)
| 4. Resolve template (tenant → global fallback)
| 3. Debita crédito SMS do tenant (RPC, admin + p_tenant_id)
| 4. Resolve template (templates do schema pertencem ao tenant)
| 5. Renderiza variáveis {{var}}
| 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
|--------------------------------------------------------------------------
*/
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 = {
'Access-Control-Allow-Origin': '*',
@@ -89,22 +95,19 @@ function mockSend(_to: string, _body: string): { sid: string; status: string } {
return { sid, status: 'sent' }
}
// ── Main handler ───────────────────────────────────────────────
// ── Processa a fila de UM tenant ───────────────────────────────
Deno.serve(async (req: Request) => {
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')!
)
type Result = { id: string; status: string; error?: string }
async function processTenantQueue(
admin: SupabaseClient,
tdb: SupabaseClient,
tenantId: string
): Promise<Result[]> {
const now = new Date().toISOString()
// 1. Busca itens pendentes
const { data: items, error: fetchErr } = await supabase
// 1. Busca itens pendentes deste tenant
const { data: items, error: fetchErr } = await tdb
.from('notification_queue')
.select('*')
.eq('channel', 'sms')
@@ -113,28 +116,17 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (fetchErr) throw new Error(fetchErr.message)
if (!items?.length) return []
if (!items?.length) {
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 }> = []
const results: Result[] = []
for (const item of items) {
// Filtra por max_attempts
if (item.attempts >= (item.max_attempts || 5)) continue
// 2. Lock otimista
const { error: lockErr } = await supabase
const { error: lockErr } = await tdb
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
@@ -146,10 +138,10 @@ Deno.serve(async (req: Request) => {
}
try {
// 3. Debita crédito SMS do tenant
const { data: debitResult, error: debitErr } = await supabase
// 3. Debita crédito SMS do tenant (RPC global, p_tenant_id da iteração)
const { data: debitResult, error: debitErr } = await admin
.rpc('debit_addon_credit', {
p_tenant_id: item.tenant_id,
p_tenant_id: tenantId,
p_addon_type: 'sms',
p_queue_id: item.id,
p_description: `SMS para ${item.recipient_address}`,
@@ -161,13 +153,12 @@ Deno.serve(async (req: Request) => {
if (!debitResult?.success) {
// Sem crédito — não envia, marca como sem_credito
await supabase
await tdb
.from('notification_queue')
.update({ status: 'sem_credito', last_error: debitResult?.reason || 'Sem créditos SMS' })
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
@@ -185,26 +176,26 @@ Deno.serve(async (req: Request) => {
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
const { data: tenantTpl } = await supabase
const { data: ownerTpl } = await tdb
.from('notification_templates')
.select('body_text, is_active')
.eq('tenant_id', item.tenant_id)
.eq('owner_id', item.owner_id)
.eq('key', item.template_key)
.eq('channel', 'sms')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (tenantTpl) {
template = tenantTpl
if (ownerTpl) {
template = ownerTpl
} else {
const { data: globalTpl } = await supabase
const { data: defaultTpl } = await tdb
.from('notification_templates')
.select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key)
.eq('channel', 'sms')
.eq('is_default', true)
@@ -212,7 +203,7 @@ Deno.serve(async (req: Request) => {
.is('deleted_at', null)
.maybeSingle()
template = globalTpl
template = defaultTpl
}
if (!template) {
@@ -223,11 +214,11 @@ Deno.serve(async (req: Request) => {
const vars = item.resolved_vars || {}
const message = renderTemplate(template.body_text, vars)
// 6. Busca from_number override do tenant (se tiver)
const { data: creditRow } = await supabase
// 6. Busca from_number override do tenant (addon_credits é GLOBAL → admin)
const { data: creditRow } = await admin
.from('addon_credits')
.select('from_number_override')
.eq('tenant_id', item.tenant_id)
.eq('tenant_id', tenantId)
.eq('addon_type', 'sms')
.maybeSingle()
@@ -243,7 +234,7 @@ Deno.serve(async (req: Request) => {
}
// 8. Sucesso
await supabase
await tdb
.from('notification_queue')
.update({
status: 'enviado',
@@ -252,8 +243,7 @@ Deno.serve(async (req: Request) => {
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
@@ -279,7 +269,7 @@ Deno.serve(async (req: Request) => {
const isExhausted = attempts >= maxAttempts
const retryMs = attempts * 2 * 60 * 1000
await supabase
await tdb
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
@@ -288,8 +278,7 @@ Deno.serve(async (req: Request) => {
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.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 failed = results.filter(r => r.status === 'falhou').length
const noCredit = results.filter(r => r.status === 'sem_credito').length
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' } }
)
})
@@ -5,11 +5,15 @@
| Processa a notification_queue para channel='whatsapp' e provider='twilio'.
| 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:
| 1. Busca itens pendentes (channel='whatsapp', status='pendente')
| 2. Filtra somente tenants com provider='twilio' em notification_channels
| 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}}
| 6. Envia via Twilio usando credenciais da SUBCONTA do tenant
| 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 = {
'Access-Control-Allow-Origin': '*',
@@ -82,22 +87,23 @@ function mockSend(to: string, body: string): { sid: string; status: string } {
return { sid, status: 'sent' }
}
// ── Main handler ──────────────────────────────────────────────────────────
// ── Processa a fila de UM tenant ───────────────────────────────────────────
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
type Result = { id: string; status: string; error?: string }
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const usdBrlRate = parseFloat(Deno.env.get('USD_BRL_RATE') ?? '5.5')
interface TwilioChannel {
twilio_subaccount_sid: string
twilio_phone_number: string
cost_per_message_usd: number
price_per_message_brl: number
credentials: Record<string, string>
}
async function processTenantQueue(tdb: SupabaseClient, usdBrlRate: number): Promise<Result[]> {
const now = new Date().toISOString()
// 1. Busca itens pendentes de WhatsApp
const { data: items, error: fetchErr } = await supabase
// 1. Busca itens pendentes de WhatsApp deste tenant
const { data: items, error: fetchErr } = await tdb
.from('notification_queue')
.select('*')
.eq('channel', 'whatsapp')
@@ -106,51 +112,33 @@ Deno.serve(async (req: Request) => {
.order('scheduled_at', { ascending: true })
.limit(20)
if (fetchErr) {
return new Response(
JSON.stringify({ error: fetchErr.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
if (fetchErr) throw new Error(fetchErr.message)
if (!items?.length) return []
if (!items?.length) {
return new Response(
JSON.stringify({ message: 'Nenhuma mensagem WhatsApp na fila', processed: 0 }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// 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
// Há exatamente um canal twilio whatsapp por tenant (no schema do tenant).
// Resolve uma vez (lazy) e reusa.
let channel: TwilioChannel | null | undefined = undefined
async function getChannel(): Promise<TwilioChannel | null> {
if (channel !== undefined) return channel
const { data } = await tdb
.from('notification_channels')
.select('twilio_subaccount_sid, twilio_phone_number, cost_per_message_usd, price_per_message_brl, credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
channelCache[tenantId] = data
return data
channel = (data as TwilioChannel | null) ?? null
return channel
}
const results: Array<{ id: string; status: string; error?: string }> = []
const results: Result[] = []
for (const item of items) {
if (item.attempts >= (item.max_attempts || 5)) continue
// 2. Lock otimista
const { error: lockErr } = await supabase
const { error: lockErr } = await tdb
.from('notification_queue')
.update({ status: 'processando', attempts: item.attempts + 1 })
.eq('id', item.id)
@@ -163,41 +151,41 @@ Deno.serve(async (req: Request) => {
try {
// 3. Busca canal twilio do tenant
const channel = await getChannel(item.tenant_id)
if (!channel?.twilio_subaccount_sid) {
const ch = await getChannel()
if (!ch?.twilio_subaccount_sid) {
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')
// 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
const { data: tenantTpl } = await supabase
const { data: ownerTpl } = await tdb
.from('notification_templates')
.select('body_text')
.eq('tenant_id', item.tenant_id)
.eq('owner_id', item.owner_id)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (tenantTpl) {
template = tenantTpl
if (ownerTpl) {
template = ownerTpl
} else {
const { data: globalTpl } = await supabase
const { data: defaultTpl } = await tdb
.from('notification_templates')
.select('body_text')
.is('tenant_id', null)
.eq('key', item.template_key)
.eq('channel', 'whatsapp')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
template = globalTpl
template = defaultTpl
}
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)
} else {
sendResult = await sendWhatsAppViaTwilio(
channel.twilio_subaccount_sid,
ch.twilio_subaccount_sid,
subToken,
channel.twilio_phone_number,
ch.twilio_phone_number,
item.recipient_address,
message
)
}
// 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
await supabase
await tdb
.from('notification_queue')
.update({
status: 'enviado',
@@ -235,8 +223,7 @@ Deno.serve(async (req: Request) => {
.eq('id', item.id)
// 7b. Insere no log
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.id,
agenda_evento_id: item.agenda_evento_id,
@@ -264,7 +251,7 @@ Deno.serve(async (req: Request) => {
const isExhausted = attempts >= maxAttempts
const retryMs = Math.min(attempts * 2 * 60 * 1000, 30 * 60 * 1000) // max 30min
await supabase
await tdb
.from('notification_queue')
.update({
status: isExhausted ? 'falhou' : 'pendente',
@@ -273,8 +260,7 @@ Deno.serve(async (req: Request) => {
})
.eq('id', item.id)
await supabase.from('notification_logs').insert({
tenant_id: item.tenant_id,
await tdb.from('notification_logs').insert({
owner_id: item.owner_id,
queue_id: item.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 failed = results.filter(r => r.status === 'falhou').length
const retry = results.filter(r => r.status === 'retry').length
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' } }
)
})
@@ -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 = {
'Access-Control-Allow-Origin': '*',
@@ -71,27 +72,42 @@ Deno.serve(async (req: Request) => {
const userId = authData.user.id
// Service role pra bypass RLS
const supaSvc = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const supaSvc = adminClient()
// Localiza o canal alvo
let target: { id: string, tenant_id: string, channel: string, provider: string, metadata: Record<string, unknown> | null } | null = null
// schema-per-tenant: notification_channels vive no schema do tenant (sem
// 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) {
const { data } = await supaSvc
.from('notification_channels')
.select('id, tenant_id, channel, provider, metadata')
.eq('id', channelId)
.maybeSingle()
target = data
// 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')
.select('id, channel, provider, metadata')
.eq('id', channelId)
.maybeSingle()
if (error) {
console.warn('[reactivate] busca canal em', ref.schema, ':', error.message)
continue
}
if (data) { tdb = candidate; targetTenantId = ref.tenantId; target = data; break }
}
} else {
// Busca o mais recente soft-deleted daquele tenant+provider
const { data } = await supaSvc
// tenant_id + provider: schema conhecido. Busca o mais recente daquele provider.
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')
.select('id, tenant_id, channel, provider, metadata')
.eq('tenant_id', tenantId!)
.select('id, channel, provider, metadata')
.eq('provider', provider!)
.eq('channel', 'whatsapp')
.order('created_at', { ascending: false })
@@ -100,7 +116,7 @@ Deno.serve(async (req: Request) => {
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
const { data: isAdmin } = await supaSvc.rpc('is_saas_admin')
@@ -109,7 +125,7 @@ Deno.serve(async (req: Request) => {
const { data: membership } = await supaSvc
.from('tenant_members')
.select('id')
.eq('tenant_id', target.tenant_id)
.eq('tenant_id', targetTenantId)
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
@@ -117,20 +133,19 @@ Deno.serve(async (req: Request) => {
}
if (!authorized) return json({ ok: false, error: 'forbidden' }, 403)
// Exclusividade: soft-deleta outros canais ativos do mesmo tenant+channel
// (se estava Twilio ativo e reativa Evolution, Twilio é desativado)
// Exclusividade: soft-deleta outros canais ativos do mesmo channel (tabela
// tenant — o escopo do tenant já é o próprio schema, sem tenant_id)
const nowIso = new Date().toISOString()
const { data: others } = await supaSvc
const { data: others } = await tdb
.from('notification_channels')
.select('id')
.eq('tenant_id', target.tenant_id)
.eq('channel', target.channel)
.neq('id', target.id)
.is('deleted_at', null)
let deactivatedOthers = 0
if (others && others.length > 0) {
const { error: deactErr } = await supaSvc
const { error: deactErr } = await tdb
.from('notification_channels')
.update({ is_active: false, deleted_at: nowIso })
.in('id', others.map((o) => o.id))
@@ -146,7 +161,7 @@ Deno.serve(async (req: Request) => {
const cleanedMeta: Record<string, unknown> = { ...(target.metadata || {}) }
delete cleanedMeta.first_unhealthy_at
const { error: updErr } = await supaSvc
const { error: updErr } = await tdb
.from('notification_channels')
.update({
is_active: true,
@@ -166,7 +181,7 @@ Deno.serve(async (req: Request) => {
ok: true,
channel_id: target.id,
provider: target.provider,
tenant_id: target.tenant_id,
tenant_id: targetTenantId,
deactivated_others: deactivatedOthers
})
} catch (err) {
@@ -17,6 +17,7 @@
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = {
'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)
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 tenantId = body?.tenant_id
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)
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)
const userId = authData.user.id
const supa = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const supa = adminClient()
// Carrega evento + paciente
const { data: ev, error: evErr } = await supa
// Client ligado ao schema do tenant (agenda_eventos, patients,
// 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')
.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)
.maybeSingle()
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
.from('tenant_members')
.select('id')
.eq('tenant_id', ev.tenant_id)
.eq('tenant_id', tenantId)
.eq('user_id', userId)
.eq('status', 'active')
.maybeSingle()
@@ -141,25 +153,23 @@ Deno.serve(async (req: Request) => {
const phone = normalizePhoneBR(pat.telefone)
if (!/^\d{10,15}$/.test(phone)) return json({ ok: false, error: 'invalid_phone' }, 400)
// Canal WhatsApp ativo do tenant
const { data: channel } = await supa
// Canal WhatsApp ativo do tenant (tabela tenant)
const { data: channel } = await tdb
.from('notification_channels')
.select('id, provider, credentials, is_active')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (!channel) return json({ ok: false, error: 'no_active_channel' }, 400)
// Tenant name
const { data: tenant } = await supa.from('tenants').select('name').eq('id', ev.tenant_id).maybeSingle()
// Tenant name (global tenants)
const { data: tenant } = await supa.from('tenants').select('name').eq('id', tenantId).maybeSingle()
// Template lembrete_sessao — tenta custom do tenant, fallback pro default
const { data: tpl } = await supa
// Template lembrete_sessao — tenta custom, fallback pro default (tabela tenant)
const { data: tpl } = await tdb
.from('notification_templates')
.select('body_text')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp')
.eq('key', 'lembrete_sessao')
.is('deleted_at', null)
@@ -170,13 +180,13 @@ Deno.serve(async (req: Request) => {
let body_text = tpl?.body_text
if (!body_text) {
// Fallback: template default global
const { data: def } = await supa
// Fallback: template default semeado no schema (is_custom=false)
const { data: def } = await tdb
.from('notification_templates')
.select('body_text')
.eq('channel', 'whatsapp')
.eq('key', 'lembrete_sessao')
.is('tenant_id', null)
.eq('is_custom', false)
.is('deleted_at', null)
.eq('is_active', true)
.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)
if (!sendRes.ok) return json({ ok: false, error: `send_failed: ${sendRes.error}` }, 500)
// Registra conversa + log
const { data: msg } = await supa.from('conversation_messages').insert({
tenant_id: ev.tenant_id,
// Registra conversa + log (tabelas tenant)
const { data: msg } = await tdb.from('conversation_messages').insert({
patient_id: pat.id,
channel: 'whatsapp',
direction: 'outbound',
@@ -221,9 +230,8 @@ Deno.serve(async (req: Request) => {
}).select('id').single()
// 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,
tenant_id: ev.tenant_id,
reminder_type: 'manual',
provider: 'evolution',
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 = {
'Access-Control-Allow-Origin': '*',
@@ -166,11 +167,10 @@ async function sendViaTwilio(
}
// Verifica se paciente está opted-out
async function isOptedOut(supa: SupabaseClient, tenantId: string, phone: string): Promise<boolean> {
const { data } = await supa
async function isOptedOut(tdb: SupabaseClient, phone: string): Promise<boolean> {
const { data } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', phone)
.is('opted_back_in_at', null)
.limit(1)
@@ -185,9 +185,12 @@ type ProcessStats = {
errors: number
}
// Processa eventos em uma janela especifica
// Processa eventos em uma janela especifica (já escopado a um tenant via tdb)
async function processWindow(
supa: SupabaseClient,
tdb: SupabaseClient,
admin: SupabaseClient,
tenantId: string,
tenantName: string,
type: '24h' | '2h',
minutesAhead: number,
stats: ProcessStats
@@ -196,11 +199,11 @@ async function processWindow(
const start = 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)
const { data: events, error } = await supa
// Busca eventos na janela com patient (tenant tables via tdb; schema já filtra o tenant)
const { data: events, error } = await tdb
.from('agenda_eventos')
.select(`
id, tenant_id, inicio_em, modalidade, patient_id, status,
id, inicio_em, modalidade, patient_id, status,
patients:patient_id (id, nome_completo, telefone)
`)
.eq('status', 'agendado')
@@ -217,23 +220,22 @@ async function processWindow(
for (const ev of events || []) {
try {
const eventId = ev.id as string
const tenantId = ev.tenant_id as string
const pat = Array.isArray(ev.patients) ? ev.patients[0] : ev.patients
if (!pat || !pat.telefone) {
// 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
continue
}
const phone = normalizePhoneBR(pat.telefone)
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
continue
}
// Ja enviado? (UNIQUE constraint previne dup mas checamos pra economizar)
const { data: existing } = await supa
const { data: existing } = await tdb
.from('session_reminder_logs')
.select('id')
.eq('event_id', eventId)
@@ -244,11 +246,10 @@ async function processWindow(
continue
}
// Settings do tenant
const { data: settings } = await supa
// Settings do tenant (tabela tenant; uma linha por schema)
const { data: settings } = await tdb
.from('session_reminder_settings')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle()
if (!settings || !settings.enabled) {
@@ -269,48 +270,40 @@ async function processWindow(
const startHHMM = String(settings.quiet_hours_start || '22:00').slice(0, 5)
const endHHMM = String(settings.quiet_hours_end || '08:00').slice(0, 5)
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
continue
}
}
// Opt-out
if (settings.respect_opt_out && await isOptedOut(supa, tenantId, phone)) {
await logSkip(supa, eventId, tenantId, type, 'opted_out')
if (settings.respect_opt_out && await isOptedOut(tdb, phone)) {
await logSkip(tdb, eventId, type, 'opted_out')
stats.skipped.opted_out = (stats.skipped.opted_out || 0) + 1
continue
}
// Canal ativo
const { data: channel } = await supa
const { data: channel } = await tdb
.from('notification_channels')
.select('provider, credentials, twilio_phone_number, is_active')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp')
.is('deleted_at', null)
.maybeSingle()
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
continue
}
// Nome da clinica (tenants.name)
const { data: tenant } = await supa
.from('tenants')
.select('name')
.eq('id', tenantId)
.maybeSingle()
// Monta mensagem
// Monta mensagem (nome da clínica vem do tenant global, resolvido no loop)
const tpl = type === '24h' ? settings.template_24h : settings.template_2h
const text = renderTemplate(tpl, {
nome_paciente: pat.nome_completo || 'Paciente',
data_sessao: fmtDateDayMonth(ev.inicio_em),
hora_sessao: fmtTime(ev.inicio_em),
modalidade: ev.modalidade === 'online' ? 'online' : 'presencial',
nome_clinica: tenant?.name || ''
nome_clinica: tenantName || ''
})
// Envia (Evolution only por enquanto)
@@ -319,23 +312,22 @@ async function processWindow(
if (providerKind === 'evolution') {
const creds = (channel.credentials ?? {}) as Record<string, string>
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
continue
}
const sendRes = await sendViaEvolution(creds.api_url, creds.api_key, creds.instance_name, phone, text)
if (!sendRes.ok) {
console.error('[reminders] send failed for event', eventId, sendRes.error)
await supa.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type,
await tdb.from('session_reminder_logs').insert({
event_id: eventId, reminder_type: type,
provider: 'evolution', skip_reason: `send_failed: ${sendRes.error}`, to_phone: phone
})
stats.errors++
continue
}
// Registra outbound message + log
const { data: msg } = await supa.from('conversation_messages').insert({
tenant_id: tenantId,
const { data: msg } = await tdb.from('conversation_messages').insert({
patient_id: pat.id,
channel: 'whatsapp',
direction: 'outbound',
@@ -349,8 +341,8 @@ async function processWindow(
responded_at: new Date().toISOString()
}).select('id').single()
await supa.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type,
await tdb.from('session_reminder_logs').insert({
event_id: eventId, reminder_type: type,
provider: 'evolution', to_phone: phone,
provider_message_id: sendRes.messageId ?? null,
conversation_message_id: msg?.id ?? null
@@ -358,10 +350,9 @@ async function processWindow(
stats.sent++
} else if (providerKind === 'twilio') {
// Busca creds twilio (colunas dedicadas + credentials JSONB com auth_token)
const { data: fullChannel } = await supa
const { data: fullChannel } = await tdb
.from('notification_channels')
.select('twilio_subaccount_sid, twilio_phone_number, credentials')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp')
.is('deleted_at', null)
.maybeSingle()
@@ -371,13 +362,13 @@ async function processWindow(
const twFrom = fullChannel?.twilio_phone_number as string
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
continue
}
// Deduz 1 crédito ANTES (atômico via RPC)
const { error: dedErr } = await supa.rpc('deduct_whatsapp_credits', {
// Deduz 1 crédito ANTES (atômico via RPC; whatsapp_credit_* é GLOBAL → admin)
const { error: dedErr } = await admin.rpc('deduct_whatsapp_credits', {
p_tenant_id: tenantId,
p_amount: 1,
p_conversation_message_id: null,
@@ -385,15 +376,15 @@ async function processWindow(
})
if (dedErr) {
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
continue
}
const sendRes = await sendViaTwilio(subSid, subToken, twFrom, phone, text)
if (!sendRes.ok) {
// Refund
await supa.rpc('add_whatsapp_credits', {
// Refund (GLOBAL → admin)
await admin.rpc('add_whatsapp_credits', {
p_tenant_id: tenantId,
p_amount: 1,
p_kind: 'refund',
@@ -402,16 +393,15 @@ async function processWindow(
p_note: `Refund lembrete falhou: ${sendRes.error?.slice(0, 200)}`
})
console.error('[reminders] twilio send failed:', sendRes.error)
await supa.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type,
await tdb.from('session_reminder_logs').insert({
event_id: eventId, reminder_type: type,
provider: 'twilio', skip_reason: `send_failed: ${sendRes.error}`, to_phone: phone
})
stats.errors++
continue
}
const { data: msg } = await supa.from('conversation_messages').insert({
tenant_id: tenantId,
const { data: msg } = await tdb.from('conversation_messages').insert({
patient_id: pat.id,
channel: 'whatsapp',
direction: 'outbound',
@@ -426,15 +416,15 @@ async function processWindow(
delivery_status: sendRes.status === 'delivered' ? 'delivered' : 'sent'
}).select('id').single()
await supa.from('session_reminder_logs').insert({
event_id: eventId, tenant_id: tenantId, reminder_type: type,
await tdb.from('session_reminder_logs').insert({
event_id: eventId, reminder_type: type,
provider: 'twilio', to_phone: phone,
provider_message_id: sendRes.messageId ?? null,
conversation_message_id: msg?.id ?? null
})
stats.sent++
} 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
}
} catch (err) {
@@ -445,10 +435,10 @@ async function processWindow(
}
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({
event_id: eventId, tenant_id: tenantId, reminder_type: type,
await tdb.from('session_reminder_logs').insert({
event_id: eventId, reminder_type: type,
provider: 'skipped', skip_reason: reason
})
}
@@ -457,17 +447,27 @@ Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const admin = adminClient()
const stats: ProcessStats = { considered: 0, sent: 0, skipped: {}, errors: 0 }
// 24h antes
await processWindow(supabase, '24h', 24 * 60, stats)
// 2h antes
await processWindow(supabase, '2h', 2 * 60, stats)
// 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
await processWindow(tdb, admin, t.tenantId, tenantName, '24h', 24 * 60, stats)
// 2h antes
await processWindow(tdb, admin, t.tenantId, tenantName, '2h', 2 * 60, stats)
}
return json({ ok: true, stats })
} 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 = {
'Access-Control-Allow-Origin': '*',
@@ -96,7 +97,7 @@ Deno.serve(async (req: Request) => {
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405)
try {
const payload = await req.json().catch(() => null) as { event_id?: string; old_status?: string; new_status?: string } | null
const payload = await req.json().catch(() => null) as { event_id?: string; tenant_id?: string; old_status?: string; new_status?: string } | null
const eventId = payload?.event_id
const newStatus = String(payload?.new_status || '').toLowerCase()
if (!eventId || !newStatus) return json({ ok: false, error: 'invalid_payload' }, 400)
@@ -104,51 +105,77 @@ Deno.serve(async (req: Request) => {
const templateKey = STATUS_TEMPLATE_MAP[newStatus]
if (!templateKey) return json({ ok: true, skipped: 'status_not_mapped', status: newStatus })
const supa = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const admin = adminClient()
// Carrega evento + paciente
const { data: ev, error: evErr } = await supa
.from('agenda_eventos')
.select('id, tenant_id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)')
.eq('id', eventId)
.maybeSingle()
// Resolve o schema do tenant dono do evento. Preferencialmente o trigger
// passa tenant_id no body; senão varremos os schemas até achar o evento.
const evSelect = 'id, inicio_em, modalidade, patient_id, status, patients:patient_id(id, nome_completo, telefone)'
if (evErr || !ev) return json({ ok: false, error: 'event_not_found' }, 404)
let tdb: SupabaseClient | null = null
let tenantId: string | null = payload?.tenant_id ?? null
let ev: Record<string, unknown> | null = null
const pat = Array.isArray(ev.patients) ? ev.patients[0] : ev.patients
if (tenantId) {
tdb = await tenantDbForId(admin, tenantId)
const { data, error: evErr } = await tdb
.from('agenda_eventos')
.select(evSelect)
.eq('id', eventId)
.maybeSingle()
if (evErr || !data) return json({ ok: false, error: 'event_not_found' }, 404)
ev = data as Record<string, unknown>
} else {
// Fallback: descobre o tenant procurando o evento em cada schema.
for (const t of await listTenantSchemas(admin)) {
const cand = admin.schema(t.schema)
const { data } = await cand
.from('agenda_eventos')
.select(evSelect)
.eq('id', eventId)
.maybeSingle()
if (data) {
tdb = cand
tenantId = t.tenantId
ev = data as Record<string, unknown>
break
}
}
if (!tdb || !ev || !tenantId) return json({ ok: false, error: 'event_not_found' }, 404)
}
const evRow = ev as {
inicio_em: string
modalidade: string
patients: { id: string; nome_completo: string | null; telefone: string | null } | Array<{ id: string; nome_completo: string | null; telefone: string | null }>
}
const pat = Array.isArray(evRow.patients) ? evRow.patients[0] : evRow.patients
if (!pat?.telefone) return json({ ok: true, skipped: 'no_phone' })
const phone = normalizePhoneBR(pat.telefone)
if (!/^\d{10,15}$/.test(phone)) return json({ ok: true, skipped: 'invalid_phone' })
// Opt-out: respeita
const { data: optout } = await supa
// Opt-out: respeita (conversation_optouts é tenant → tdb)
const { data: optout } = await tdb
.from('conversation_optouts')
.select('id')
.eq('tenant_id', ev.tenant_id)
.eq('contact_number', phone)
.is('opted_in_at', null)
.maybeSingle()
if (optout) return json({ ok: true, skipped: 'opt_out' })
// Canal WhatsApp ativo
const { data: channel } = await supa
// Canal WhatsApp ativo (tenant → tdb)
const { data: channel } = await tdb
.from('notification_channels')
.select('id, provider, credentials')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
.maybeSingle()
if (!channel) return json({ ok: true, skipped: 'no_active_channel' })
// Template (tenant-specific → global default)
const { data: tpl } = await supa
// Template (notification_templates é tenant → tdb; defaults já semeados no schema)
const { data: tpl } = await tdb
.from('notification_templates')
.select('body_text')
.eq('tenant_id', ev.tenant_id)
.eq('channel', 'whatsapp')
.eq('key', templateKey)
.is('deleted_at', null)
@@ -156,29 +183,17 @@ Deno.serve(async (req: Request) => {
.limit(1)
.maybeSingle()
let body_text = tpl?.body_text
if (!body_text) {
const { data: def } = await supa
.from('notification_templates')
.select('body_text')
.eq('channel', 'whatsapp')
.eq('key', templateKey)
.is('tenant_id', null)
.is('deleted_at', null)
.eq('is_active', true)
.limit(1)
.maybeSingle()
body_text = def?.body_text
}
const body_text = tpl?.body_text
if (!body_text) return json({ ok: true, skipped: 'template_not_found', template_key: templateKey })
const { data: tenant } = await supa.from('tenants').select('name').eq('id', ev.tenant_id).maybeSingle()
// Nome da clínica (tenants é GLOBAL → admin)
const { data: tenant } = await admin.from('tenants').select('name').eq('id', tenantId).maybeSingle()
const text = renderTemplate(body_text, {
nome_paciente: pat.nome_completo || 'paciente',
data_sessao: fmtDate(ev.inicio_em),
hora_sessao: fmtTime(ev.inicio_em),
modalidade: ev.modalidade === 'online' ? 'online' : 'presencial',
data_sessao: fmtDate(evRow.inicio_em),
hora_sessao: fmtTime(evRow.inicio_em),
modalidade: evRow.modalidade === 'online' ? 'online' : 'presencial',
nome_clinica: tenant?.name || '',
status: newStatus
})
@@ -197,9 +212,8 @@ Deno.serve(async (req: Request) => {
const sendRes = await sendViaEvolution(creds.api_url, creds.api_key, creds.instance_name, phone, text)
if (!sendRes.ok) return json({ ok: false, error: `send_failed: ${sendRes.error}` }, 500)
// Registra conversa (sem log unique — transições podem acontecer várias vezes)
await supa.from('conversation_messages').insert({
tenant_id: ev.tenant_id,
// Registra conversa (conversation_messages é tenant → tdb, sem tenant_id)
await tdb.from('conversation_messages').insert({
patient_id: pat.id,
channel: 'whatsapp',
direction: 'outbound',
@@ -19,6 +19,7 @@
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -143,10 +144,17 @@ Deno.serve(async (req: Request) => {
const { data: authData, error: authErr } = await supaAuthed.auth.getUser()
if (authErr || !authData?.user) return json({ ok: false, error: 'auth' }, 401)
const supaSvc = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const supaSvc = adminClient()
// 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
// Valida membership
@@ -162,11 +170,10 @@ Deno.serve(async (req: Request) => {
if (!membership) return json({ ok: false, error: 'forbidden' }, 403)
}
// Busca canal (Evolution ou Twilio)
const { data: channel, error: chErr } = await supaSvc
// Busca canal (Evolution ou Twilio) — tabela tenant
const { data: channel, error: chErr } = await tdb
.from('notification_channels')
.select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active')
.eq('tenant_id', tenant_id)
.eq('channel', 'whatsapp')
.is('deleted_at', null)
.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)
const { data: inserted, error: insErr } = await supaSvc.from('conversation_messages').insert({
tenant_id,
const { data: inserted, error: insErr } = await tdb.from('conversation_messages').insert({
patient_id: resolvedPatientId,
channel: 'whatsapp',
direction: 'outbound',
@@ -294,8 +300,7 @@ Deno.serve(async (req: Request) => {
const providerMessageId = (evoJson as { key?: { id?: string } } | null)?.key?.id ?? null
const { data: inserted, error: insErr } = await supaSvc.from('conversation_messages').insert({
tenant_id,
const { data: inserted, error: insErr } = await tdb.from('conversation_messages').insert({
patient_id: resolvedPatientId,
channel: 'whatsapp',
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 = {
'Access-Control-Allow-Origin': '*',
@@ -45,13 +45,12 @@ Deno.serve(async (req: Request) => {
)
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const admin = adminClient()
// email_templates_tenant é TENANT → tdb (schema do tenant)
const tdb = await tenantDbForId(admin, tenant_id)
// 1. Busca todos os templates globais ativos
const { data: globals, error: globalsErr } = await supabase
// 1. Busca todos os templates globais ativos (GLOBAL → admin)
const { data: globals, error: globalsErr } = await admin
.from('email_templates_global')
.select('key, version')
.eq('is_active', true)
@@ -64,11 +63,10 @@ Deno.serve(async (req: Request) => {
)
}
// 2. Busca templates existentes do tenant
const { data: tenantTemplates, error: tenantErr } = await supabase
// 2. Busca templates existentes do tenant (TENANT → tdb, sem tenant_id)
const { data: tenantTemplates, error: tenantErr } = await tdb
.from('email_templates_tenant')
.select('template_key, synced_version')
.eq('tenant_id', tenant_id)
.eq('owner_id', owner_id)
if (tenantErr) throw tenantErr
@@ -84,11 +82,10 @@ Deno.serve(async (req: Request) => {
const existingVersion = tenantMap.get(global.key)
if (existingVersion === undefined) {
// Não existe → INSERT com campos null (herda do global)
const { error: insertErr } = await supabase
// Não existe → INSERT com campos null (herda do global). Tenant → tdb, sem tenant_id.
const { error: insertErr } = await tdb
.from('email_templates_tenant')
.insert({
tenant_id,
owner_id,
template_key: global.key,
subject: null,
@@ -104,11 +101,10 @@ Deno.serve(async (req: Request) => {
}
synced++
} else if (existingVersion < global.version) {
// Existe mas desatualizado → UPDATE apenas synced_version
const { error: updateErr } = await supabase
// Existe mas desatualizado → UPDATE apenas synced_version (tenant → tdb)
const { error: updateErr } = await tdb
.from('email_templates_tenant')
.update({ synced_version: global.version })
.eq('tenant_id', tenant_id)
.eq('owner_id', owner_id)
.eq('template_key', global.key)
@@ -31,6 +31,7 @@ import {
registerOptout,
type TwilioChannel
} from '../_shared/whatsapp-hooks.ts'
import { tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -73,6 +74,7 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const tdb = await tenantDbForId(supabase, tenantId)
const formData = await req.formData()
const from = stripWhatsappPrefix(formData.get('From') as string)
@@ -111,8 +113,7 @@ Deno.serve(async (req: Request) => {
WaId: formData.get('WaId') ?? null,
}
const { error: insErr } = await supabase.from('conversation_messages').insert({
tenant_id: tenantId,
const { error: insErr } = await tdb.from('conversation_messages').insert({
patient_id: patientId,
channel: 'whatsapp',
direction: 'inbound',
@@ -144,23 +145,22 @@ Deno.serve(async (req: Request) => {
try {
// Busca canal Twilio (uma vez) — reutilizado por registerOptout/autoReply
const { data: channel } = await supabase
const { data: channel } = await tdb
.from('notification_channels')
.select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active')
.eq('tenant_id', tenantId)
.eq('channel', 'whatsapp')
.is('deleted_at', null)
.maybeSingle()
if (channel && channel.is_active && channel.provider === 'twilio') {
// 1) Opt-IN (voltar) tem prioridade
if (await maybeOptIn(supabase, tenantId, from, cleanBody)) {
if (await maybeOptIn(tdb, from, cleanBody)) {
optoutAction = 'in'
}
// 2) Opt-OUT por keyword — envia ack via Twilio (deduz 1 credito)
if (!optoutAction) {
const keyword = await detectOptoutKeyword(supabase, tenantId, cleanBody)
const keyword = await detectOptoutKeyword(tdb, cleanBody)
if (keyword) {
const optoutSendFn = makeTwilioCreditedSendFn(
supabase,
@@ -169,8 +169,7 @@ Deno.serve(async (req: Request) => {
'Opt-out ack WhatsApp'
)
await registerOptout(
supabase,
tenantId,
tdb,
from,
patientId,
cleanBody,
@@ -193,6 +192,7 @@ Deno.serve(async (req: Request) => {
'Bot de triagem WhatsApp'
)
const botRes = await maybeProcessBot(
tdb,
supabase,
tenantId,
threadKey,
@@ -214,8 +214,7 @@ Deno.serve(async (req: Request) => {
'Auto-reply WhatsApp'
)
autoReplyResult = await maybeSendAutoReply(
supabase,
tenantId,
tdb,
threadKey,
from,
'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 = {
'Access-Control-Allow-Origin': '*',
@@ -181,10 +182,32 @@ Deno.serve(async (req: Request) => {
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const supabaseAdmin = adminClient()
// ── 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
const { data: { user }, error: authErr } = await supabase.auth.getUser()
@@ -232,12 +255,12 @@ Deno.serve(async (req: Request) => {
return !!data
}
// ── Busca canal do tenant ────────────────────────────────────────
// ── Busca canal do tenant (tabela tenant → schema do tenant) ─────
async function getChannel(tid: string) {
const { data, error } = await supabaseAdmin
const tdb = await tenantDbForId(supabaseAdmin, tid)
const { data, error } = await tdb
.from('notification_channels')
.select('*')
.eq('tenant_id', tid)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
@@ -290,6 +313,7 @@ Deno.serve(async (req: Request) => {
}
const ownerId = await getOwnerId(tenantId)
const tdb = await tenantDbForId(supabaseAdmin, tenantId)
// 1. Cria subconta Twilio
const subaccountData = await twilio.createSubaccount(
@@ -316,9 +340,8 @@ Deno.serve(async (req: Request) => {
const numData = await twilio.buyNumber(subSid, finalNumber, webhookUrl, subToken)
phoneSid = numData.sid as string
// 4. Salva no banco
// 4. Salva no banco (notification_channels é tabela tenant — sem tenant_id)
const channelData = {
tenant_id: tenantId,
owner_id: ownerId,
channel: 'whatsapp',
provider: 'twilio',
@@ -338,7 +361,7 @@ Deno.serve(async (req: Request) => {
let savedChannel
if (existing?.id) {
const { data, error } = await supabaseAdmin
const { data, error } = await tdb
.from('notification_channels')
.update(channelData)
.eq('id', existing.id)
@@ -347,7 +370,7 @@ Deno.serve(async (req: Request) => {
if (error) throw error
savedChannel = data
} else {
const { data, error } = await supabaseAdmin
const { data, error } = await tdb
.from('notification_channels')
.insert(channelData)
.select('*')
@@ -377,30 +400,27 @@ Deno.serve(async (req: Request) => {
if (!channelId) return err('channel_id obrigatório', 400)
try {
const { data: ch, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
const found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { tdb, channel: ch } = found
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
if (ch.twilio_phone_sid && subToken) {
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) {
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
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid, 'closed')
await twilio.updateSubaccountStatus(ch.twilio_subaccount_sid as string, 'closed')
// 3. Soft-delete do canal
const { error: delErr } = await supabaseAdmin
// 3. Soft-delete do canal (tabela tenant)
const { error: delErr } = await tdb
.from('notification_channels')
.update({
deleted_at: new Date().toISOString(),
@@ -427,18 +447,15 @@ Deno.serve(async (req: Request) => {
if (!channelId) return err('channel_id obrigatório', 400)
try {
const { data: ch, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
const found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { tdb, channel: ch } = found
if (!ch.twilio_subaccount_sid) return err('Canal sem subconta Twilio', 400)
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')
.update({
is_active: action === 'reactivate',
@@ -468,70 +485,83 @@ Deno.serve(async (req: Request) => {
const endDate = now.toISOString().split('T')[0]
try {
// Busca todos os canais twilio (ou somente o especificado)
const query = supabaseAdmin
.from('notification_channels')
.select('id, tenant_id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl')
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
.not('twilio_subaccount_sid', 'is', null)
if (channelId) query.eq('id', channelId)
const { data: channels, error } = await query
if (error) throw error
// schema-per-tenant: notification_channels/logs e twilio_subaccount_usage
// 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 ch of channels ?? []) {
try {
const subToken = ch.credentials?.subaccount_auth_token as string
if (!subToken) continue
for (const ref of refs) {
const tdb = supabaseAdmin.schema(ref.schema)
// Busca mensagens enviadas no mês via notification_logs
const { data: logs } = await supabaseAdmin
.from('notification_logs')
.select('status, estimated_cost_brl')
.eq('tenant_id', ch.tenant_id)
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.gte('created_at', startDate)
.lte('created_at', endDate + 'T23:59:59Z')
// Canais twilio do schema (ou somente o especificado)
let chQuery = tdb
.from('notification_channels')
.select('id, twilio_subaccount_sid, credentials, cost_per_message_usd, price_per_message_brl')
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.is('deleted_at', null)
.not('twilio_subaccount_sid', 'is', null)
const sent = logs?.length ?? 0
const delivered = logs?.filter(l => l.status === 'delivered' || l.status === 'read').length ?? 0
const failed = logs?.filter(l => l.status === 'failed').length ?? 0
const costBrl = logs?.reduce((sum, l) => sum + parseFloat(String(l.estimated_cost_brl ?? 0)), 0) ?? 0
const costUsd = costBrl / usdBrlRate
const revBrl = sent * (ch.price_per_message_brl ?? 0)
if (channelId) chQuery = chQuery.eq('id', channelId)
// Upsert no twilio_subaccount_usage
const { error: upsertErr } = await supabaseAdmin
.from('twilio_subaccount_usage')
.upsert({
tenant_id: ch.tenant_id,
channel_id: ch.id,
twilio_subaccount_sid: ch.twilio_subaccount_sid,
period_start: startDate,
period_end: endDate,
messages_sent: sent,
messages_delivered: delivered,
messages_failed: failed,
cost_usd: costUsd,
cost_brl: costBrl,
revenue_brl: revBrl,
usd_brl_rate: usdBrlRate,
synced_at: new Date().toISOString(),
}, {
onConflict: 'channel_id,period_start,period_end',
})
if (upsertErr) throw upsertErr
synced.push({ channel_id: ch.id, sent, delivered, failed })
} catch (e) {
console.warn(`[sync_usage] canal ${ch.id}:`, e.message)
const { data: channels, error } = await chQuery
if (error) {
console.warn(`[sync_usage] canais em ${ref.schema}:`, error.message)
continue
}
for (const ch of channels ?? []) {
try {
const subToken = (ch.credentials as { subaccount_auth_token?: string } | null)?.subaccount_auth_token as string
if (!subToken) continue
// Busca mensagens enviadas no mês via notification_logs (tabela tenant)
const { data: logs } = await tdb
.from('notification_logs')
.select('status, estimated_cost_brl')
.eq('channel', 'whatsapp')
.eq('provider', 'twilio')
.gte('created_at', startDate)
.lte('created_at', endDate + 'T23:59:59Z')
const sent = logs?.length ?? 0
const delivered = logs?.filter(l => l.status === 'delivered' || l.status === 'read').length ?? 0
const failed = logs?.filter(l => l.status === 'failed').length ?? 0
const costBrl = logs?.reduce((sum, l) => sum + parseFloat(String(l.estimated_cost_brl ?? 0)), 0) ?? 0
const costUsd = costBrl / usdBrlRate
const revBrl = sent * (ch.price_per_message_brl ?? 0)
// Upsert no twilio_subaccount_usage (tabela tenant)
const { error: upsertErr } = await tdb
.from('twilio_subaccount_usage')
.upsert({
channel_id: ch.id,
twilio_subaccount_sid: ch.twilio_subaccount_sid,
period_start: startDate,
period_end: endDate,
messages_sent: sent,
messages_delivered: delivered,
messages_failed: failed,
cost_usd: costUsd,
cost_brl: costBrl,
revenue_brl: revBrl,
usd_brl_rate: usdBrlRate,
synced_at: new Date().toISOString(),
}, {
onConflict: 'channel_id,period_start,period_end',
})
if (upsertErr) throw upsertErr
synced.push({ channel_id: ch.id, sent, delivered, failed })
} catch (e) {
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 })
@@ -588,32 +618,29 @@ Deno.serve(async (req: Request) => {
// Tenant pode testar o seu próprio canal; admin pode testar qualquer um
try {
const { data: ch, error } = await supabaseAdmin
.from('notification_channels')
.select('*')
.eq('id', channelId)
.single()
if (error || !ch) return err('Canal não encontrado', 404)
const found = await findChannelById(channelId)
if (!found) return err('Canal não encontrado', 404)
const { channel: ch, ref } = found
// Verifica permissão: próprio tenant ou admin
// Verifica permissão: próprio tenant (via ref.tenantId) ou admin
const admin = await isSaasAdmin()
if (!admin) {
const { data: member } = await supabaseAdmin
.from('tenant_members')
.select('tenant_id')
.eq('tenant_id', ch.tenant_id)
.eq('tenant_id', ref.tenantId)
.eq('user_id', user.id)
.maybeSingle()
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)
const result = await twilio.sendWhatsApp(
ch.twilio_subaccount_sid,
ch.twilio_subaccount_sid as string,
subToken,
ch.twilio_phone_number,
ch.twilio_phone_number as string,
toNumber,
message
)
@@ -13,7 +13,7 @@
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { adminClient, tenantDbForId } from '../_shared/tenant.ts'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -42,10 +42,7 @@ Deno.serve(async (req: Request) => {
}
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const admin = adminClient()
const url = new URL(req.url)
const tenantId = url.searchParams.get('tenant_id')
@@ -85,19 +82,26 @@ Deno.serve(async (req: Request) => {
: `Twilio status: ${messageStatus}`
}
const query = supabase
.from('notification_logs')
.update(updateData)
.eq('provider_message_id', messageSid)
if (tenantId) query.eq('tenant_id', tenantId)
const { error } = await query
if (error) {
console.error('[webhook] Erro ao atualizar log:', error.message)
// 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 {
console.log(`[webhook] ${messageSid}${messageStatus} (tenant: ${tenantId ?? 'unknown'})`)
try {
const tdb = await tenantDbForId(admin, tenantId)
const { error } = await tdb
.from('notification_logs')
.update(updateData)
.eq('provider_message_id', messageSid)
if (error) {
console.error('[webhook] Erro ao atualizar log:', error.message)
} else {
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
@@ -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 = {
'Access-Control-Allow-Origin': '*',
@@ -89,7 +90,6 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
interface ChannelRow {
id: string
tenant_id: string
owner_id: string
provider: string
credentials: Record<string, string>
@@ -98,7 +98,7 @@ interface ChannelRow {
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
channel_id: string
previous_status: string | null
@@ -114,10 +114,10 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
if (!apiUrl || !apiKey || !instance) {
// 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() })
.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)
@@ -160,10 +160,10 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
if (firstUnhealthyAtRaw) delete newMeta.first_unhealthy_at
patch.metadata = newMeta
await supa.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 })
await tdb.from('notification_channels').update(patch).eq('id', channel.id)
const { data: resolved } = await tdb.rpc('whatsapp_heartbeat_resolve_open_incidents', { p_channel_id: channel.id })
return {
tenant_id: channel.tenant_id,
tenant_id: tenantId,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
@@ -177,13 +177,13 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
newMeta.first_unhealthy_at = now.toISOString()
}
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
if (minutesUnhealthy < thresholdMinutes) {
return {
tenant_id: channel.tenant_id,
tenant_id: tenantId,
channel_id: channel.id,
previous_status: channel.connection_status,
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_count = (Number(cleanedMeta.heartbeat_reconnect_count) || 0) + 1
await supa.from('notification_channels').update({
await tdb.from('notification_channels').update({
connection_status: 'connected',
last_health_check: now.toISOString(),
metadata: cleanedMeta
}).eq('id', channel.id)
// 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 {
tenant_id: channel.tenant_id,
tenant_id: tenantId,
channel_id: channel.id,
previous_status: channel.connection_status,
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
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)
@@ -265,7 +265,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
...(fetchError ? { error: fetchError } : {}),
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_kind: kind,
p_last_state: state || fetchError,
@@ -274,7 +274,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
if (incidentErr) {
return {
tenant_id: channel.tenant_id,
tenant_id: tenantId,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
@@ -285,8 +285,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
const newIncidentId = incidentId as unknown as string
if (alertsEnabled && newIncidentId) {
await notifyChannelStakeholders(supa, {
tenant_id: channel.tenant_id,
await notifyChannelStakeholders(tdb, admin, tenantId, {
channel_owner_id: channel.owner_id,
incident_id: newIncidentId,
channel_display: String(channel.provider === 'evolution_api' ? 'WhatsApp Pessoal' : 'WhatsApp'),
@@ -296,7 +295,7 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
}
return {
tenant_id: channel.tenant_id,
tenant_id: tenantId,
channel_id: channel.id,
previous_status: channel.connection_status,
new_status: newStatus,
@@ -306,16 +305,15 @@ async function checkOneChannel(supa: SupabaseClient, channel: ChannelRow, now: D
}
}
async function notifyChannelStakeholders(supa: SupabaseClient, params: {
tenant_id: string
async function notifyChannelStakeholders(tdb: SupabaseClient, admin: SupabaseClient, tenantId: string, params: {
channel_owner_id: string
incident_id: string
channel_display: string
kind: string
minutes_unhealthy: number
}): Promise<void> {
// Checa se já notificou esse incident
const { data: incident } = await supa
// Checa se já notificou esse incident (tenant → tdb)
const { data: incident } = await tdb
.from('whatsapp_connection_incidents')
.select('notified_at, notification_count')
.eq('id', params.incident_id)
@@ -329,10 +327,11 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
const userIds = new Set<string>()
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')
.select('user_id')
.eq('tenant_id', params.tenant_id)
.eq('tenant_id', tenantId)
.in('role', ['clinic_admin', 'tenant_admin'])
.eq('status', 'active')
@@ -353,7 +352,6 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
const rows = Array.from(userIds).map((uid) => ({
owner_id: uid,
tenant_id: params.tenant_id,
type: 'system_alert',
ref_id: params.incident_id,
ref_table: 'whatsapp_connection_incidents',
@@ -365,52 +363,63 @@ async function notifyChannelStakeholders(supa: SupabaseClient, params: {
}
}))
await supa.from('notifications').insert(rows)
await supa.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
// notifications é tenant → tdb (sem tenant_id no payload)
await tdb.from('notifications').insert(rows)
await tdb.rpc('whatsapp_heartbeat_mark_notified', { p_incident_id: params.incident_id })
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
const supa = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
{ auth: { autoRefreshToken: false, persistSession: false } }
)
const admin = adminClient()
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 singleChannelId = url.searchParams.get('channel_id')
let query = supa
.from('notification_channels')
.select('id, tenant_id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
.eq('provider', 'evolution_api')
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
const now = new Date()
const tasks: Array<Promise<Awaited<ReturnType<typeof checkOneChannel>>>> = []
if (singleChannelId) query = query.eq('id', singleChannelId)
for (const t of await listTenantSchemas(admin)) {
const tdb = admin.schema(t.schema)
const { data: channels, error: fetchErr } = await query
let query = tdb
.from('notification_channels')
.select('id, owner_id, provider, credentials, connection_status, last_health_check, metadata')
.eq('provider', 'evolution_api')
.eq('channel', 'whatsapp')
.eq('is_active', true)
.is('deleted_at', null)
if (fetchErr) return json({ error: fetchErr.message }, 500)
if (!channels || channels.length === 0) {
if (singleChannelId) query = query.eq('id', singleChannelId)
const { data: channels, error: fetchErr } = await query
if (fetchErr) {
console.error(`[heartbeat] channels query error (tenant ${t.tenantId}):`, fetchErr.message)
continue
}
for (const ch of channels || []) {
tasks.push(
checkOneChannel(tdb, admin, t.tenantId, ch as ChannelRow, now).catch((e) => ({
tenant_id: t.tenantId,
channel_id: (ch as ChannelRow).id,
previous_status: (ch as ChannelRow).connection_status,
new_status: 'error',
action: 'fetch_error' as const,
error: (e as Error).message
}))
)
}
}
if (tasks.length === 0) {
return json({ checked: 0, results: [] })
}
const now = new Date()
const results = await Promise.all(
channels.map((ch) => checkOneChannel(supa, ch as ChannelRow, now).catch((e) => ({
tenant_id: (ch as ChannelRow).tenant_id,
channel_id: (ch as ChannelRow).id,
previous_status: (ch as ChannelRow).connection_status,
new_status: 'error',
action: 'fetch_error' as const,
error: (e as Error).message
})))
)
const results = await Promise.all(tasks)
const summary = {
checked: results.length,