Worktree: /Users/rafaelmelgaco/fv-gatilhos · Branch: fv/gatilhos (de feat/followup-vivo = 4f89a0da)
Autor: DevGatilhos · Data: 2026-08-10
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.
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.
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:
- 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. - Sobre
agent_cases, o gatilho colide de frente com o que já existe. A abertura do caso emiteai.handoff_triggered, elib/followup/reactivity.tsjá 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.
Varri lib/, app/ e supabase/: não há entidade de proposta comercial. O que
existe é:
crm_stages.agent_stage_hint(migration 0084) com o valornegotiating, 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.
| 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.
- 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_loge 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_endcontinua escondido e continua recusado no publish, porque continua sem produtor. A honestidade do arquivo foi preservada.
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.