# W1-GATILHOS — relatório **Worktree:** `/Users/rafaelmelgaco/fv-gatilhos` · **Branch:** `fv/gatilhos` (de `feat/followup-vivo` = `4f89a0da`) **Autor:** DevGatilhos · **Data:** 2026-08-10 --- ## 1. O que já era emitido em `event_log` (a lista levantada) Trinta e cinco tipos, extraídos dos `p_event_type` de toda chamada a `emit_event` em `lib/`, `app/` e `workers/` no SHA `4f89a0da` (excluindo `*.test.ts`): ``` ai.handoff_resolved lead.created message.failed ai.handoff_triggered lead.risk_backlog_seeded message.received ai.responded lead.stage_changed message.send_requested ai.sentiment_alert lead.tag_added message.sent ai_agent.dispatch_requested lead.updated org.updated ai_agent.run_started lgpd.data_request_received user.profile_updated contact.anonymized lgpd.export_delivered whatsapp.chat_id_not_recognized contact.created lgpd.export_generated whatsapp.conversation_mark_failed contact.tag_added lgpd.redact_applied knowledge_source.updated contact.updated lgpd.redact_failed media.derive_requested conversation.claimed lgpd.redact_received media.persist_requested conversation.transferred crm.activity_write_failed ``` Mais três emitidos por **trigger Postgres** (`fn_emit_event_on_lead_change`, via `fn_log_event` — nenhum faz HTTP): `lead.won`, `lead.lost`, `lead.reopened`, e `lead.assigned` na troca de dono. Consumidores registrados hoje (`lib/event-log/register-handlers.ts`): resposta de IA, sentimento, handoff por sentimento, indexador RAG, export e redact LGPD, regras de automação, reatividade do follow-up, persistência e derivação de mídia — e, agora, o gatilho de etapa. ### O achado que muda o desenho `lead.stage_changed` sai de **exatamente três** rotas HTTP: | Emissor | Linha | Quando | |---|---|---| | `app/api/v1/leads/[id]/move/route.ts` | 171 | arrasto do card no quadro | | `app/api/v1/leads/_handler.ts` (`moveLeadHandler`) | 619 | MCP e regras de automação | | `app/api/v1/leads/bulk/route.ts` | 200 | movimento em lote | **`lib/leads/agent-stage-sync.ts` não é um deles.** O briefing afirmava que a linha 220 desse arquivo emite `stage_changed` em `event_log`; medido, o arquivo não tem uma única ocorrência de `event_log`. Ele escreve `crm_leads.stage_id` direto (linha 238) e grava uma **atividade** em `crm_lead_activities` via `emitLeadActivity` (linha 264). O maestro confirmou por medição independente e corrigiu o briefing na fonte. **Consequência que fica aberta, e não é só minha:** card movido pelo assistente de IA não arma este gatilho — e também não dispara as **regras de automação**, que consomem o mesmo `lead.stage_changed` (`lib/automation/engine.handler.ts:9`). Não consertei porque o conserto muda comportamento de automação existente, fora da minha fronteira de arquivos. Reportado ao maestro; o lugar do conserto é o emissor, nunca um segundo produtor lendo a atividade — isso duplicaria a verdade e passaria a disparar dois enrollments no dia em que o primeiro fosse corrigido. --- ## 2. "Caso" e "demanda": não são a mesma coisa, e o gatilho pedido colide São **duas entidades distintas**, com donos e ciclos de vida diferentes: | | `demandas` (migrations 0136–0138) | `agent_cases` (migration 0050) | |---|---|---| | O que é | o problema do cliente | o caso de **escalada** — a IA travou e passou ao humano | | Como abre | **automática**, no primeiro `inbound` de todo contato sem demanda aberta (trigger `trg_demanda_abre_no_inbound` em `messages`) | quando o agente aciona handoff | | Vocabulário | `estado`, `origem`, `proximo_passo`, `desfecho` | `status`, `blocker`, `followup_attempts` | | Liga as duas | `demandas.agent_case_id` — ponteiro, não cópia | | (O briefing citava a migration `0119`; a de demandas é a `0136`.) Daí saem dois problemas com "caso aberto" como gatilho: 1. **Sobre `demandas`, o gatilho é vazio de significado.** Como toda primeira mensagem de todo contato abre demanda, "quando abrir um caso" equivale a "quando alguém escrever pela primeira vez" — o follow-up dispararia em cima de quem acabou de falar com a gente. 2. **Sobre `agent_cases`, o gatilho colide de frente com o que já existe.** A abertura do caso emite `ai.handoff_triggered`, e `lib/followup/reactivity.ts` já consome esse evento para **pausar ou cancelar** todos os follow-ups vivos do contato — porque tem um humano atendendo. Um gatilho que criasse um enrollment no mesmo evento estaria brigando com a política de handoff dentro do mesmo tick. **Proposta:** o gatilho coerente é o **simétrico** — *caso encerrado*, que já existe no schema como `conversation_end` e tem evento (`ai.handoff_resolved`, e o fechamento de conversa que `fn_demanda_fecha_com_conversa` já observa). É o momento em que um follow-up de pós-atendimento faz sentido. **Parei aqui e aguardo a decisão do Rafael** antes de construir — construir "caso aberto" como pedido literalmente entregaria um controle que briga com outro. --- ## 3. "Proposta feita": não existe tabela, e não precisa de uma Varri `lib/`, `app/` e `supabase/`: não há entidade de proposta comercial. O que existe é: - **`crm_stages.agent_stage_hint`** (migration 0084) com o valor `negotiating`, definido no próprio classificador como *"há proposta/preço/condições na mesa e o lead está discutindo valor, desconto, parcelamento"* (`lib/agent-engine/agent/stage-classifier.ts:55`); - `lib/leads/next-action.ts`, onde "proposta" significa outra coisa — a **próxima ação proposta pela IA** ao humano. Homônimo, não sinônimo. Ou seja: **"proposta feita" já é uma etapa do funil.** No DeskcommCRM o negócio que teve proposta é o negócio que está na coluna "Proposta enviada" (ou o nome que o clone deu a ela). Aplicando DIRC, é caso de **Calcular/Referenciar**, não de Duplicar: modelar uma tabela `propostas` criaria uma segunda verdade sobre um estado que o funil já guarda, e cujo movimento já é auditado. **Portanto o gatilho 3 está entregue pelo gatilho 1** — o usuário escolhe a etapa "Proposta enviada" no mesmo controle. Zero tabela nova, zero campo novo. --- ## 4. O que foi construído | Peça | Arquivo | Papel | |---|---|---| | Produtor | `lib/followup/gatilho-etapa.ts` | consome `lead.stage_changed`, aplica o gate, cria o enrollment, grava a proveniência | | Adapter | `lib/followup/gatilho-etapa.handler.ts` | pluga no dispatcher genérico do `event_log` | | Registro | `lib/event-log/register-handlers.ts` | +1 linha | | Publish | `app/api/v1/ai/followup-flows/[id]/publish/route.ts` | libera `stage_change`; recusa etapa ausente, apagada ou arquivada | | Tela | `.../TriggerConfigControl.tsx` | terceiro tipo de gatilho, seletor de etapa **por nome** | | Leitura | `hooks/followup/useEtapasDeGatilho.ts` | id → nome da etapa, agrupada por funil | **Event-driven, não time-driven**, porque "o lead entrou na etapa X" é um evento com hora e autor — ao contrário de silêncio, que é ausência de evento e por isso continua sendo varredura no tick do cron. **As três regras do motor, respeitadas e provadas:** o índice org-wide `idx_followup_enrollments_one_live` barra o segundo enrollment (23505 é caminho normal, vira `skipped_existing`); `resolveAgentForAutomaticTrigger` gateia e devolve o `agent_id` pinado; nada roda dentro da transação do banco. **Sem migration.** A `0143` continua livre: não há mudança de schema. A proveniência do enrollment (de que negócio, de qual etapa para qual, e por qual linha do `event_log`) entra como `enrolled_by_stage_change` em `followup_enrollment_events`, que já existe e já é a timeline que a fila lê. Uma coluna `origem` em `followup_enrollments` seria uma segunda verdade sobre o mesmo fato. ### Duas decisões de tela que não são cosméticas - **A etapa é escolhida pelo nome, agrupada por funil.** Duas etapas podem se chamar "Proposta" em funis diferentes; sem o cabeçalho do funil, o operador escolheria a errada sem ter como saber. - **O rótulo diz *quando*.** O disparo depende de dois crons de um minuto (o dreno do `event_log` e o tick do motor), então a tela promete "poucos minutos", não "na hora". Prometer instantâneo seria o controle mentindo sobre a própria função — que é exatamente o defeito que esta missão existe para consertar. - **`conversation_end` continua escondido e continua recusado no publish**, porque continua sem produtor. A honestidade do arquivo foi preservada. ### Um efeito colateral em teste alheio `tests/e2e/followup-builder.spec.ts` congelava "o seletor oferece exatamente 2 opções e nenhuma de `stage_change`". Ela reprovava, de propósito, a ausência do produtor que esta entrega criou — atualizei para 3 opções, mantendo a asserção de que `conversation_end` não aparece.