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:
@@ -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) {
|
||||
<Button v-if="isEdit && hasSerie" label="Encerrar série" icon="pi pi-trash" severity="danger" outlined size="small" class="rounded-full text-xs h-8" @click="onEncerrarSerie" />
|
||||
<Button v-if="isEdit && !hasSerie" icon="pi pi-trash" severity="danger" outlined size="small" class="rounded-full h-9 w-9" v-tooltip.bottom="'Remover'" @click="onDelete" />
|
||||
|
||||
<!-- Lembrar paciente (WhatsApp on-demand) -->
|
||||
<Button
|
||||
v-if="isEdit && isSessionEvent && form.paciente_id"
|
||||
icon="pi pi-whatsapp"
|
||||
severity="success"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full h-9 w-9"
|
||||
v-tooltip.bottom="'Enviar lembrete WhatsApp agora'"
|
||||
:loading="sendingReminder"
|
||||
@click="onSendManualReminder"
|
||||
/>
|
||||
|
||||
<!-- Google Calendar link -->
|
||||
<a
|
||||
v-if="isEdit && googleCalendarUrl"
|
||||
|
||||
@@ -177,6 +177,41 @@ async function fetchInviteInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave de progresso (8.4) — backend marca status='in_progress' e, se
|
||||
// paciente abandonar, job cron cria lead no CRM.
|
||||
// Debounce pra não spammar: salva 1.5s após parar de digitar em campos chave.
|
||||
let _progressTimer = null;
|
||||
const progressDebounceMs = 1500;
|
||||
function scheduleProgressSave() {
|
||||
if (!tokenOk.value || sucesso.value) return;
|
||||
if (_progressTimer) clearTimeout(_progressTimer);
|
||||
_progressTimer = setTimeout(savePartialProgress, progressDebounceMs);
|
||||
}
|
||||
|
||||
async function savePartialProgress() {
|
||||
if (!tokenOk.value || sucesso.value) return;
|
||||
const nome = String(form.nome_completo || '').trim();
|
||||
const tel = digitsOnly(form.telefone || '');
|
||||
// Só salva se já tem mínimo pra contato (nome OU telefone)
|
||||
if (!nome && !tel) return;
|
||||
try {
|
||||
await supabase.functions.invoke('save-intake-progress', {
|
||||
body: {
|
||||
token: token.value,
|
||||
nome_completo: nome || undefined,
|
||||
telefone: tel || undefined,
|
||||
email_principal: String(form.email_principal || '').trim() || undefined,
|
||||
onde_nos_conheceu: String(form.onde_nos_conheceu || '').trim() || undefined
|
||||
}
|
||||
});
|
||||
} catch { /* silencioso — autosave não é crítico */ }
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [form.nome_completo, form.telefone, form.email_principal, form.onde_nos_conheceu],
|
||||
scheduleProgressSave
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user