c2c42a1620
Bot que coleta nome, motivo de busca e preferências ANTES do paciente
entrar no fluxo humano. Terapeuta abre a conversa e já encontra
resumo em conversation_notes.
Banco (migration 20260423000007):
- conversation_bots: config 1 por tenant. enabled, greeting/closing
messages, steps (JSONB array de {prompt, variable, type}), trigger_mode
(new_contact | all_unassigned | keyword), trigger_keywords[],
idle_timeout_minutes, respect_optout.
Defaults vêm com 4 perguntas úteis: nome, motivo, modalidade,
horário preferido.
- conversation_bot_sessions: estado por thread. current_step,
collected_data JSONB, status (active | completed | abandoned_idle |
abandoned_manual | opted_out). UNIQUE parcial garante 1 ativa por
(tenant, thread).
- RLS: leitura tenant/saas_admin, escrita admins (config) + service_role
(sessions, só edge altera).
Shared (_shared/whatsapp-hooks.ts):
- maybeProcessBot: carrega config, busca sessão ativa, avança step
com resposta, envia próxima pergunta via SendFn. Ao esgotar steps,
envia closing + cria conversation_notes com resumo das variáveis
coletadas. Se humano assume (conversation_assignments preenchido),
sessão marca 'abandoned_manual' e bot sai.
- Trigger modes:
- 'new_contact' (default): só inicia pra thread sem histórico bot
E sem paciente vinculado (lead real).
- 'all_unassigned': qualquer thread sem assignee.
- 'keyword': matched contra lista; normalizeForMatch já existe.
Integração nos inbound (ambos providers):
- evolution-whatsapp-inbound: chama maybeProcessBot após opt-in/opt-out,
ANTES do auto-reply. Se bot processou, skip auto-reply (senão duas
respostas sobrepostas).
- twilio-whatsapp-inbound: idem, usando makeTwilioCreditedSendFn pra
cobrar crédito de cada mensagem enviada pelo bot.
UI (/configuracoes/conversas-bots):
- Toggle enabled + Select trigger_mode + (se keyword) chips de keywords.
- Textareas greeting/closing.
- Editor de steps: reordenar (up/down), remover, add, editor com prompt
e variable (regex /^[a-z_][a-z0-9_]*$/).
- Botão "Padrão" restaura mensagens/steps default.
- InputNumber idle_timeout + toggle respect_optout.
- Card inferior: últimas 30 sessões (7 dias) com status, contato,
nome coletado (primeiro campo), progresso (step X/N), início.
- Entrada na landing de configurações + rota /configuracoes/conversas-bots.
Caveat conhecido: a resolução de conversation_notes.created_by usa
o primeiro admin ativo do tenant (pickAnyAdmin). Pra uma v2 seria
ideal ter um user "bot" sintético dedicado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
7.7 KiB
SQL
183 lines
7.7 KiB
SQL
-- ==========================================================================
|
|
-- Agencia PSI — Migracao: Bot de auto-triagem WhatsApp (Grupo 3.7)
|
|
-- ==========================================================================
|
|
-- Criado por: Leonardo Nohama
|
|
-- Data: 2026-04-23 · Sao Carlos/SP — Brasil
|
|
--
|
|
-- Bot que coleta nome + motivo + preferencias antes de encaminhar o
|
|
-- paciente pro fluxo humano. Evita que terapeuta tenha que fazer
|
|
-- perguntas basicas toda vez que chega um lead novo no WhatsApp.
|
|
--
|
|
-- Modelo:
|
|
-- conversation_bots → config (1 por tenant)
|
|
-- conversation_bot_sessions → estado ativo por thread
|
|
--
|
|
-- Fluxo:
|
|
-- 1. Paciente novo manda inbound
|
|
-- 2. Edge evolution-whatsapp-inbound cria session no step 0 e envia
|
|
-- greeting + primeira pergunta (steps[0].prompt)
|
|
-- 3. Proxima inbound: salva resposta em collected_data[steps[0].variable],
|
|
-- avanca step, envia proxima pergunta
|
|
-- 4. Quando passa do ultimo step: envia closing_message, marca session
|
|
-- como 'completed', cria conversation_note com resumo das respostas
|
|
--
|
|
-- Interrupcoes:
|
|
-- - Paciente pede opt-out (keywords) → bot sai, session 'opted_out'
|
|
-- - Humano assume a conversa (conversation_assignments) → bot sai, 'abandoned_manual'
|
|
-- - Session sem resposta > idle_timeout → 'abandoned_idle' (job futuro)
|
|
--
|
|
-- UNIQUE parcial garante 1 sessao ativa por thread.
|
|
-- ==========================================================================
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Tabela: conversation_bots (config)
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.conversation_bots (
|
|
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
|
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
|
|
|
greeting_message TEXT NOT NULL DEFAULT 'Olá! 👋 Sou o assistente virtual. Vou te fazer algumas perguntas rápidas pra a equipe preparar seu atendimento.',
|
|
closing_message TEXT NOT NULL DEFAULT 'Obrigado! Recebemos suas informações e a equipe entrará em contato em breve. 💙',
|
|
|
|
-- Array de steps: [{ "prompt": "...", "variable": "...", "type": "text" }]
|
|
steps JSONB NOT NULL DEFAULT jsonb_build_array(
|
|
jsonb_build_object('prompt', 'Qual seu nome completo?', 'variable', 'nome_completo', 'type', 'text'),
|
|
jsonb_build_object('prompt', 'O que te levou a buscar atendimento? Pode me contar brevemente.', 'variable', 'motivo', 'type', 'text'),
|
|
jsonb_build_object('prompt', 'Prefere atendimento online ou presencial?', 'variable', 'modalidade', 'type', 'text'),
|
|
jsonb_build_object('prompt', 'Qual o melhor dia e horário pra você? (Ex: terça à tarde)', 'variable', 'horario_preferido', 'type', 'text')
|
|
),
|
|
|
|
-- Gatilho: quem dispara o bot?
|
|
trigger_mode TEXT NOT NULL DEFAULT 'new_contact'
|
|
CHECK (trigger_mode IN ('new_contact', 'all_unassigned', 'keyword')),
|
|
-- Usado quando trigger_mode='keyword'
|
|
trigger_keywords TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
|
|
-- Abandono automatico: se session fica ativa sem avancar por N min
|
|
idle_timeout_minutes INT NOT NULL DEFAULT 30 CHECK (idle_timeout_minutes >= 5 AND idle_timeout_minutes <= 1440),
|
|
|
|
-- Se bot deve encerrar quando paciente usa keyword de opt-out
|
|
respect_optout BOOLEAN NOT NULL DEFAULT true,
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
DROP TRIGGER IF EXISTS trg_conv_bots_updated_at ON public.conversation_bots;
|
|
CREATE TRIGGER trg_conv_bots_updated_at
|
|
BEFORE UPDATE ON public.conversation_bots
|
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
|
|
|
COMMENT ON TABLE public.conversation_bots IS
|
|
'Config do bot de triagem WhatsApp por tenant. steps contem array de perguntas.';
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Tabela: conversation_bot_sessions (estado)
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.conversation_bot_sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
thread_key TEXT NOT NULL,
|
|
contact_number TEXT,
|
|
|
|
current_step INT NOT NULL DEFAULT 0,
|
|
collected_data JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
|
|
status TEXT NOT NULL DEFAULT 'active'
|
|
CHECK (status IN ('active', 'completed', 'abandoned_idle', 'abandoned_manual', 'opted_out')),
|
|
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
last_advance_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
completed_at TIMESTAMPTZ,
|
|
abandoned_at TIMESTAMPTZ,
|
|
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
DROP TRIGGER IF EXISTS trg_bot_sessions_updated_at ON public.conversation_bot_sessions;
|
|
CREATE TRIGGER trg_bot_sessions_updated_at
|
|
BEFORE UPDATE ON public.conversation_bot_sessions
|
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
|
|
|
-- 1 sessao ativa por thread
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_bot_sessions_active_per_thread
|
|
ON public.conversation_bot_sessions (tenant_id, thread_key)
|
|
WHERE status = 'active';
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_bot_sessions_tenant_status
|
|
ON public.conversation_bot_sessions (tenant_id, status, started_at DESC);
|
|
|
|
COMMENT ON TABLE public.conversation_bot_sessions IS
|
|
'Estado do bot por thread. UNIQUE parcial garante 1 ativa por (tenant, thread).';
|
|
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- RLS
|
|
-- ---------------------------------------------------------------------------
|
|
ALTER TABLE public.conversation_bots ENABLE ROW LEVEL SECURITY;
|
|
|
|
DROP POLICY IF EXISTS "conv_bots: select membros" ON public.conversation_bots;
|
|
CREATE POLICY "conv_bots: select membros"
|
|
ON public.conversation_bots
|
|
FOR SELECT TO authenticated
|
|
USING (
|
|
public.is_saas_admin()
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.tenant_members tm
|
|
WHERE tm.tenant_id = conversation_bots.tenant_id
|
|
AND tm.user_id = auth.uid()
|
|
AND tm.status = 'active'
|
|
)
|
|
);
|
|
|
|
DROP POLICY IF EXISTS "conv_bots: write admins" ON public.conversation_bots;
|
|
CREATE POLICY "conv_bots: write admins"
|
|
ON public.conversation_bots
|
|
FOR ALL TO authenticated
|
|
USING (
|
|
public.is_saas_admin()
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.tenant_members tm
|
|
WHERE tm.tenant_id = conversation_bots.tenant_id
|
|
AND tm.user_id = auth.uid()
|
|
AND tm.role IN ('clinic_admin', 'tenant_admin')
|
|
AND tm.status = 'active'
|
|
)
|
|
)
|
|
WITH CHECK (
|
|
public.is_saas_admin()
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.tenant_members tm
|
|
WHERE tm.tenant_id = conversation_bots.tenant_id
|
|
AND tm.user_id = auth.uid()
|
|
AND tm.role IN ('clinic_admin', 'tenant_admin')
|
|
AND tm.status = 'active'
|
|
)
|
|
);
|
|
|
|
|
|
ALTER TABLE public.conversation_bot_sessions ENABLE ROW LEVEL SECURITY;
|
|
|
|
DROP POLICY IF EXISTS "bot_sessions: select membros" ON public.conversation_bot_sessions;
|
|
CREATE POLICY "bot_sessions: select membros"
|
|
ON public.conversation_bot_sessions
|
|
FOR SELECT TO authenticated
|
|
USING (
|
|
public.is_saas_admin()
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.tenant_members tm
|
|
WHERE tm.tenant_id = conversation_bot_sessions.tenant_id
|
|
AND tm.user_id = auth.uid()
|
|
AND tm.status = 'active'
|
|
)
|
|
);
|
|
|
|
DROP POLICY IF EXISTS "bot_sessions: write service_role" ON public.conversation_bot_sessions;
|
|
CREATE POLICY "bot_sessions: write service_role"
|
|
ON public.conversation_bot_sessions
|
|
FOR ALL TO service_role
|
|
USING (true) WITH CHECK (true);
|