Files
DeskcommCRM/lib/schemas/webhooks.ts
T
Pessoaand423313 f652cf5e64 fix(agenda): o gatilho de automação leva o tipo de atendimento real
Reconciliação do PR #784 (`feat/agenda-emite-eventos`). Os quatro gatilhos, a
fonte única de `ENTIDADE_ESPERADA_POR_GATILHO` e o contexto de compromisso no
motor são acertos e ficam. O que não pode entrar é o literal.

O DEFEITO, confirmado no diff do PR:

  _handler.ts:373 (alterar)   nomeDoTipo: "Agendamento",
  _handler.ts:430 (cancelar)  nomeDoTipo: "Agendamento",
  _handler.ts:218 (marcar)    nomeDoTipo: tipo.name,     ← o único certo

`fecharOLaco` manda esse valor em `payload.event_type_name`, e o editor de
regras (`RuleEditor.tsx`, `AGENDAMENTO_FIELDS`) oferece justamente a condição
"Tipo de atendimento contém …" lendo `event.event_type_name` — é a ÚNICA
condição por onde uma regra distingue "Limpeza de pele" de "Avaliação", porque
a linha do compromisso só guarda o uuid `event_type_id`.

Com o literal, três dos quatro gatilhos (`confirmed`, `rescheduled`,
`cancelled`) anunciam sempre a mesma palavra. A regra aparece no seletor, o
operador a salva, o horário é confirmado — e nada roda, porque "Agendamento"
não contém "Limpeza". É a classe "a tela oferece o que o código ignora":
controle decorativo é pior que controle ausente, porque a pessoa acredita que
configurou. E o PR não tinha como pegar: `laco.gatilho.test.ts` cobre a função
pura (QUAL gatilho), nunca o payload.

O CONSERTO:
- `nomeDoTipoDoCompromisso()` lê `calendar_event_types.name` pelo
  `event_type_id` da linha; `cancelar` passa a selecionar essa coluna.
  `marcar` segue usando `tipo.name`, que já tem em mãos.
- `NOME_GENERICO_DO_TIPO` sai de `lib/agenda/tipos.ts` e é explicitamente
  FALLBACK: só alcançado quando o tipo foi apagado depois do compromisso.
  Perder o catálogo não pode desfazer um cancelamento já gravado.
- `tests/unit/agenda-gatilho-leva-o-tipo-real.test.ts` mede os QUATRO gatilhos
  na linha que chega ao `event_log` — não na chamada — porque as irmãs não se
  parecem por fora: `marcar` estava certo e serve de controle de vacuidade.
  O quinto caso (tipo apagado) prova que o nome vem da leitura.

Medido: `npx vitest run tests/unit/agenda-gatilho-leva-o-tipo-real.test.ts` →
5/5 verdes. As 16 suítes vizinhas de agenda/automação: 129/129 verdes.
`pnpm typecheck` exit 0.

Co-authored-by: 423313 <423313@users.noreply.github.com>
2026-09-14 10:17:07 -03:00

119 lines
4.9 KiB
TypeScript

