Sai do estado "preview/sandbox" e liga o Melissa como layout real
ativavel pelo user em Configuracoes -> Profile.
Mudancas:
- routes.misc.js: /preview/melissa/:secao? -> /melissa/:secao?,
nome PreviewMelissa -> Melissa. Sem alias por compat (autorizado).
- router/index.js: novo beforeEach apos o supportGuard e antes do
applyGuards. Quando to.name e' therapist.dashboard ou admin.dashboard
E localStorage.layout_variant === 'melissa' E viewport >= 1280px,
redireciona pra { name: 'Melissa' }. Le do localStorage (gravado pelo
bootstrapUserSettings + setVariant) pra evitar esperar store do DB e
evitar flash do shell antes do redirect. Bypassa mobile pq Melissa
nao foi feito pra <xl e o effectiveVariant ja forca 'classic' la.
- MelissaLayout.vue: 2 chamadas router.push apontavam pra
'PreviewMelissa', agora 'Melissa'. Header doc atualizado.
- useMelissaPacientes.js: comment doc citando /preview/melissa
generalizado pra "sem session retorna vazio".
- ProfilePage.vue: card Melissa perde badge "Em construcao" e ganha
badge "Beta". Texto explicativo perde "navegacao completa ainda
nao esta integrada" e ganha "Ao salvar, sua proxima entrada na
home cai direto no Melissa". Link /preview/melissa -> /melissa.
Remove regra CSS .lv-card--wip orfa.
Tradeoff aceito: rotas especificas (/therapist/agenda etc.) seguem
no shell classico/rail. So a HOME do role e' interceptada pra /melissa.
Coerente com o desenho atual do MelissaLayout, que ja abre Agenda /
Pacientes / etc. como overlays internos via deep-link /melissa/<secao>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O watch de scheduleProgressSave referenciava form.* antes da declaração
do reactive form, violando TDZ e quebrando a página inteira no load.
Move o watch pra depois da `const form = reactive(resetForm())`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Severidade dos toasts de validação: warn → error (não selecionar tenant,
valor < 1, > WA_ADJUST_MAX, nada removível, excede max removível)
- Remove :max do <InputNumber> no formulário — antes ele clampeava
silenciosamente o valor digitado pro máximo permitido, escondendo o erro.
Agora deixa o usuário digitar e estourar o toast vermelho do submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fecha o gap de analytics que faltava: MRR/ARR de assinatura já existia,
mas não havia visão de receita dos créditos WhatsApp comprados via Asaas.
Banco (migration 20260423000011) — 4 RPCs saas_admin only:
- saas_wa_credits_revenue_stats(from, to): total arrecadado, count de
compras, tenants únicos, créditos vendidos, ticket médio.
- saas_wa_credits_top_packages(from, to): ranking top 10 pacotes por
revenue, consolida nome atual se pacote foi renomeado.
- saas_wa_credits_usage_summary(): snapshot atual de lifetime_purchased
vs lifetime_used vs current_balance + taxa de consumo.
- saas_wa_credits_revenue_evolution(from, to, bucket_days): série
temporal pra sparkline.
Todas com check is_saas_admin() no início + SECURITY DEFINER.
Frontend:
- useSaasCreditsAnalytics composable orquestra as 4 RPCs em paralelo
com seleção de período (30d/90d/6m/12m) que ajusta bucket_days
automaticamente.
- SaasCreditsRevenueCard.vue: 4 KPIs (receita + ticket médio, compras +
tenants, créditos vendidos, % consumo global), sparkline SVG com
indicador de tendência, ranking top 5 pacotes.
- Integrado no SaasDashboard logo antes da tabela "Distribuição por plano".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
=== 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>
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>
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>