diff --git a/src/features/agenda/composables/__tests__/useAgendaEventLifecycle.spec.js b/src/features/agenda/composables/__tests__/useAgendaEventLifecycle.spec.js index 1e0b8de..b59852d 100644 --- a/src/features/agenda/composables/__tests__/useAgendaEventLifecycle.spec.js +++ b/src/features/agenda/composables/__tests__/useAgendaEventLifecycle.spec.js @@ -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); }); diff --git a/src/features/agenda/composables/useAgendaEventLifecycle.js b/src/features/agenda/composables/useAgendaEventLifecycle.js index 5314eb9..708c794 100644 --- a/src/features/agenda/composables/useAgendaEventLifecycle.js +++ b/src/features/agenda/composables/useAgendaEventLifecycle.js @@ -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'; diff --git a/supabase/functions/_shared/tenant.ts b/supabase/functions/_shared/tenant.ts new file mode 100644 index 0000000..14e6e41 --- /dev/null +++ b/supabase/functions/_shared/tenant.ts @@ -0,0 +1,101 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — Edge Functions: helper schema-per-tenant +|-------------------------------------------------------------------------- +| As tabelas tenant-scoped vivem em schemas físicos `tenant_` 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 { + 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 { + 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 { + // 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) => { + 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 } +} diff --git a/supabase/functions/_shared/whatsapp-hooks.ts b/supabase/functions/_shared/whatsapp-hooks.ts index e23f1a1..8f5e889 100644 --- a/supabase/functions/_shared/whatsapp-hooks.ts +++ b/supabase/functions/_shared/whatsapp-hooks.ts @@ -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_, 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 { 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 { - const { data } = await supa +export async function isOptedOut(tdb: SupabaseClient, phone: string): Promise { + 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 { @@ -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 { - 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 { - const { data, error } = await supa +async function windowsFromAgenda(tdb: SupabaseClient): Promise { + 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, tenant_id: string, thread_key: string, contact_number: string | null }, + session: { id: string, current_step: number, collected_data: Record, 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 { 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 }); diff --git a/supabase/functions/asaas-create-payment-record/index.ts b/supabase/functions/asaas-create-payment-record/index.ts index da8acbd..a8d1084 100644 --- a/supabase/functions/asaas-create-payment-record/index.ts +++ b/supabase/functions/asaas-create-payment-record/index.ts @@ -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) diff --git a/supabase/functions/asaas-sync-payment/index.ts b/supabase/functions/asaas-sync-payment/index.ts index 6668227..118a87d 100644 --- a/supabase/functions/asaas-sync-payment/index.ts +++ b/supabase/functions/asaas-sync-payment/index.ts @@ -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'; diff --git a/supabase/functions/asaas-webhook/index.ts b/supabase/functions/asaas-webhook/index.ts index 7d664ba..8fb9305 100644 --- a/supabase/functions/asaas-webhook/index.ts +++ b/supabase/functions/asaas-webhook/index.ts @@ -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 | null = null diff --git a/supabase/functions/conversation-sla-check/index.ts b/supabase/functions/conversation-sla-check/index.ts index 803eedd..11ab5e4 100644 --- a/supabase/functions/conversation-sla-check/index.ts +++ b/supabase/functions/conversation-sla-check/index.ts @@ -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 { - // 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> = [] - 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, diff --git a/supabase/functions/convert-abandoned-intakes/index.ts b/supabase/functions/convert-abandoned-intakes/index.ts index 0e828e3..759fc98 100644 --- a/supabase/functions/convert-abandoned-intakes/index.ts +++ b/supabase/functions/convert-abandoned-intakes/index.ts @@ -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, diff --git a/supabase/functions/deactivate-notification-channel/index.ts b/supabase/functions/deactivate-notification-channel/index.ts index 5e37f50..6732d77 100644 --- a/supabase/functions/deactivate-notification-channel/index.ts +++ b/supabase/functions/deactivate-notification-channel/index.ts @@ -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, diff --git a/supabase/functions/evolution-whatsapp-inbound/index.ts b/supabase/functions/evolution-whatsapp-inbound/index.ts index db98299..577f780 100644 --- a/supabase/functions/evolution-whatsapp-inbound/index.ts +++ b/supabase/functions/evolution-whatsapp-inbound/index.ts @@ -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 { - const { data: channel, error } = await supa +// Busca credenciais Evolution do tenant em notification_channels (schema do tenant) +async function getTenantEvolutionCreds(tdb: SupabaseClient): Promise { + 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) } diff --git a/supabase/functions/notification-webhook/index.ts b/supabase/functions/notification-webhook/index.ts index f617964..edb9dfe 100644 --- a/supabase/functions/notification-webhook/index.ts +++ b/supabase/functions/notification-webhook/index.ts @@ -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_` (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}`) } } diff --git a/supabase/functions/process-email-queue/index.ts b/supabase/functions/process-email-queue/index.ts index fd2bdfb..0d99a86 100644 --- a/supabase/functions/process-email-queue/index.ts +++ b/supabase/functions/process-email-queue/index.ts @@ -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 { 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' } } ) diff --git a/supabase/functions/process-notification-queue/index.ts b/supabase/functions/process-notification-queue/index.ts index 5181f47..4aad205 100644 --- a/supabase/functions/process-notification-queue/index.ts +++ b/supabase/functions/process-notification-queue/index.ts @@ -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 { 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() 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' } } ) }) diff --git a/supabase/functions/process-sms-queue/index.ts b/supabase/functions/process-sms-queue/index.ts index 1a7649e..42d7ebb 100644 --- a/supabase/functions/process-sms-queue/index.ts +++ b/supabase/functions/process-sms-queue/index.ts @@ -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 { 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' } } ) }) diff --git a/supabase/functions/process-whatsapp-queue/index.ts b/supabase/functions/process-whatsapp-queue/index.ts index eaba953..1df0cb6 100644 --- a/supabase/functions/process-whatsapp-queue/index.ts +++ b/supabase/functions/process-whatsapp-queue/index.ts @@ -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 +} +async function processTenantQueue(tdb: SupabaseClient, usdBrlRate: number): Promise { 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 - } | 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 { + 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' } } ) }) diff --git a/supabase/functions/reactivate-notification-channel/index.ts b/supabase/functions/reactivate-notification-channel/index.ts index f936ede..1ae1bc7 100644 --- a/supabase/functions/reactivate-notification-channel/index.ts +++ b/supabase/functions/reactivate-notification-channel/index.ts @@ -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 | 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 | 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 = { ...(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) { diff --git a/supabase/functions/send-session-reminder-manual/index.ts b/supabase/functions/send-session-reminder-manual/index.ts index 90d8931..c95a7ff 100644 --- a/supabase/functions/send-session-reminder-manual/index.ts +++ b/supabase/functions/send-session-reminder-manual/index.ts @@ -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, diff --git a/supabase/functions/send-session-reminders/index.ts b/supabase/functions/send-session-reminders/index.ts index 840ed6a..50bc82e 100644 --- a/supabase/functions/send-session-reminders/index.ts +++ b/supabase/functions/send-session-reminders/index.ts @@ -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 { - const { data } = await supa +async function isOptedOut(tdb: SupabaseClient, phone: string): Promise { + 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 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) { diff --git a/supabase/functions/send-session-status-notification/index.ts b/supabase/functions/send-session-status-notification/index.ts index 4f1768f..e84e5d0 100644 --- a/supabase/functions/send-session-status-notification/index.ts +++ b/supabase/functions/send-session-status-notification/index.ts @@ -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 | 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 + } 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 + 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', diff --git a/supabase/functions/send-whatsapp-message/index.ts b/supabase/functions/send-whatsapp-message/index.ts index bd7f3a7..cf19c00 100644 --- a/supabase/functions/send-whatsapp-message/index.ts +++ b/supabase/functions/send-whatsapp-message/index.ts @@ -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', diff --git a/supabase/functions/sync-email-templates/index.ts b/supabase/functions/sync-email-templates/index.ts index 9a683ce..cc6411a 100644 --- a/supabase/functions/sync-email-templates/index.ts +++ b/supabase/functions/sync-email-templates/index.ts @@ -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) diff --git a/supabase/functions/twilio-whatsapp-inbound/index.ts b/supabase/functions/twilio-whatsapp-inbound/index.ts index ca75389..a33198c 100644 --- a/supabase/functions/twilio-whatsapp-inbound/index.ts +++ b/supabase/functions/twilio-whatsapp-inbound/index.ts @@ -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', diff --git a/supabase/functions/twilio-whatsapp-provision/index.ts b/supabase/functions/twilio-whatsapp-provision/index.ts index 88fabaa..0016788 100644 --- a/supabase/functions/twilio-whatsapp-provision/index.ts +++ b/supabase/functions/twilio-whatsapp-provision/index.ts @@ -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; 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, 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 ) diff --git a/supabase/functions/twilio-whatsapp-webhook/index.ts b/supabase/functions/twilio-whatsapp-webhook/index.ts index 132c659..9c78e36 100644 --- a/supabase/functions/twilio-whatsapp-webhook/index.ts +++ b/supabase/functions/twilio-whatsapp-webhook/index.ts @@ -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 diff --git a/supabase/functions/whatsapp-heartbeat-check/index.ts b/supabase/functions/whatsapp-heartbeat-check/index.ts index 2e34c72..997a583 100644 --- a/supabase/functions/whatsapp-heartbeat-check/index.ts +++ b/supabase/functions/whatsapp-heartbeat-check/index.ts @@ -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 @@ -98,7 +98,7 @@ interface ChannelRow { metadata: Record | 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 { - // 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() 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>>> = [] - 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,