Card novo pra clínica e terapeuta com 3 métricas + sparkline:
- Tempo médio (e mediana) de 1ª resposta no período
- Taxa de SLA cumprido — % de respostas dentro do threshold configurado
- Contagem total de respostas no período
- Sparkline da evolução com indicador de tendência (melhorando/piorando)
- Ranking top 5 terapeutas (só no ClinicDashboard)
Filtro de período: 7/30/90 dias (muda granularidade do bucket:
1/7/15 dias pra sparkline com ~5-6 pontos).
Banco (migration 20260423000006):
- Helper interno _first_response_runs: identifica "runs" de inbound
(sequências do paciente sem outbound entre) e calcula delta até a
próxima outbound. Evita contar múltiplas mensagens repetidas do
paciente. responder_id vem de conversation_assignments.
- first_response_stats: agregados (count, avg, median, min, max,
sla_compliance_rate baseado em conversation_sla_rules).
- first_response_by_therapist: ranking com avg e count por assigned_to.
- first_response_evolution: série temporal com bucket alinhado a
p_from (p_from + bucket_index * N days). Parâmetro p_bucket_days
deixa o frontend escolher granularidade por período.
Todas SECURITY DEFINER + GRANT authenticated/service_role. Filtro
opcional por therapist_id nas funções que aplicam.
Frontend:
- useFirstResponseAnalytics composable wraps as 3 RPCs com cache
via Promise.all paralelo. Helper formatSeconds (Ns/Xmin/Xh).
- FirstResponseCard.vue renderiza sparkline SVG nativo
(sem lib extra), cor da taxa SLA por threshold (verde ≥80%,
âmbar ≥50%, vermelho).
- Integrado em ClinicDashboard (visão global) e TherapistDashboard
(filtrado por ownerId, sem ranking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: onclick da Notification do browser (nativa do Chrome/Windows)
fazia window.location.pathname = payload.deeplink direto, sem resolver
alias semântico e sem abrir o drawer em alertas com thread_key. Como
praticamente todos os nossos alertas do SLA vêm com deeplink '/conversas'
(alias), o click na notificação do Chrome caía em NotFound.
Fix:
- fireBrowserNotification agora aceita um callback onClick e é exportada.
- Removido o fireBrowserNotification hardcoded do subscribeRealtime do
store (passa a ser responsabilidade do composable useNotifications).
- useNotifications.onRealtimeNotification dispara toast + browser notif
passando handleNotificationAction como handler.
- handleNotificationAction: se tem thread_key → abre ConversationDrawer
global direto na thread; senão resolve alias e router.push. Mesma
lógica que já existe no toast e no clique do NotificationItem do sino.
Agora os 3 pontos de click (toast, sininho, notificação nativa do OS)
convergem pro mesmo comportamento.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 melhorias no item de notificação do sininho:
1. handleRowClick: agora resolve alias (/conversas → /therapist|admin/
conversas) baseado em tenantStore.activeRole. Antes caía em NotFound
quando o deeplink era /conversas ou /crm/conversas.
2. Se payload tem thread_key (alertas do SLA), o clique abre o drawer
global diretamente na thread em vez de navegar — experiência similar
à do botão do toast. Fallback pra deeplink se a thread sumiu.
3. typeMap ganha entrada 'system_alert' (ícone pi-exclamation-circle,
borda vermelha).
4. Botões inline "Conversa" e "Abrir" aparecem embaixo do detail quando
o payload tem thread_key ou deeplink — atalhos pras ações mais
comuns sem precisar clicar na área do item.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O alerta já vem com payload.thread_key vindo do edge conversation-sla-
check. Agora o toast renderiza 2 botões lado a lado quando thread_key
existe:
- "Abrir conversa" (outlined) → abre ConversationDrawer global direto
na thread, sem navegar de página. Usa o store global que já existe.
- "Abrir CRM →" (solid) → fallback pra lista inteira via deeplink alias.
openConversationDrawer busca o row da view conversation_threads pelo
tenant+thread_key e delega pro conversationDrawerStore.openForThread.
Se a thread sumiu (arquivada/paciente deletado), cai no fallback de
navegar pra /conversas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: toast do SLA tinha deeplink /crm/conversas que caía em NotFound.
As rotas reais são /therapist/conversas (terapeuta) e /admin/conversas
(clinic_admin), contextuais por role.
Fix: novo sistema de aliases em AppLayout.resolveDeeplink.
DEEPLINK_ALIASES traduz links semânticos (ex: /conversas, /crm/conversas)
pra rota real baseado em tenantStore.activeRole. Edge do SLA agora
emite /conversas (alias) em vez de path hardcoded; frontend resolve.
Padrão aplicável pras próximas features — basta registrar novo alias
aqui quando a rota depender de contexto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: a cada mount (F5, navegação), todas as system_alert não-lidas
voltavam a disparar toast mesmo que o alerta já não fizesse mais
sentido (ex: saldo baixo já restabelecido, mas notif histórica ainda
não-lida reaparecia como toast sticky vermelho a cada reload).
Fix: seed do set alertedIds marca TODAS as system_alert do load inicial
como "já vistas nesta sessão". Alertas continuam no sino/drawer — o
usuário vê que tem pendências, mas sem bombardeio de toasts repetidos.
Toast só dispara pra alertas que chegarem depois do mount — seja via
Realtime (novidade) ou via catch-up encontrando id ainda não no set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completa o Grupo 3 do CRM com alerta de conversa sem resposta além
do tempo configurado — reutiliza o pipeline system_alert (toast
vermelho sticky + sininho + drawer).
Banco (migration 20260423000005):
- conversation_sla_rules: 1 linha por tenant com threshold global
(1-1440 min), respect_business_hours, business_hours_start/end,
business_days (ISO 1=seg..7=dom), alert_scope (assigned_only|all),
notify_admin_on_breach. Default: enabled=false.
- conversation_sla_breaches: incidents com UNIQUE parcial
(tenant_id, thread_key) WHERE resolved_at IS NULL — idempotência.
- Trigger AFTER INSERT em conversation_messages resolve o breach
automaticamente quando chega nova outbound na thread.
- RPCs service_role: sla_open_breach (idempotente), sla_mark_notified.
- RLS: membros do tenant leem; clinic_admin/tenant_admin/saas_admin
escrevem na config; service_role escreve em breaches.
Edge function conversation-sla-check (cron 5min):
- Varre tenants com enabled=true.
- Query conversation_threads onde last_message_direction='inbound'
(+ assigned_to NOT NULL se scope='assigned_only').
- Se respect_business_hours: calcula businessMinutesElapsed em TS
iterando dia por dia a interseção da janela [start,end] com
[last_inbound_at, now], só em dias marcados em business_days. TZ
fixa em America/Sao_Paulo via Intl.DateTimeFormat.
- Se elapsed >= threshold: sla_open_breach (idempotente) + notifica
assigned_to sempre + admins se notify_admin_on_breach (deduplicado
via Set).
- Anti-spam: só notifica 1x por incident (checa notified_at).
- Notification leva deeplink pra /crm/conversas e payload.thread_key
pro frontend destacar a conversa (fora de escopo deste commit).
UI em /configuracoes/conversas-sla:
- Toggle enabled + InputNumber threshold com preview "≈ Xh Ymin".
- Toggle respect_business_hours → revela start/end + seletor de dias
úteis (pills toggleáveis Seg..Dom, ISO order).
- Select scope.
- Toggle notify_admin_on_breach.
- Card abaixo com breaches dos últimos 7 dias (status aberto/resolvido,
thread_key, limite configurado no momento do breach, duração).
- Adicionada na ConfiguracoesPage landing + rota /configuracoes/conversas-sla.
Cron template comentado no fim da migration (mesmo padrão do heartbeat).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: acumulando N system_alert não-lidas, o refreshAndMaybeAlert
(mount / visibilitychange / polling 60s) disparava N toasts de uma vez.
Comum após recarregar a página com alertas pendentes do último teste.
Fix: no catch-up, mostra só a notif mais recente, com sufixo "+N
outros alertas no sino" no detail se houver múltiplas. As demais são
marcadas no alertedIds pra não redisparar — continuam visíveis no
sininho/drawer com badge.
Eventos novos via Realtime seguem aparecendo individualmente (fluxo
normal — o usuário está online vendo chegar).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Novo <Toast group="system-alerts"> no AppLayout com template custom
(vive no bloco global — persiste em qualquer layout/rota). Renderiza:
- Ícone de alerta + título em bold
- Detail em texto menor com opacity
- Botão com deeplink quando payload.deeplink existe, severity danger
Label do botão inferido do deeplink:
- /configuracoes/creditos-whatsapp → "Ir pra loja"
- /configuracoes/whatsapp-pessoal → "Ver conexão"
- /configuracoes/whatsapp-oficial → "Ver canal oficial"
- outros → "Abrir" (ou payload.actionLabel se vier explícito)
Clique navega via router.push se é path interno, senão
window.location.href. Toast continua sticky (24h) + closable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Realtime em ambiente self-hosted às vezes perde eventos (WebSocket
desconecta silenciosamente, JWT expira, sleep do SO, etc). Sem fallback,
system_alert chega no DB mas toast nunca dispara — usuário só vê ao
relogar ou recarregar.
Três caminhos complementares agora:
1. Realtime (instantâneo, quando funciona)
2. visibilitychange — ao voltar pro foco da aba, recarrega notificações
e dispara toast pras system_alert não-lidas ainda não exibidas
3. Polling a cada 60s como redundância
Set alertedIds (in-memory por sessão) evita toast duplicado quando dois
caminhos entregam a mesma notif. Seed inicial marca notifs já lidas/
arquivadas no mount pra não disparar retroativamente.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fecha o loop do Marco B — tenant não zera mais saldo sem aviso.
Nova função fn_whatsapp_low_balance_notify + trigger BEFORE UPDATE em
whatsapp_credits_balance:
- Dispara quando NEW.balance < NEW.low_balance_threshold e
NEW.low_balance_alerted_at IS NULL
- Insere system_alert pros stakeholders do tenant (owner do canal
WhatsApp ativo + clinic_admin + tenant_admin, deduplicado via UNION)
- Deeplink direto pra /configuracoes/creditos-whatsapp
- Seta NEW.low_balance_alerted_at = now() pra anti-spam
Reset do anti-spam já existia: add_whatsapp_credits seta
low_balance_alerted_at=NULL ao creditar (purchase/topup/refund).
Assim o ciclo completo funciona: cai abaixo → alerta → compra recrédita
→ cai de novo futuramente → alerta de novo.
Toast no frontend já é sticky vermelho pra type='system_alert'
(commit anterior). Config de threshold já existia na UI em
/configuracoes/creditos-whatsapp.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cadeia de fixes descoberta ao testar o heartbeat 6.1 num tenant que migrou
de Evolution → Twilio e precisava voltar pro Evolution.
1. RLS notification_channels (migration 20260423000003)
- Policy antiga tinha `deleted_at IS NULL` como primeira condição AND,
bloqueando leitura de soft-deleted até pro próprio owner/saas_admin.
- Isso fazia o chooser nunca detectar "canal antigo pra reativar".
- Relaxada: owner/membro/saas_admin leem inclusive soft-deleted.
- Filtro de deleted_at fica no código aplicativo (todos os queries já
filtram explicitamente quando querem apenas ativos).
2. Edge function reactivate-notification-channel (nova)
- Espelho da deactivate existente; service_role bypass RLS.
- Aceita {channel_id} OU {tenant_id + provider}.
- Autoriza saas_admin OU membro ativo do tenant.
- Garante exclusividade: soft-deleta qualquer OUTRO canal ativo do
mesmo tenant+channel.
- Reseta metadata.first_unhealthy_at + connection_status=disconnected
(heartbeat começa do zero).
3. SaasWhatsappPage (/saas/whatsapp)
- loadChannel busca soft-deleted como fallback quando não tem ativo.
- saveCredentials detecta soft-deleted e chama reactivate edge,
depois atualiza credentials+display_name.
- Banner âmbar "Canal configurado anteriormente" + botão vira
"Reativar e salvar".
4. ConfiguracoesWhatsappPage tenant (/configuracoes/whatsapp-pessoal)
- loadCredentials busca soft-deleted como fallback.
- Card âmbar "WhatsApp Pessoal foi usado anteriormente" com botão
"Reativar WhatsApp Pessoal" em vez de mostrar apenas "chame o suporte".
5. ChooserPage (/configuracoes/whatsapp)
- Fix bug lateral: comparava activeProvider === 'evolution' (template)
com 'evolution_api' (DB) — card nunca mostrava estado ativo. Agora
normaliza via computed activeProviderKey.
- softDeletedByProvider map carregado no mount; cards que têm row
soft-deleted mostram "Reativar" em vez de "Ativar".
- handleChoose chama reactivate edge antes de goSetup se detecta
soft-deleted do provider escolhido.
6. whatsapp-heartbeat-check: notifica owner do channel + admins
- notifyChannelStakeholders substitui notifyTenantAdmins.
- Set dedupa o owner_id do channel + clinic_admin + tenant_admin.
- Em tenant solo: 1 notificação; em clínica com canal de terapeuta
específico: terapeuta (owner) + admin recebem; em clínica com canal
do próprio admin: 1 (owner=admin).
7. Toast frontend para system_alert
- notificationStore.subscribeRealtime aceita callback onInsert.
- useNotifications registra callback que dispara toast PrimeVue
(severity error, life 24h, closable) para type='system_alert'.
- Usuário precisa fechar manualmente — alerta crítico de infra
não pode sumir sozinho.
Cron heartbeat ativado em runtime local via cron.schedule()
(não vai neste commit — é config de ambiente, não migration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Detecta celular desconectado antes de falhar envios silenciosamente.
Banco (migration 20260423000002):
- Tabela whatsapp_connection_incidents (tenant_id, channel_id, kind,
started_at, resolved_at, duration_seconds, notified_at, details).
UNIQUE parcial garante no máximo 1 incident aberto por channel.
- RPCs whatsapp_heartbeat_open_incident (idempotente), _resolve_open_incidents
e _mark_notified. Service_role only.
- RLS: membros do tenant leem, saas_admin tudo.
- ALTER notifications.type pra aceitar 'system_alert' (usado pelo alerta).
Edge function whatsapp-heartbeat-check:
- Varre notification_channels provider=evolution_api e ativos.
- GET {api_url}/instance/connectionState/{instance} (timeout 8s, rewrite
localhost → host.docker.internal pra containers).
- Mapeia state pra connection_status (open/connecting/qr_pending/
disconnected/error), persiste + last_health_check.
- Lógica de threshold: marca first_unhealthy_at em metadata na primeira
falha; só abre incident após heartbeat_threshold_minutes (default 5).
- Notifica admins ativos (clinic_admin/tenant_admin) do tenant via
insert em notifications. Anti-spam: só notifica 1x por incident.
- Aceita ?channel_id=X pra check on-demand de um tenant específico.
UI tenant (/configuracoes/whatsapp-pessoal):
- Novo card "Monitoramento de conexão" com toggle alerts_enabled +
InputNumber threshold (1-60 min). Persiste em
notification_channels.metadata.
- Histórico últimos 7 dias: kind (tag colorida), aberto/resolvido,
início → fim, duração formatada (Ns/Xmin Ys/Nh Xmin).
UI SaaS (/saas/whatsapp):
- Badge "N incidents abertos" no header quando há algum.
- Botão "Verificar tudo agora" invoca a edge function e atualiza a lista.
- Tabela enriquecida: coluna Status ganha pill "Incident aberto",
colunas novas Última check e Incidents 7d (em laranja se > 0).
Cron template no final da migration (comentado — descomentar
cron.schedule pra ativar 2min periódico).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fecha polimento do Marco B (créditos/Asaas) entregue em 21/04.
Nova RPC admin_adjust_whatsapp_credits(tenant, amount_signed, admin_id, note):
- |amount| <= 1000 por operação (anti dedo-gordo). Valores maiores → repetir.
- Em remoção (amount < 0), aplica regra FIFO cortesia primeiro:
removable = max(0, sum(topup_manual+adjustment+refund) - usage_total).
Créditos de 'purchase' (Asaas/PIX) são intocáveis — estorno real vai pelo
fluxo financeiro do Asaas.
- Protegida por is_saas_admin() — authenticated comum não consegue chamar.
- Registra como kind='adjustment' com amount signed (+ ou -).
Helper get_whatsapp_removable_balance(tenant) retorna {balance, removable,
protected_amount, topup_net, usage_total} pra UI mostrar breakdown.
Aba 4 (Pacotes WhatsApp):
- Desativação dispara ConfirmDialog com histórico (N compras, M tenants
distintos) + aviso forte se é o único pacote ativo + nota que créditos já
adquiridos continuam válidos.
- Fix visual: :key no ToggleSwitch força re-mount durante confirm pra não
desligar visualmente antes do accept.
Aba 5 (Topup → Ajuste):
- Substituído Select de kind por SelectButton Adicionar/Remover.
- InputNumber max 1000 · label e botão dinâmicos.
- Modo Remover: card laranja com breakdown removível/protegido, botão
vermelho, confirm obrigatório com saldo resultante.
- Error mapping friendly pt-BR pros códigos da RPC.
ConfirmDialog com v-html habilitado pra suportar <br><br> entre frases
e <strong>/cores. Inputs livres (row.name, tenantName) passam por
escapeHtml() antes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documento "ler primeiro ao voltar" reflete estado atual: 15 areas auditadas,
zero critico/alto aberto, A#31 reformulada como "Preparacao pra deploy"
(MVP nao tem cloud Supabase nem secrets reais).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>