Grupo 8: agenda ↔ WhatsApp completo (8.2 lembrar manual, 8.3 status→msg, 8.4 lead)
=== 8.2 Botão "Lembrar paciente" na agenda ===
Edge nova send-session-reminder-manual:
- Recebe {event_id}, autoriza (member ativo do tenant), resolve template
lembrete_sessao (custom → default global), envia via Evolution, registra
outbound em conversation_messages + log em session_reminder_logs com
reminder_type='manual'.
- Reusa lógica do cron reminders (sanitização, fmt datas, render template)
mas sem janela/dedup — terapeuta pode redisparar quantas vezes quiser
(log usa UPSERT; UNIQUE (event_id, reminder_type) sobrescreve).
Migration 20260423000008 adiciona 'manual' ao CHECK constraint de
session_reminder_logs.reminder_type.
UI: botão verde pi-whatsapp no footer do AgendaEventDialog (só em edit
de sessão com paciente vinculado). Confirm dialog + toast + erros
amigáveis (no_phone, invalid_phone, no_active_channel, template_not_found,
forbidden, send_failed).
=== 8.3 Status sessão dispara mensagem ===
Migration 20260423000009 cria trigger AFTER UPDATE OF status em
agenda_eventos: quando status muda pra cancelado/remarcado/confirmado,
dispara edge send-session-status-notification via pg_net (não bloqueia
o UPDATE). Settings app.settings.supabase_url/service_role_key reusadas.
Edge nova send-session-status-notification:
- Body {event_id, old_status, new_status}
- STATUS_TEMPLATE_MAP: cancelado→cancelamento_sessao, remarcado→
remarcacao_sessao, confirmado→confirmacao_sessao.
- Respeita opt-out (conversation_optouts), canal ativo, template
existente (tenant-specific → global default). Skip silencioso em
caso de falta de config.
- Insere outbound em conversation_messages (sem log unique — múltiplas
mudanças de status geram múltiplas mensagens por design).
=== 8.4 Intake abandonado vira lead no CRM ===
Migration 20260423000010:
- Adiciona 'in_progress' e 'abandoned_lead' ao CHECK de
patient_intake_requests.status. Colunas last_progress_at e
lead_thread_key.
- RPC convert_abandoned_intake_to_lead(intake_id): cria mensagem
placeholder inbound no CRM do tenant (thread_key anon:{phone}) +
conversation_notes com resumo dos dados coletados + marca status.
Edge save-intake-progress:
- POST {token, nome_completo?, telefone?, email_principal?, ...}
- Whitelist de campos (ALLOWED_FIELDS) pra proteger contra POST
malicioso tentar setar status/owner/etc.
- Busca por token, set status='in_progress' se era 'new', atualiza
campos enviados + last_progress_at.
Edge convert-abandoned-intakes (cron):
- Body opcional {idle_minutes} (default 30).
- Varre patient_intake_requests status='in_progress' + last_progress_at
mais antigo que cutoff. Filtra só os com nome_completo OU telefone
(contato mínimo pra valer lead). Chama RPC pra cada um.
Hook no form público CadastroPacienteExterno:
- Watch em nome_completo, telefone, email_principal, onde_nos_conheceu
dispara scheduleProgressSave() com debounce 1.5s.
- savePartialProgress só chama a edge se tem nome OU telefone.
- Silent fail — autosave não é crítico.
Cron do convert-abandoned-intakes NÃO ativado automaticamente (igual
heartbeat/SLA). Template comentado não está na migration — admin
descomenta SELECT cron.schedule manualmente quando quiser ligar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: session_reminder_logs aceita tipo 'manual' (8.2)
|
||||
-- ==========================================================================
|
||||
-- Permite log de lembrete disparado manualmente pelo botao "Lembrar paciente"
|
||||
-- na agenda. O UNIQUE (event_id, reminder_type) continua: disparar de novo
|
||||
-- sobrescreve o log manual anterior do mesmo evento.
|
||||
-- ==========================================================================
|
||||
|
||||
ALTER TABLE public.session_reminder_logs
|
||||
DROP CONSTRAINT IF EXISTS session_reminder_logs_reminder_type_check;
|
||||
|
||||
ALTER TABLE public.session_reminder_logs
|
||||
ADD CONSTRAINT session_reminder_logs_reminder_type_check
|
||||
CHECK (reminder_type = ANY (ARRAY['24h'::text, '2h'::text, 'manual'::text]));
|
||||
@@ -0,0 +1,84 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Trigger agenda_eventos → notificacao WhatsApp (8.3)
|
||||
-- ==========================================================================
|
||||
-- Quando um agenda_evento muda de status (ex: agendado → cancelado), dispara
|
||||
-- a edge send-session-status-notification que resolve template e envia
|
||||
-- mensagem WhatsApp ao paciente.
|
||||
--
|
||||
-- Transicoes que disparam (controlado na propria edge via STATUS_TEMPLATE_MAP):
|
||||
-- - cancelado → cancelamento_sessao
|
||||
-- - remarcado → remarcacao_sessao
|
||||
-- - confirmado → confirmacao_sessao
|
||||
--
|
||||
-- O trigger nao bloqueia o UPDATE (pg_net e assincrono). Se a edge falhar,
|
||||
-- o evento ja foi salvo — so a notificacao sera perdida.
|
||||
--
|
||||
-- Pre-requisito: settings app.settings.supabase_url e app.settings.service_role_key
|
||||
-- configuradas (mesmas usadas pelo cron do heartbeat e SLA).
|
||||
-- ==========================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_notify_agenda_status_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_url TEXT;
|
||||
v_key TEXT;
|
||||
BEGIN
|
||||
-- So dispara se status realmente mudou
|
||||
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
|
||||
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
|
||||
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Precisa de paciente vinculado (senao nao tem telefone)
|
||||
IF NEW.patient_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Busca settings
|
||||
BEGIN
|
||||
v_url := current_setting('app.settings.supabase_url', true);
|
||||
v_key := current_setting('app.settings.service_role_key', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Settings nao configuradas — silencioso
|
||||
RETURN NEW;
|
||||
END;
|
||||
|
||||
IF v_url IS NULL OR v_key IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Fire and forget (pg_net)
|
||||
PERFORM net.http_post(
|
||||
url := v_url || '/functions/v1/send-session-status-notification',
|
||||
headers := jsonb_build_object(
|
||||
'Authorization', 'Bearer ' || v_key,
|
||||
'Content-Type', 'application/json'
|
||||
),
|
||||
body := jsonb_build_object(
|
||||
'event_id', NEW.id,
|
||||
'old_status', OLD.status,
|
||||
'new_status', NEW.status
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_agenda_status_notify ON public.agenda_eventos;
|
||||
CREATE TRIGGER trg_agenda_status_notify
|
||||
AFTER UPDATE OF status ON public.agenda_eventos
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_notify_agenda_status_change();
|
||||
|
||||
COMMENT ON FUNCTION public.fn_notify_agenda_status_change() IS
|
||||
'Dispara edge send-session-status-notification quando status de agenda_eventos muda. So pra status mapeados (cancelado/remarcado/confirmado). Nao bloqueia UPDATE.';
|
||||
@@ -0,0 +1,159 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: Intake abandonado vira lead no CRM (8.4)
|
||||
-- ==========================================================================
|
||||
-- Quando paciente abre o link de cadastro externo e preenche dados parciais
|
||||
-- (ex: nome + telefone) mas nao finaliza, rodamos um cron periodico que:
|
||||
-- 1. Varre patient_intake_requests com status='in_progress' ha > N min
|
||||
-- (default 30)
|
||||
-- 2. Pra cada, cria uma mensagem outbound placeholder no CRM anunciando:
|
||||
-- "Paciente X preencheu parcialmente o cadastro e abandonou" + dados
|
||||
-- coletados (nome, telefone, motivo se tiver)
|
||||
-- 3. Marca intake como status='abandoned_lead' pra nao reprocessar
|
||||
--
|
||||
-- Pre-requisito pro fluxo funcionar: o form de intake (CadastroPacienteExterno)
|
||||
-- precisa chamar save-intake-progress em algum evento de progresso
|
||||
-- (ex: ao preencher telefone). Sem isso, nenhum row fica 'in_progress'.
|
||||
-- ==========================================================================
|
||||
|
||||
-- Expande CHECK de status pra incluir 'in_progress' e 'abandoned_lead'
|
||||
ALTER TABLE public.patient_intake_requests
|
||||
DROP CONSTRAINT IF EXISTS chk_intakes_status;
|
||||
|
||||
ALTER TABLE public.patient_intake_requests
|
||||
ADD CONSTRAINT chk_intakes_status
|
||||
CHECK (status = ANY (ARRAY[
|
||||
'new'::text,
|
||||
'in_progress'::text,
|
||||
'converted'::text,
|
||||
'rejected'::text,
|
||||
'abandoned_lead'::text
|
||||
]));
|
||||
|
||||
-- Timestamp da ultima atualizacao de progresso (autosave)
|
||||
ALTER TABLE public.patient_intake_requests
|
||||
ADD COLUMN IF NOT EXISTS last_progress_at TIMESTAMPTZ;
|
||||
|
||||
-- Referencia pra mensagem/thread criada como lead (evita duplicacao)
|
||||
ALTER TABLE public.patient_intake_requests
|
||||
ADD COLUMN IF NOT EXISTS lead_thread_key TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intakes_progress_pending
|
||||
ON public.patient_intake_requests (last_progress_at)
|
||||
WHERE status = 'in_progress';
|
||||
|
||||
COMMENT ON COLUMN public.patient_intake_requests.last_progress_at IS
|
||||
'Ultima vez que o form salvou dados parciais. Cron usa pra detectar abandonados.';
|
||||
COMMENT ON COLUMN public.patient_intake_requests.lead_thread_key IS
|
||||
'Thread key da conversa criada no CRM quando vira lead abandonado.';
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- RPC: convert_abandoned_intake_to_lead
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Processa 1 intake abandonado: cria conversation_message (outbound placeholder)
|
||||
-- + conversation_note + marca status. Retorna o thread_key gerado.
|
||||
--
|
||||
-- SECURITY DEFINER pra bypass RLS do conversation_messages/notes.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id UUID)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_intake RECORD;
|
||||
v_tenant_id UUID;
|
||||
v_thread_key TEXT;
|
||||
v_phone TEXT;
|
||||
v_note_body TEXT;
|
||||
v_admin_id UUID;
|
||||
v_msg_id BIGINT;
|
||||
BEGIN
|
||||
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::UUID; END IF;
|
||||
|
||||
-- Tenant_id vem via owner_id (tenant_members)
|
||||
SELECT tenant_id INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_intake.owner_id
|
||||
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END
|
||||
LIMIT 1;
|
||||
|
||||
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||
|
||||
-- Normaliza telefone pra thread_key
|
||||
v_phone := regexp_replace(COALESCE(v_intake.telefone, ''), '\D', '', 'g');
|
||||
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55' || v_phone; END IF;
|
||||
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||
v_thread_key := 'anon:' || v_phone;
|
||||
|
||||
-- Nota com dados coletados
|
||||
v_note_body := format(
|
||||
'📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||
E'\n', E'\n',
|
||||
COALESCE(v_intake.nome_completo, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.telefone, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.email_principal, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.onde_nos_conheceu, '—'),
|
||||
E'\n', E'\n',
|
||||
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI'),
|
||||
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI')
|
||||
);
|
||||
|
||||
-- Pega 1 admin do tenant pra preencher created_by da nota
|
||||
SELECT user_id INTO v_admin_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = v_tenant_id
|
||||
AND role IN ('tenant_admin', 'clinic_admin')
|
||||
AND status = 'active'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_admin_id IS NULL THEN
|
||||
v_admin_id := v_intake.owner_id;
|
||||
END IF;
|
||||
|
||||
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
|
||||
INSERT INTO public.conversation_messages
|
||||
(tenant_id, channel, direction, from_number, to_number, body, provider,
|
||||
provider_raw, kanban_status)
|
||||
VALUES (
|
||||
v_tenant_id, 'whatsapp', 'inbound',
|
||||
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||
NULL,
|
||||
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.',
|
||||
COALESCE(v_intake.nome_completo, 'Visitante')),
|
||||
'system',
|
||||
jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id),
|
||||
'awaiting_us'
|
||||
) RETURNING id INTO v_msg_id;
|
||||
|
||||
-- Cria nota interna
|
||||
INSERT INTO public.conversation_notes
|
||||
(tenant_id, thread_key, contact_number, body, created_by)
|
||||
VALUES (
|
||||
v_tenant_id, v_thread_key,
|
||||
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||
v_note_body, v_admin_id
|
||||
);
|
||||
|
||||
-- Atualiza intake
|
||||
UPDATE public.patient_intake_requests
|
||||
SET status = 'abandoned_lead',
|
||||
lead_thread_key = v_thread_key,
|
||||
updated_at = now()
|
||||
WHERE id = p_intake_id;
|
||||
|
||||
RETURN p_intake_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.convert_abandoned_intake_to_lead(UUID) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.convert_abandoned_intake_to_lead(UUID) TO service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.convert_abandoned_intake_to_lead(UUID) IS
|
||||
'Transforma 1 intake abandonado em lead no CRM: cria mensagem placeholder + nota com dados + marca status.';
|
||||
Reference in New Issue
Block a user