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