/**
* Zod schemas for webhook-sources e automation-rules (feature Webhooks, Task 12).
*
* `ENTIDADE_ESPERADA_POR_GATILHO`, logo abaixo, é a fonte única dos gatilhos:
* `lib/automation/engine.ts` e `lib/automation/engine.handler.ts` leem daqui.
* (Este cabeçalho já afirmou "exatamente os 5 eventos" — número que envelheceu
* na primeira vez que alguém acrescentou um. Agora não há número a envelhecer.)
*/
import { z } from "zod";
/**
* Os gatilhos que o motor reconhece, e a entidade que cada um tem que trazer.
*
* É UMA FONTE, e não três, porque as três divergiam: este arquivo listava os
* gatilhos para o Zod, `engine.ts` repetia o mapa de entidade, e
* `engine.handler.ts` repetia a lista de novo para se registrar no dispatcher.
* Acrescentar um gatilho exigia lembrar dos três lugares, e esquecer o terceiro
* produz o pior desfecho possível: a regra aparece na tela, o operador a salva,
* o evento acontece — e nada roda, porque o handler não assinou aquele evento.
* Sem erro, sem log, sem run.
*
* A entidade existe porque o trigger legado `fn_emit_event_on_lead_change` emite
* `lead.created` com `entity_kind='lead'` (derivado por `split_part` do
* event_type) enquanto os handlers desta feature emitem `crm_lead`. Sem o guard,
* o motor rodaria a regra duas vezes por mudança de lead.
*/
export const ENTIDADE_ESPERADA_POR_GATILHO = {
"lead.created": "crm_lead",
"lead.stage_changed": "crm_lead",
"message.received": "message",
"lead.tag_added": "crm_lead",
"contact.tag_added": "contact",
"appointment.created": "calendar_appointment",
"appointment.confirmed": "calendar_appointment",
"appointment.rescheduled": "calendar_appointment",
"appointment.cancelled": "calendar_appointment",
} as const;
export type GatilhoDeAutomacao = keyof typeof ENTIDADE_ESPERADA_POR_GATILHO;
export const TRIGGER_EVENTS = Object.keys(ENTIDADE_ESPERADA_POR_GATILHO) as [
GatilhoDeAutomacao,
...GatilhoDeAutomacao[],
];
export const conditionSchema = z.object({
field: z.string().min(1).max(200),
op: z.enum(["eq", "neq", "contains"]),
value: z.string().max(500),
});
export const actionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("create_or_move_lead"), config: z.object({ pipeline_id: z.string().uuid(), stage_id: z.string().uuid() }) }),
z.object({ type: z.literal("send_whatsapp_message"), config: z.object({ channel_session_id: z.string().uuid(), template: z.string().min(1).max(2000) }) }),
z.object({ type: z.literal("add_tag"), config: z.object({ tags: z.array(z.string().min(1).max(60)).min(1).max(10) }) }),
z.object({ type: z.literal("assign_owner"), config: z.object({ user_id: z.string().uuid() }) }),
z.object({
type: z.literal("send_ai_message"),
config: z.object({
/** Agente PUBLICADO que assina a mensagem. */
agent_id: z.string().uuid(),
channel_session_id: z.string().uuid(),
/**
* O que fazer com os dados do formulário. Mesmo teto do `prompt_hint` de
* um passo de follow-up (1000): é instrução, não roteiro — quem escreve
* mais que isso está tentando pôr o prompt do agente aqui dentro.
*/
instruction: z.string().min(1).max(1000),
}),
}),
z.object({
type: z.literal("call_webhook"),
config: z.object({
url: z.string().url().max(2000),
// Input do usuário (plaintext, write-only) — a rota troca por secret_enc.
secret: z.string().max(200).optional(),
// Ciphertext hex (round-trip do editor: GET devolve, PATCH preserva).
secret_enc: z.string().max(4000).optional(),
}),
}),
z.object({
type: z.literal("start_message_flow"),
config: z.object({ flow_pointer_id: z.string().uuid() }),
}),
]);
export const createWebhookSourceSchema = z.object({
name: z.string().min(1).max(120),
default_pipeline_id: z.string().uuid(),
default_stage_id: z.string().uuid(),
redirect_to: z.string().url().max(2000).nullish(),
field_map: z
.object({
name: z.array(z.string()).optional(),
phone: z.array(z.string()).optional(),
email: z.array(z.string()).optional(),
})
.optional(),
secret: z.string().min(16).max(200).nullish(),
});
export const updateWebhookSourceSchema = createWebhookSourceSchema.partial().extend({
is_active: z.boolean().optional(),
});
export const createAutomationRuleSchema = z.object({
name: z.string().min(1).max(120),
trigger_event: z.enum(TRIGGER_EVENTS),
conditions: z.array(conditionSchema).max(10).default([]),
actions: z.array(actionSchema).min(1).max(10),
});
export const updateAutomationRuleSchema = createAutomationRuleSchema.partial().extend({
is_active: z.boolean().optional(),
});
export type CreateWebhookSourceInput = z.infer<typeof createWebhookSourceSchema>;
export type UpdateWebhookSourceInput = z.infer<typeof updateWebhookSourceSchema>;
export type CreateAutomationRuleInput = z.infer<typeof createAutomationRuleSchema>;
export type UpdateAutomationRuleInput = z.infer<typeof updateAutomationRuleSchema>;