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