diff --git a/database-novo/migrations/20260423000008_session_reminder_manual.sql b/database-novo/migrations/20260423000008_session_reminder_manual.sql new file mode 100644 index 0000000..3d1f55a --- /dev/null +++ b/database-novo/migrations/20260423000008_session_reminder_manual.sql @@ -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])); diff --git a/database-novo/migrations/20260423000009_session_status_trigger.sql b/database-novo/migrations/20260423000009_session_status_trigger.sql new file mode 100644 index 0000000..a0afb3f --- /dev/null +++ b/database-novo/migrations/20260423000009_session_status_trigger.sql @@ -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.'; diff --git a/database-novo/migrations/20260423000010_intake_abandoned_lead.sql b/database-novo/migrations/20260423000010_intake_abandoned_lead.sql new file mode 100644 index 0000000..a81847d --- /dev/null +++ b/database-novo/migrations/20260423000010_intake_abandoned_lead.sql @@ -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.'; diff --git a/src/features/agenda/components/AgendaEventDialog.vue b/src/features/agenda/components/AgendaEventDialog.vue index 192dd82..f8854ea 100644 --- a/src/features/agenda/components/AgendaEventDialog.vue +++ b/src/features/agenda/components/AgendaEventDialog.vue @@ -1512,6 +1512,43 @@ function onEncerrarSerie() { }); } +// ───── Lembrete manual WhatsApp (8.2) ───── +const sendingReminder = ref(false); +async function onSendManualReminder() { + if (!form.value?.id) return; + confirm.require({ + header: 'Enviar lembrete WhatsApp?', + message: `Vou mandar o template "lembrete_sessao" pra ${form.value.paciente_nome || 'o paciente'} agora. Pode disparar?`, + icon: 'pi pi-whatsapp', + acceptLabel: 'Enviar', + rejectLabel: 'Cancelar', + accept: async () => { + sendingReminder.value = true; + try { + const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', { + body: { event_id: form.value.id } + }); + if (error || !data?.ok) { + const err = data?.error || error?.message || 'unknown_error'; + let friendly = err; + if (err === 'no_phone') friendly = 'Paciente sem telefone cadastrado.'; + else if (err === 'invalid_phone') friendly = 'Telefone do paciente inválido.'; + else if (err === 'no_active_channel') friendly = 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.'; + else if (err === 'template_not_found') friendly = 'Template "lembrete_sessao" não encontrado. Configure em Configurações → WhatsApp.'; + else if (err === 'forbidden') friendly = 'Você não tem permissão pra enviar por este canal.'; + else if (String(err).startsWith('send_failed')) friendly = 'Não conseguimos enviar. Verifique a conexão do WhatsApp.'; + throw new Error(friendly); + } + toast.add({ severity: 'success', summary: 'Lembrete enviado', detail: data.to ? `Para ${data.to}` : undefined, life: 3500 }); + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro ao enviar lembrete', detail: e.message, life: 5000 }); + } finally { + sendingReminder.value = false; + } + } + }); +} + function onDelete() { if (!form.value.id) return; @@ -2439,6 +2476,19 @@ function statusExtraClass(v) {