merge: upstream/main (campos exigidos #1536) na branch da #1538 — união das duas leis

- move/bulk/_handler/KanbanBoard/useMoveCard/errors: mantém a régua de campos
  exigidos (422) E a recusa de reabertura (409) — duas leis concorrentes.
- teste do move: assinatura fundida do bancoFalso (ajustes #1538 + settingsDoFunil
  #1536) e bloco único de crm_pipelines (união de settings) — antes o primeiro
  vencia e a pergunta de 409 saía como 200.
- fake do teste da main ganhou .in + then (o caminho de reabertura consulta os
  funis por .in).
This commit is contained in:
webtecnica
2026-09-26 11:12:42 -03:00
56 changed files with 3036 additions and 149 deletions
@@ -0,0 +1,7 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Funis podem exigir campos ao entrar numa etapa ou ao encerrar, e o motivo de ganho vira campo próprio
---
Na tela de funil, cada campo pode ser marcado como exigido ao entrar em etapas escolhidas, ao ganhar ou ao perder. Sem nenhuma marca, nada muda. Quem arrasta um card sem os dados recebe um diálogo que pede só o que falta. Quando o assistente de IA é barrado, aparece um aviso na Central. O motivo de ganho passa a ser um campo próprio do negócio, com lista e exigência opcionais por funil, e sai no webhook. Contribuição de @webtecnica (PR #1688, issue #1536).
@@ -0,0 +1,12 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Candidato a golden set não grava o texto do cliente como ele chegou
---
Os arquivos de curadoria que o matcher de skills e o classificador de etapa gravam em
`lib/agent-engine/golden-candidates/` levavam a mensagem do cliente como ela chegou — CPF,
telefone e e-mail junto. A mensagem agora passa pelo mesmo redator da telemetria antes de
tocar o disco, e a pasta saiu do git: as duas portas por onde um `git add -A` publicava
conversa de cliente. Os candidatos que já estavam versionados foram removidos. Nada muda na
operação de quem já roda o sistema.
+10
View File
@@ -58,6 +58,16 @@ supabase/.branches/
# Epic executor (skill artifacts)
.epic-executor/
# Candidatos a golden set (F3-09/F3-11): os DOIS caminhos que os gravam em runtime — o
# near-miss do matcher de skills e a divergência do classificador de etapa — escrevem o
# TEXTO do lead no arquivo, para curadoria humana. É registro de dado do titular, não
# artefato do produto: versionado, um `git add -A` publica conversa de cliente no repo, e no
# clone de todo mundo, de onde ela não sai mais. A curadoria é local.
# Ignora os dois prefixos que o engine grava, e não a pasta: a spec 15 (§11.1) reserva este
# diretório para os goldens, que são curados à mão e não carregam texto de cliente.
lib/agent-engine/golden-candidates/skill-miss_*.json
lib/agent-engine/golden-candidates/stage-divergence_*.json
# Claude Code harness artifacts (scheduled tasks lock, etc)
# gov-loop: agents/ e commands/ são maquinaria versionada do loop e PRECISAM
# viajar com o repo (merge de gov/setup → main); o resto de .claude/ segue local.
@@ -61,6 +61,10 @@ export async function updatePipelineConfig(
const nextSettings: Record<string, unknown> = { ...currentSettings };
if (parsed.data.fields !== undefined) nextSettings.fields = parsed.data.fields;
if (parsed.data.lost_reasons !== undefined) nextSettings.lost_reasons = parsed.data.lost_reasons;
if (parsed.data.won_reasons !== undefined) nextSettings.won_reasons = parsed.data.won_reasons;
if (parsed.data.won_reason_required !== undefined) {
nextSettings.won_reason_required = parsed.data.won_reason_required;
}
const { error } = await supabase
.from("crm_pipelines")
@@ -79,6 +83,8 @@ export async function updatePipelineConfig(
vocabulary_changed: !!parsed.data.vocabulary,
fields_count: parsed.data.fields?.length ?? null,
lost_reasons_count: parsed.data.lost_reasons?.length ?? null,
won_reasons_count: parsed.data.won_reasons?.length ?? null,
won_reason_required: parsed.data.won_reason_required ?? null,
},
});
+191 -14
View File
@@ -36,9 +36,17 @@ const DEPOIS_DA_ATIVIDADE = "2026-09-15T12:00:01.500Z";
* que já não vale — e o próximo arrastar do mesmo card cai na OCC (issue #916).
*/
function bancoFalso(
ajustes: { /** `settings.reabertura` do funil; ausente = nenhum declarado. */ reabertura?: string; /** `status` do lead; ausente = aberto. */ statusDoLead?: string } = {},
primeiro: (Record<string, unknown> & { reabertura?: string; statusDoLead?: string }) | null = null,
stageExtra: Record<string, unknown> = {},
) {
const banco = { updatedAt: CARREGADO, stageId: STAGE_A };
// Lado A (#1538): { reabertura?, statusDoLead? } = ajustes do funil/lead.
// Lado B (#1536): primeiro parâmetro = settings do funil; 2º = palavras do stage.
const ajustes: { reabertura?: string; statusDoLead?: string } =
primeiro && ("reabertura" in primeiro || "statusDoLead" in primeiro)
? (primeiro as { reabertura?: string; statusDoLead?: string })
: {};
const settingsDoFunil: unknown = ajustes === primeiro ? null : primeiro;
const banco = { updatedAt: CARREGADO, stageId: STAGE_A, ultimoPatch: null as Record<string, unknown> | null };
vi.mocked(emitLeadActivity).mockImplementation(async () => {
banco.updatedAt = DEPOIS_DA_ATIVIDADE;
return { ok: true } as never;
@@ -52,9 +60,29 @@ function bancoFalso(
contact_id: null,
status: ajustes.statusDoLead ?? "open",
updated_at: banco.updatedAt,
custom_fields: {} as Record<string, unknown>,
won_reason: null,
});
const from = (tabela: string) => {
if (tabela === "crm_pipelines") {
const chain = {
select: () => chain,
eq: () => chain,
maybeSingle: async () => ({
data: {
// União dos dois lados: ajuste do funil (#1538) tem prioridade sobre
// o settings cruo (#1536); os dois blocos `crm_pipelines` abaixo eram
// redundantes e o primeiro vencia — a pergunta de 409 saía como 200.
settings: ajustes.reabertura
? { reabertura: ajustes.reabertura }
: settingsDoFunil,
},
error: null,
}),
};
return chain;
}
if (tabela === "crm_stages") {
const chain = {
select: () => chain,
@@ -69,6 +97,7 @@ function bancoFalso(
name: "Etapa",
is_won: false,
is_lost: false,
...stageExtra,
},
error: null,
}),
@@ -81,13 +110,14 @@ function bancoFalso(
const leitura = { eq: () => leitura, maybeSingle: async () => ({ data: lead(), error: null }) };
return leitura;
},
update: (valores: { stage_id: string }) => {
update: (valores: { stage_id: string } & Record<string, unknown>) => {
const escrita = {
eq: () => escrita,
select: () => escrita,
maybeSingle: async () => {
banco.stageId = valores.stage_id;
banco.updatedAt = DEPOIS_DO_MOVE;
banco.ultimoPatch = valores;
return { data: { id: LEAD_ID }, error: null };
},
};
@@ -95,17 +125,6 @@ function bancoFalso(
},
};
}
if (tabela === "crm_pipelines") {
const chain = {
select: () => chain,
eq: () => chain,
maybeSingle: async () => ({
data: { settings: ajustes.reabertura ? { reabertura: ajustes.reabertura } : {} },
error: null,
}),
};
return chain;
}
throw new Error(`tabela inesperada: ${tabela}`);
};
@@ -144,6 +163,164 @@ describe("POST /api/v1/leads/[id]/move", () => {
expect(corpo.data.stage_id).toBe(STAGE_B);
expect(corpo.data.updated_at).toBe(DEPOIS_DA_ATIVIDADE);
});
// ── CAMPOS OBRIGATÓRIOS (issue #1536) ──────────────────────────────────────
//
// O caminho 1 dos SEIS da issue (arrasto no quadro). A promessa testada: a
// recusa é 422 com `details.faltando` ANTES de qualquer escrita, e o reenvio
// com `custom_fields` passa na MESMA régua e grava etapa + campos num
// UPDATE só — a janela entre dois writes é o defeito da #917.
const CAMPOS_EXIGIDOS = {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [STAGE_B] },
},
],
};
it("etapa que exige campo sem valor: 422 com faltando, e NADA é gravado", async () => {
vi.mocked(createClient).mockResolvedValue(bancoFalso(CAMPOS_EXIGIDOS) as never);
const { POST } = await import("./route");
const response = await POST(
request({ stage_id: STAGE_B, position_in_stage: 1500, expected_updated_at: CARREGADO }),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(response.status).toBe(422);
const corpo = (await response.json()) as {
error?: { code?: string; details?: { faltando?: { chave: string; rotulo: string }[] } };
};
expect(corpo.error?.code).toBe("required_fields_missing");
expect(corpo.error?.details?.faltando).toEqual([
{ chave: "concorrente", rotulo: "Concorrente", tipo: "text" },
]);
// A prova de que a recusa veio ANTES do update: a etapa não mudou.
const falso = bancoFalso(CAMPOS_EXIGIDOS);
vi.mocked(createClient).mockResolvedValue(falso as never);
await POST(
request({ stage_id: STAGE_B, position_in_stage: 1500, expected_updated_at: CARREGADO }),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(falso.banco.stageId).toBe(STAGE_A);
expect(falso.banco.ultimoPatch).toBeNull();
});
it("reenvio com os campos coletados: passa e grava etapa + custom_fields no MESMO update", async () => {
const falso = bancoFalso(CAMPOS_EXIGIDOS);
vi.mocked(createClient).mockResolvedValue(falso as never);
const { POST } = await import("./route");
const response = await POST(
request({
stage_id: STAGE_B,
position_in_stage: 1500,
expected_updated_at: CARREGADO,
custom_fields: { concorrente: "ACME" },
}),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(response.status).toBe(200);
// UM update, com as duas coisas: a etapa nova e o campo preenchido.
expect(falso.banco.stageId).toBe(STAGE_B);
expect(falso.banco.ultimoPatch).toMatchObject({
stage_id: STAGE_B,
custom_fields: { concorrente: "ACME" },
});
});
it("funil sem obrigatorio_em segue exatamente como hoje (controle do critério 3)", async () => {
// O campo tem `required: true` (o asterisco antigo) e NENHUMA regra de
// quando exigir: o move continua passando com o campo vazio.
const soAsterisco = {
fields: [{ key: "concorrente", label: "Concorrente", type: "text", required: true }],
};
vi.mocked(createClient).mockResolvedValue(bancoFalso(soAsterisco) as never);
const { POST } = await import("./route");
const response = await POST(
request({ stage_id: STAGE_B, position_in_stage: 1500, expected_updated_at: CARREGADO }),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(response.status).toBe(200);
});
// ── A MESMA ETAPA PASSA (CR do mantenedor) ──────────────────────────────────
//
// O card já está NA coluna exigente: arrastar dentro dela é REORDENAÇÃO, não
// entrada. Sem a comparação destino × `lead.stage_id`, a régua respondia 422
// e o card ficava preso na própria coluna — ninguém conseguia mudar a posição
// de um card num funil que exige campo. Os dois casos abaixo partem do lead em
// STAGE_A e mandam STAGE_A de volta; um deles com a exigência declarada na
// etapa, o outro com `won_reason_required`, que é o que prendia TODO card
// antigo da coluna Ganho (`won_reason` nasce `null`).
it("mesma etapa passa: reordenar dentro da coluna que exige campo não cai na régua", async () => {
const exigenteNaOrigem = {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [STAGE_A] },
},
],
};
const falso = bancoFalso(exigenteNaOrigem);
vi.mocked(createClient).mockResolvedValue(falso as never);
const { POST } = await import("./route");
const response = await POST(
request({ stage_id: STAGE_A, position_in_stage: 1500, expected_updated_at: CARREGADO }),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(response.status).toBe(200);
// A escrita aconteceu (a posição muda) — o card não foi devolvido.
expect(falso.banco.ultimoPatch).toMatchObject({ stage_id: STAGE_A });
});
it("mesma etapa na coluna Ganho: `won_reason_required` não trava a reordenação", async () => {
// O card antigo da coluna tem `won_reason` nulo — exigir o motivo DELE ao
// reordenar tornaria o ganho impossível de reordenar para sempre.
const falso = bancoFalso({ won_reason_required: true }, { is_won: true });
vi.mocked(createClient).mockResolvedValue(falso as never);
const { POST } = await import("./route");
const response = await POST(
request({ stage_id: STAGE_A, position_in_stage: 2500, expected_updated_at: CARREGADO }),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(response.status).toBe(200);
// E a reordenação NÃO escreve motivo nenhum: não houve fechamento novo.
expect(falso.banco.ultimoPatch?.won_reason).toBeUndefined();
});
it("mudança de etapa de verdade continua barrada pela régua (o atalho não vira buraco)", async () => {
// Aqui a exigência é na etapa de DESTINO — é ela que o card está entrando.
const exigenteNoDestino = {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [STAGE_B] },
},
],
};
vi.mocked(createClient).mockResolvedValue(bancoFalso(exigenteNoDestino) as never);
const { POST } = await import("./route");
const response = await POST(
request({ stage_id: STAGE_B, position_in_stage: 1500, expected_updated_at: CARREGADO }),
{ params: Promise.resolve({ id: LEAD_ID }) },
);
expect(response.status).toBe(422);
});
});
// ── A RETOMADA COMO NOVO NEGÓCIO (issue #1538) ────────────────────────────────
+84
View File
@@ -25,6 +25,13 @@ import {
} from "@/lib/leads/motivo-da-perda";
import { RECUSA_DE_TROCA_DE_FUNIL } from "@/lib/leads/clonar-para-funil";
import { modoDeReabertura, recusaReabertura } from "@/lib/leads/reabertura";
import {
recusaDeCamposObrigatorios,
recusaDeMotivoDoGanho,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -128,6 +135,67 @@ export async function POST(
});
}
// ── A MESMA ETAPA É REORDENAÇÃO, NÃO ENTRADA (CR do mantenedor, #1536) ──────
//
// O card que já está NA coluna de destino não está ENTRANDO nela: arrastar
// dentro da própria coluna só troca a posição. A régua abaixo pergunta "este
// destino exige campos que o lead não tem?" e, sem esta comparação, respondia
// 422 para um movimento que não muda de etapa — na coluna exigente o card
// ficava preso sem ninguém conseguir reordená-lo, e com `won_reason_required`
// valia para TODO card antigo da coluna Ganho (o `won_reason` nasce `null`,
// então reordenar a coluna virava 422).
//
// Comparado AQUI, antes da régua, e não dentro dela: `campos-exigidos.ts`
// continua não sabendo nada sobre "mesma etapa" — quem sabe é esta rota, que
// é quem lê `lead.stage_id` ao lado do destino. As regras de vocabulário do
// ganho e da perda (#917) SEGUEM valendo: elas decidem sobre VALORES que a
// escrita traz, não sobre a entrada em si.
const mesmaEtapa = input.stage_id === lead.stage_id;
// ── OS CAMPOS OBRIGATÓRIOS (issue #1536) ────────────────────────────────────
//
// A mesma pergunta dos outros cinco caminhos, respondida pela MESMA função:
// este destino exige campos que o lead não tem? Decidido ANTES do update, pela
// mesma razão da perda abaixo — depois dele só existiria a linha recusada.
// `details.faltando` nomeia chave e rótulo de cada campo: é ele que a tela
// vira em diálogo (o único caminho onde dá para PREENCHER e tentar de novo).
const settings = await settingsDoFunil(supabase, lead.pipeline_id);
const vereditoDeCampos = mesmaEtapa
? { faltando: [] }
: validaCamposExigidos({
lead: lead as Record<string, unknown>,
settingsDoFunil: settings,
destino: {
stageId: stage.id,
desfecho: stage.is_won ? "won" : stage.is_lost ? "lost" : null,
},
motivoDeGanho: input.won_reason ?? null,
customFieldsPropostos: input.custom_fields ?? null,
});
if (vereditoDeCampos.faltando.length > 0) {
const recusa = recusaDeCamposObrigatorios(vereditoDeCampos.faltando, user.idioma);
return fail(recusa.codigo, recusa.mensagem, 422, {
requestId,
details: { faltando: vereditoDeCampos.faltando },
});
}
// O MOTIVO DE GANHO (issue #1536): vocabulário do funil quando há lista, e
// obrigatoriedade opt-in (`settings.won_reason_required`) quando o funil pede.
if (stage.is_won) {
const recusaVocabulario = recusaDeMotivoDoGanho({
motivo: input.won_reason,
settingsDoFunil: settings,
idioma: user.idioma,
});
if (recusaVocabulario) {
return fail(recusaVocabulario.codigo, recusaVocabulario.mensagem, 422, {
requestId,
});
}
}
// ── O MOTIVO DA PERDA (issue #917) ──────────────────────────────────────────
//
// A etapa de destino é de perda? Então esta escrita fecha o negócio, e o banco
@@ -152,6 +220,22 @@ export async function POST(
position_in_stage: input.position_in_stage,
updated_at: new Date().toISOString(),
...veredito.patch,
// O motivo de ganho sai NA MESMA escrita que muda a etapa — o mesmo
// desenho do motivo da perda (#917): uma segunda escrita teria janela.
...(stage.is_won && input.won_reason?.trim()
? { won_reason: input.won_reason.trim() }
: {}),
// O merge é AQUI, nunca num PATCH anterior: ver `custom_fields` em
// `moveLeadSchema` — dois writes teriam janela e uma segunda OCC.
...(input.custom_fields
? {
custom_fields: {
...(((lead as { custom_fields?: Record<string, unknown> })
.custom_fields ?? {}) as Record<string, unknown>),
...input.custom_fields,
},
}
: {}),
})
.eq("id", leadId)
.eq("updated_at", input.expected_updated_at)
+11 -2
View File
@@ -22,7 +22,7 @@ import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(
_req: NextRequest,
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
): Promise<Response> {
const supportDenied = await requireSupportWrite();
@@ -36,6 +36,15 @@ export async function POST(
const authz = await requireRole("agent", { requestId, resource: "crm_leads" });
if (!authz.ok) return authz.response;
// O MOTIVO DE GANHO (issue #1536): corpo opcional. Body vazio/ausente é o
// contrato antigo — ganhar sem motivo continua valendo, salvo quando o funil
// liga `settings.won_reason_required` (aí a recusa vem de `encerraDemanda`).
const corpo = await req.json().catch(() => ({}));
const wonReason =
typeof (corpo as { won_reason?: unknown }).won_reason === "string"
? ((corpo as { won_reason: string }).won_reason as string)
: null;
try {
const { lead } = await encerraDemanda(
supabase,
@@ -45,7 +54,7 @@ export async function POST(
requestId,
idioma: authz.user.idioma,
},
{ leadId, desfecho: "won" },
{ leadId, desfecho: "won", motivo: wonReason },
);
return ok(lead, { requestId });
} catch (err) {
+52
View File
@@ -25,6 +25,12 @@ import {
modoDeReabertura,
recusaReabertura,
} from "@/lib/leads/reabertura";
import {
recusaDeCamposObrigatorios,
recusaDeMotivoDoGanho,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import { ORIGEM_DA_PLANILHA } from "@/lib/leads/planilha";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import { moedaDaOrganizacao } from "@/lib/catalogo/moeda-da-org";
@@ -819,6 +825,11 @@ export interface MoveLeadAdminInput {
* exigir ou não é `lib/leads/motivo-da-perda.ts`, o mesmo dos outros caminhos.
*/
lost_reason?: string | null;
/**
* O motivo do ganho, quando a etapa de destino fecha o negócio como ganho
* (issue #1536) — espelho do `lost_reason`, mesma disciplina de escrita.
*/
won_reason?: string | null;
}
export async function moveLeadHandler(
@@ -918,6 +929,44 @@ export async function moveLeadHandler(
position = maxRow?.position_in_stage ? Number(maxRow.position_in_stage) + 1000 : 1000;
}
// ── OS CAMPOS OBRIGATÓRIOS (issue #1536) ────────────────────────────────────
//
// Este handler é o escritor de etapa de TODOS os clientes que não são o board
// (MCP `crm_move_lead_stage`, ações de automação), então a régua é a MESMA do
// arrasto, decidida pela MESMA função: o que falta vira 422 com
// `details.faltando`, e a tool do MCP devolve a frase ao modelo — que pergunta
// ao cliente ou passa para o humano, em vez de mover calado.
const settings = await settingsDoFunil(supabase, lead.pipeline_id);
const vereditoDeCampos = validaCamposExigidos({
lead: lead as Record<string, unknown>,
settingsDoFunil: settings,
destino: {
stageId: stage.id,
desfecho: stage.is_won ? "won" : stage.is_lost ? "lost" : null,
},
motivoDeGanho: input.won_reason ?? null,
});
if (vereditoDeCampos.faltando.length > 0) {
const recusa = recusaDeCamposObrigatorios(vereditoDeCampos.faltando, ctx.idioma);
throw new ApiError(
422,
recusa.codigo,
{ faltando: vereditoDeCampos.faltando },
ctx.requestId,
recusa.mensagem,
);
}
if (stage.is_won) {
const recusaGanho = recusaDeMotivoDoGanho({
motivo: input.won_reason,
settingsDoFunil: settings,
idioma: ctx.idioma,
});
if (recusaGanho) {
throw new ApiError(422, recusaGanho.codigo, undefined, ctx.requestId, recusaGanho.mensagem);
}
}
// ── O MOTIVO DA PERDA (issue #917) ──────────────────────────────────────────
//
// Este handler é o escritor de etapa de TODOS os clientes que não são o board
@@ -942,6 +991,9 @@ export async function moveLeadHandler(
position_in_stage: position,
updated_at: nowIso,
...veredito.patch,
...(stage.is_won && input.won_reason?.trim()
? { won_reason: input.won_reason.trim() }
: {}),
})
.eq("id", leadId)
.eq("updated_at", lead.updated_at)
+59 -1
View File
@@ -28,6 +28,11 @@ import {
modoDeReabertura,
recusaReabertura,
} from "@/lib/leads/reabertura";
import {
recusaDeCamposObrigatorios,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import { createClient } from "@/lib/supabase/server";
import { observeServiceOrigin } from "@/lib/atendimento/origem";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -132,7 +137,9 @@ export async function POST(req: NextRequest): Promise<Response> {
// funil `novo_negocio`, e sem o `status` não há como saber quem ficaria.
const { data: scoped } = await supabase
.from("crm_leads")
.select("id, organization_id, tags, stage_id, pipeline_id, contact_id, lost_reason, status")
.select(
"id, organization_id, tags, stage_id, pipeline_id, contact_id, lost_reason, status, custom_fields, won_reason",
)
.eq("organization_id", organizationId)
.in("id", input.lead_ids);
@@ -232,6 +239,57 @@ export async function POST(req: NextRequest): Promise<Response> {
});
}
// ── OS CAMPOS OBRIGATÓRIOS NO LOTE (issue #1536) ────────────────────────
//
// A mesma régua dos caminhos individuais, aqui por CARD: quem não passa
// é LISTADO, nunca movido em silêncio — e como `fn_mover_leads_em_lote` é
// uma transação só ("move todos ou não move nenhum"), um card que falharia
// derrubaria o lote inteiro depois de a função já começar. A recusa vem
// ANTES do RPC, nomeando os cards em `details.lead_ids` (o mesmo contrato
// da recusa de motivo da perda logo acima) e o que falta em
// `details.faltando`, por card.
// O lote pode cruzar funis, então o settings é POR FUNIL e cacheado: a
// régua é a do funil de CADA card, nunca a do primeiro da lista.
const settingsPorFunil = new Map<string, unknown>();
const leadIdsSemCampos: string[] = [];
const faltandoPorCard: Record<string, { chave: string; rotulo: string }[]> = {};
for (const linha of visible) {
const funilId = (linha as { pipeline_id?: string | null }).pipeline_id ?? null;
if (!settingsPorFunil.has(funilId ?? "")) {
settingsPorFunil.set(
funilId ?? "",
await settingsDoFunil(supabase, funilId),
);
}
const veredito = validaCamposExigidos({
lead: linha as unknown as Record<string, unknown>,
settingsDoFunil: settingsPorFunil.get(funilId ?? "") ?? null,
destino: {
stageId: etapaDeDestino.id,
desfecho: etapaDeDestino.is_won
? "won"
: etapaDeDestino.is_lost
? "lost"
: null,
},
motivoDeGanho: (linha as { won_reason?: string | null }).won_reason ?? null,
});
if (veredito.faltando.length > 0) {
leadIdsSemCampos.push(linha.id);
faltandoPorCard[linha.id] = veredito.faltando;
}
}
if (leadIdsSemCampos.length > 0) {
const recusa = recusaDeCamposObrigatorios(
Object.values(faltandoPorCard).flat(),
user.idioma,
);
return fail(recusa.codigo, recusa.mensagem, 422, {
requestId,
details: { lead_ids: leadIdsSemCampos, faltando: faltandoPorCard },
});
}
// Migration 0209: quem posiciona é o banco. Escrever aqui um
// `position_in_stage` escalar para N linhas dava a TODOS os cards do lote
// o mesmo número, e `midpoint(prev, next)` devolve NaN quando os vizinhos
@@ -13,7 +13,7 @@
* Por isso os testes medem o par: a tela OFERECE todo tipo que o schema aceita,
* e mostra as opções para TODO tipo de lista fechada — não só para `select`.
*/
import { describe, it, expect, vi } from "vitest";
import { beforeEach, describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { customFieldSchema } from "@/lib/schemas/settings";
@@ -46,7 +46,13 @@ globalThis.ResizeObserver = class {
};
import { updatePipelineConfig } from "@/app/actions/settings/updatePipelineConfig";
import { PipelinesClient, TIPOS_DE_CAMPO, tipoTemOpcoes, type PipelineRow } from "./_client";
import {
PipelinesClient,
TIPOS_DE_CAMPO,
tipoTemOpcoes,
type EtapaDoFunil,
type PipelineRow,
} from "./_client";
/** Um funil de clínica: o campo que importa é a lista de procedimentos, e ela é múltipla. */
const FUNIL: PipelineRow = {
@@ -205,3 +211,106 @@ describe("o input de opções de um campo de lista fechada", () => {
]);
});
});
/* ────────────────────────────────────────────────────────────────────────── */
/**
* O EDITOR DE `obrigatorio_em` (CR do mantenedor no PR #1688): a régua nasceu
* no schema e não tinha TELA — quem operava não conseguia ligar a regra
* principal do #1536. O que estes casos prendem:
*
* 1. a tela OFERECE as etapas do funil + "ao ganhar" + "ao perder" (as três
* chaves que `campoExigidoNoDestino` lê — nem mais, para não prometer
* gatilho que o servidor não pergunta);
* 2. a marca vira `obrigatorio_em` no patch gravado pela ÚNICA porta de
* escrita do settings (`updatePipelineConfig`);
* 3. desmarcar tudo APAGA a chave: um funil intocado continua idêntico ao de
* antes do #1536 (critério de aceite nº 3), em vez de ganhar `{}` morto.
*/
describe("editor de obrigatorio_em do funil (#1536)", () => {
const ETAPA_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const ETAPA_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const FUNIL_MARCADO_ID = "33333333-3333-4333-8333-333333333333";
const ETAPAS: Record<string, EtapaDoFunil[]> = {
[FUNIL.id]: [
{ id: ETAPA_A, name: "Avaliação", is_archived: false },
{ id: ETAPA_B, name: "Proposta (antiga)", is_archived: true },
],
[FUNIL_MARCADO_ID]: [
{ id: ETAPA_A, name: "Avaliação", is_archived: false },
{ id: ETAPA_B, name: "Proposta (antiga)", is_archived: true },
],
};
/** Um campo JÁ marcado no settings — o estado de quem abre para desligar. */
const FUNIL_MARCADO: PipelineRow = {
id: FUNIL_MARCADO_ID,
name: "Vendas",
slug: "vendas-marcado",
vocabulary: null,
settings: {
fields: [
{
key: "dor",
label: "Dor",
type: "text",
obrigatorio_em: { etapas: [ETAPA_A], ao_perder: true },
},
],
},
};
const tela = (p: PipelineRow) =>
render(<PipelinesClient pipelines={[p]} etapas={ETAPAS} podeEditarConfig />);
const patchSalvo = () => vi.mocked(updatePipelineConfig).mock.calls.at(-1)![1];
beforeEach(() => vi.mocked(updatePipelineConfig).mockClear());
it("oferece as etapas do funil + ao ganhar + ao perder, e nada vem marcado", () => {
tela(FUNIL);
expect(
screen.getByLabelText("Exigir em Avaliação — Procedimentos de interesse"),
).not.toBeChecked();
expect(
screen.getByLabelText("Exigir em Proposta (antiga) — Procedimentos de interesse"),
).not.toBeChecked();
expect(screen.getByLabelText("Ao ganhar — Procedimentos de interesse")).not.toBeChecked();
expect(screen.getByLabelText("Ao perder — Procedimentos de interesse")).not.toBeChecked();
});
it("a marca vira `obrigatorio_em` no patch da porta única de escrita", () => {
tela(FUNIL);
fireEvent.click(screen.getByLabelText("Exigir em Avaliação — Procedimentos de interesse"));
fireEvent.click(screen.getByLabelText("Ao ganhar — Procedimentos de interesse"));
fireEvent.click(screen.getByRole("button", { name: "Salvar vocabulário e campos" }));
expect(updatePipelineConfig).toHaveBeenCalledTimes(1);
expect(patchSalvo().fields?.[0]?.obrigatorio_em).toEqual({
etapas: [ETAPA_A],
ao_ganhar: true,
});
});
it("desmarcar tudo apaga a chave — o funil volta ao comportamento de antes", () => {
tela(FUNIL_MARCADO);
expect(screen.getByLabelText("Exigir em Avaliação — Dor")).toBeChecked();
expect(screen.getByLabelText("Ao perder — Dor")).toBeChecked();
fireEvent.click(screen.getByLabelText("Exigir em Avaliação — Dor"));
fireEvent.click(screen.getByLabelText("Ao perder — Dor"));
fireEvent.click(screen.getByRole("button", { name: "Salvar vocabulário e campos" }));
expect(patchSalvo().fields?.[0]).not.toHaveProperty("obrigatorio_em");
});
it("a etapa ARQUIVADA fica visível e marcável — o que alguém já marcou não some", () => {
tela(FUNIL_MARCADO);
// A regra gravada aponta para ETAPA_A; ETAPA_B (arquivada) também aparece.
const antiga = screen.getByLabelText("Exigir em Proposta (antiga) — Dor");
expect(antiga).toBeInTheDocument();
// O rótulo da coluna morta vem da própria lista, marcada — some da tela só
// quando some do settings, nunca por baixo de quem a marcou.
expect(antiga.closest("label")?.textContent).toContain("arquivada");
});
});
+183 -3
View File
@@ -55,17 +55,88 @@ export function tipoTemOpcoes(tipo: CustomFieldDef["type"]): boolean {
return tipo === "select" || tipo === "multiselect";
}
/**
* Uma etapa do funil, do jeito que o editor de `obrigatorio_em` precisa ler.
*
* `is_archived` entra porque a lista É COMPLETA de propósito: descartar a etapa
* arquivada na leitura apagaria silenciosamente a marca que alguém já fez — o
* save regrava `fields` inteiro, então o que não aparece na tela some do
* settings. Arquivada fica visível e marcada; ela não recebe card novo, mas
* também não é apagada por baixo de quem a marcou.
*/
export interface EtapaDoFunil {
id: string;
name: string;
is_archived: boolean;
}
/**
* A regra `obrigatorio_em` normalizada — NADA MARCADO É AUSÊNCIA, não `{}`.
*
* `undefined` e `{}` significam a mesma coisa para `validaCamposExigidos`, mas
* só o primeiro deixa o settings do funil idêntico ao de antes do #1536: gravar
* `{ etapas: [], ao_ganhar: false, ao_perder: false }` encheria todo campo de
* uma chave morta e faria o critério de aceite nº 3 ("sem `obrigatorio_em` o
* comportamento é o de antes") depender de olhar para dentro do objeto.
*/
export function normalizaObrigatorioEm(
regra: CustomFieldDef["obrigatorio_em"],
): CustomFieldDef["obrigatorio_em"] {
const etapas = regra?.etapas ?? [];
const aoGanhar = regra?.ao_ganhar === true;
const aoPerder = regra?.ao_perder === true;
if (etapas.length === 0 && !aoGanhar && !aoPerder) return undefined;
return {
...(etapas.length > 0 ? { etapas } : {}),
...(aoGanhar ? { ao_ganhar: true } : {}),
...(aoPerder ? { ao_perder: true } : {}),
};
}
/** Liga/desliga UMA etapa na regra do campo, sem mexer no resto da marca. */
export function comEtapa(
regra: CustomFieldDef["obrigatorio_em"],
etapaId: string,
marcada: boolean,
): CustomFieldDef["obrigatorio_em"] {
const etapas = new Set(regra?.etapas ?? []);
if (marcada) etapas.add(etapaId);
else etapas.delete(etapaId);
return normalizaObrigatorioEm({ ...regra, etapas: [...etapas] });
}
/** Liga/desliga um dos dois gatilhos de FECHAMENTO (`ao_ganhar`/`ao_perder`). */
export function comGatilho(
regra: CustomFieldDef["obrigatorio_em"],
gatilho: "ao_ganhar" | "ao_perder",
marcado: boolean,
): CustomFieldDef["obrigatorio_em"] {
return normalizaObrigatorioEm({ ...regra, [gatilho]: marcado });
}
function readLostReasons(settings: Record<string, unknown> | null): string[] {
if (!settings) return [];
const r = (settings as { lost_reasons?: unknown }).lost_reasons;
return Array.isArray(r) ? (r as string[]) : [];
}
function readWonReasons(settings: Record<string, unknown> | null): string[] {
const r = (settings as { won_reasons?: unknown } | null)?.won_reasons;
return Array.isArray(r) ? r.filter((v): v is string => typeof v === "string") : [];
}
export function PipelinesClient({
pipelines,
etapas = {},
podeEditarConfig,
}: {
pipelines: PipelineRow[];
/**
* As etapas de cada funil por id de funil — o que o editor de `obrigatorio_em`
* oferece como "exigir ao entrar aqui". Opcional só para os testes que não
* exercitam esta regra; a página sempre manda.
*/
etapas?: Record<string, EtapaDoFunil[]>;
/** Vocabulário/custom fields são admin (a server action recusa o resto). */
podeEditarConfig: boolean;
}) {
@@ -100,14 +171,20 @@ export function PipelinesClient({
<div className="border-t border-border pt-6">
<AgentMappingSection pipelineId={p.id} ancoraEtapas={ancoraDasEtapas(p.id)} />
</div>
{podeEditarConfig && <PipelineEditor pipeline={p} />}
{podeEditarConfig && <PipelineEditor pipeline={p} etapas={etapas[p.id] ?? []} />}
</Card>
))}
</div>
);
}
function PipelineEditor({ pipeline }: { pipeline: PipelineRow }) {
function PipelineEditor({
pipeline,
etapas,
}: {
pipeline: PipelineRow;
etapas: EtapaDoFunil[];
}) {
const t = useT();
const v = pipeline.vocabulary ?? {};
const [lead, setLead] = useState(v.lead ?? "Lead");
@@ -115,6 +192,10 @@ function PipelineEditor({ pipeline }: { pipeline: PipelineRow }) {
const [won, setWon] = useState(v.won ?? "Ganho");
const [lost, setLost] = useState(v.lost ?? "Perdido");
const [reasonsText, setReasonsText] = useState(readLostReasons(pipeline.settings).join(", "));
const [wonReasonsText, setWonReasonsText] = useState(readWonReasons(pipeline.settings).join(", "));
const [wonRequired, setWonRequired] = useState(
(pipeline.settings as { won_reason_required?: unknown } | null)?.won_reason_required === true,
);
const [fields, setFields] = useState<CustomFieldDef[]>(camposDoFunil(pipeline.settings));
const [isPending, startTransition] = useTransition();
@@ -136,7 +217,14 @@ function PipelineEditor({ pipeline }: { pipeline: PipelineRow }) {
.filter((o) => o.label !== ""),
}
: f;
const parsed = customFieldSchema.safeParse(limpo);
// A regra de `obrigatorio_em` é normalizada AQUI, e não só durante a
// digitação: um campo que chegou do settings com `{}` ou com marca
// desligada sai sem a chave — é o que mantém o funil idêntico ao de antes
// do #1536 enquanto ninguém marca nada.
const { obrigatorio_em: regraBruta, ...semRegra } = limpo;
const regra = normalizaObrigatorioEm(regraBruta);
const base = regra ? { ...semRegra, obrigatorio_em: regra } : semRegra;
const parsed = customFieldSchema.safeParse(base);
if (!parsed.success) {
toast.error(parsed.error.issues[0]?.message ?? t("Campo inválido."));
return;
@@ -148,10 +236,17 @@ function PipelineEditor({ pipeline }: { pipeline: PipelineRow }) {
.map((s) => s.trim())
.filter((s) => s.length > 0);
const wonReasons = wonReasonsText
.split(",")
.map((s2) => s2.trim())
.filter((s2) => s2.length > 0);
const patch: PipelineConfigPatch = {
vocabulary: { lead, deal, won, lost },
fields: ok,
lost_reasons: reasons,
won_reasons: wonReasons,
won_reason_required: wonRequired,
};
startTransition(async () => {
const r = await updatePipelineConfig(pipeline.id, patch);
@@ -189,6 +284,24 @@ function PipelineEditor({ pipeline }: { pipeline: PipelineRow }) {
<Input value={reasonsText} onChange={(e) => setReasonsText(e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">{t("Motivos de ganho (separados por vírgula)")}</Label>
<Input value={wonReasonsText} onChange={(e) => setWonReasonsText(e.target.value)} />
<p className="text-xs text-muted-foreground">
{t(
"Sem motivos cadastrados o motivo de ganho é texto livre. Com a lista, só o que está nela é aceito.",
)}
</p>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={wonRequired}
onChange={(e) => setWonRequired(e.target.checked)}
/>
{t("Exigir motivo de ganho ao fechar como ganho")}
</label>
</div>
<div className="space-y-2">
<Label className="text-xs">{t("Campos do lead neste funil")}</Label>
<p className="text-xs text-muted-foreground">
@@ -273,6 +386,73 @@ function PipelineEditor({ pipeline }: { pipeline: PipelineRow }) {
}}
/>
)}
{/* ── QUANDO ESTE CAMPO OBRIGA (issue #1536) ────────────────────
O `obrigatorio_em` nasceu no schema sem nenhuma tela: quem
operava não conseguia ligar a régua principal do PR. As três
marcas são as que `campoExigidoNoDestino` lê — etapas de
entrada, `ao_ganhar` e `ao_perder` — e nada mais, para a tela
não prometer gatilho que o servidor não pergunta. */}
<div className="space-y-1 md:col-span-4">
<p className="text-xs font-medium">{t("Exigir o preenchimento:")}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{etapas.map((e) => (
<label key={e.id} className="flex items-center gap-1">
<input
type="checkbox"
aria-label={`${t("Exigir em")} ${e.name} — ${f.label || i + 1}`}
checked={f.obrigatorio_em?.etapas?.includes(e.id) ?? false}
onChange={(ev) => {
const next = [...fields];
next[i] = {
...f,
obrigatorio_em: comEtapa(f.obrigatorio_em, e.id, ev.target.checked),
};
setFields(next);
}}
/>
{e.name}
{e.is_archived ? ` (${t("arquivada")})` : ""}
</label>
))}
<label className="flex items-center gap-1">
<input
type="checkbox"
aria-label={`${t("Ao ganhar")} — ${f.label || i + 1}`}
checked={f.obrigatorio_em?.ao_ganhar === true}
onChange={(ev) => {
const next = [...fields];
next[i] = {
...f,
obrigatorio_em: comGatilho(f.obrigatorio_em, "ao_ganhar", ev.target.checked),
};
setFields(next);
}}
/>
{t("Ao ganhar")}
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
aria-label={`${t("Ao perder")} — ${f.label || i + 1}`}
checked={f.obrigatorio_em?.ao_perder === true}
onChange={(ev) => {
const next = [...fields];
next[i] = {
...f,
obrigatorio_em: comGatilho(f.obrigatorio_em, "ao_perder", ev.target.checked),
};
setFields(next);
}}
/>
{t("Ao perder")}
</label>
</div>
<p className="text-xs text-muted-foreground">
{t(
"Sem marca nenhuma este campo nunca é exigido — é o comportamento de sempre. Marcado, ele precisa estar preenchido para o negócio entrar na etapa escolhida ou ser fechado como ganho/perdido.",
)}
</p>
</div>
</div>
))}
{fields.length < 50 && (
+20 -2
View File
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { ROLE_RANK } from "@/lib/auth/types";
import { createClient } from "@/lib/supabase/server";
import { PipelinesClient, type PipelineRow } from "./_client";
import { PipelinesClient, type EtapaDoFunil, type PipelineRow } from "./_client";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -40,6 +40,20 @@ export default async function PipelinesSettingsPage() {
.order("position");
const pipelines = (data ?? []) as PipelineRow[];
// AS ETAPAS DE CADA FUNIL entram para o editor de `obrigatorio_em` (#1536):
// sem elas a tela não tem o que oferecer como "exigir ao entrar aqui". A
// leitura é a mesma da página (mesmo cliente, mesma organização) e vem PRONTA
// do servidor: o editor não espera rede nenhuma para renderizar.
const { data: etapas } = await supabase
.from("crm_stages")
.select("id, pipeline_id, name, is_archived")
.eq("organization_id", activeOrg.orgId)
.order("position", { ascending: true });
const etapasPorFunil: Record<string, EtapaDoFunil[]> = {};
for (const e of (etapas ?? []) as Array<EtapaDoFunil & { pipeline_id: string }>) {
(etapasPorFunil[e.pipeline_id] ??= []).push(e);
}
const idioma = user.idioma;
return (
@@ -56,7 +70,11 @@ export default async function PipelinesSettingsPage() {
.
</p>
</header>
<PipelinesClient pipelines={pipelines} podeEditarConfig={podeEditarConfig} />
<PipelinesClient
pipelines={pipelines}
etapas={etapasPorFunil}
podeEditarConfig={podeEditarConfig}
/>
</div>
);
}
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import { useT } from "@/hooks/i18n/useT";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { RecusaDeCampos } from "@/hooks/kanban/useMoveCard";
/**
* O DIAGNÓGICO EM FORMULÁRIO — o outro lado do 422 `required_fields_missing`
* (issue #1536).
*
* ─── Por que diálogo e não toast ───────────────────────────────────────────
*
* O quadro pedia para mover o card; o servidor recusou dizendo QUAIS campos
* faltam (`details.faltando`, chave + rótulo + tipo). Um toast jogaria essa
* lista fora: a pessoa veria "deu errado" sem caminho, fecharia o card e
* perderia a posição que arrastou. Este diálogo é o mesmo formato da janela de
* perder (`LoseLeadDialog`), com uma diferença de contrato: ele NÃO grava nada
* sozinho — ele devolve os valores para o `useMoveCard` reenviar o MESMO move
* com `custom_fields` junto, e a etapa e os campos saem numa escrita só.
*
* ─── O que ele não faz ─────────────────────────────────────────────────────
*
* Não valida vocabulário (o servidor é quem sabe a lista do funil), não
* grava antes de mover (a janela entre dois writes é o defeito da #917) e não
* adivinha o tipo: o que vem em `tipo` é o que o funil declarou, e tipo
* desconhecido vira texto — erro de leitura não pode virar input ilegível.
*/
interface CamposObrigatoriosDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** A recusa que abriu este diálogo — argumentos originais + o que falta. */
recusa: RecusaDeCampos | null;
/** Envia o move de novo, com os valores coletados na MESMA escrita. */
onConfirmar: (valores: { customFields: Record<string, unknown>; wonReason?: string }) => void;
/** Enquanto o reenvio não responde, o botão fica travado. */
isPending?: boolean;
}
/** `won_reason` NÃO é campo do funil: sai como parâmetro próprio do move. */
const CHAVE_DO_MOTIVO_DE_GANHO = "won_reason";
function inputHtmlParaTipo(tipo: string | undefined): string {
switch (tipo) {
case "date":
return "date";
case "number":
return "number";
case "email":
return "email";
case "phone":
return "tel";
case "url":
return "url";
default:
return "text";
}
}
export function CamposObrigatoriosDialog({
open,
onOpenChange,
recusa,
onConfirmar,
isPending = false,
}: CamposObrigatoriosDialogProps) {
const t = useT();
const [valores, setValores] = useState<Record<string, string>>({});
// Troca de recusa zera o formulário: um diálogo reaproveitado com os valores
// do card anterior pediria ao operador para confirmar dados que não são dele.
useEffect(() => {
if (open) setValores({});
}, [open, recusa]);
if (!recusa) return null;
const campos = recusa.faltando;
const valido = campos.every((campo) => {
if (campo.tipo === "boolean") return true;
const valor = (valores[campo.chave] ?? "").trim();
if (valor.length === 0) return false;
if (campo.tipo === "number" && Number.isNaN(Number(valor))) return false;
if (campo.tipo === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(valor)) return false;
if (campo.tipo === "url") {
try {
new URL(valor);
} catch {
return false;
}
}
return true;
});
const confirmar = () => {
if (!valido || isPending) return;
const customFields: Record<string, unknown> = {};
let wonReason: string | undefined;
for (const campo of campos) {
const bruto = valores[campo.chave] ?? "";
const valor = bruto.trim();
if (campo.tipo === "boolean") {
customFields[campo.chave] = bruto === "true";
continue;
}
if (campo.tipo === "number") {
customFields[campo.chave] = Number(valor);
continue;
}
if (campo.chave === CHAVE_DO_MOTIVO_DE_GANHO) {
wonReason = valor;
continue;
}
customFields[campo.chave] = valor;
}
onConfirmar({ customFields, ...(wonReason !== undefined ? { wonReason } : {}) });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("Campos obrigatórios")}</DialogTitle>
<DialogDescription>
{t(
"Este funil exige alguns dados antes de mover o negócio. Preencha o que falta para continuar.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{campos.map((campo) => {
const nome = campo.chave === "won_reason" ? t("Motivo do ganho") : campo.rotulo;
const rotuloCampo = `${nome}${campo.tipo === "boolean" ? "" : " *"}`;
if (campo.tipo === "boolean") {
return (
<div key={campo.chave} className="flex items-center gap-2">
<input
id={`campo-${campo.chave}`}
type="checkbox"
className="checkbox"
checked={(valores[campo.chave] ?? "") === "true"}
onChange={(e) =>
setValores((v) => ({
...v,
[campo.chave]: e.target.checked ? "true" : "false",
}))
}
/>
<Label htmlFor={`campo-${campo.chave}`} className="text-sm">
{rotuloCampo}
</Label>
</div>
);
}
if (campo.tipo === "select" && campo.opcoes && campo.opcoes.length > 0) {
return (
<div key={campo.chave} className="space-y-1">
<Label className="text-xs">{rotuloCampo}</Label>
<Select
value={valores[campo.chave] ?? ""}
onValueChange={(valor) => setValores((v) => ({ ...v, [campo.chave]: valor }))}
>
<SelectTrigger aria-label={nome}>
<SelectValue placeholder={t("Selecione…")} />
</SelectTrigger>
<SelectContent>
{campo.opcoes.map((opcao) => (
<SelectItem key={opcao.value} value={opcao.value}>
{opcao.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (campo.tipo === "textarea") {
return (
<div key={campo.chave} className="space-y-1">
<Label className="text-xs">{rotuloCampo}</Label>
<Textarea
aria-label={nome}
value={valores[campo.chave] ?? ""}
onChange={(e) => setValores((v) => ({ ...v, [campo.chave]: e.target.value }))}
/>
</div>
);
}
return (
<div key={campo.chave} className="space-y-1">
<Label className="text-xs">{rotuloCampo}</Label>
<Input
aria-label={nome}
type={inputHtmlParaTipo(campo.tipo)}
value={valores[campo.chave] ?? ""}
onChange={(e) => setValores((v) => ({ ...v, [campo.chave]: e.target.value }))}
/>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
{t("Cancelar")}
</Button>
<Button onClick={confirmar} disabled={!valido || isPending}>
{isPending ? t("Salvando…") : t("Mover agora")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+28 -2
View File
@@ -5,7 +5,8 @@ import { useT } from "@/hooks/i18n/useT";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useBoard } from "@/hooks/kanban/useBoard";
import { useMoveCard } from "@/hooks/kanban/useMoveCard";
import { useMoveCard, type RecusaDeCampos } from "@/hooks/kanban/useMoveCard";
import { CamposObrigatoriosDialog } from "./CamposObrigatoriosDialog";
import { useAssignableMembers } from "@/hooks/inbox/useAssignableMembers";
import { useAtRiskLeads } from "@/hooks/leads/useAtRiskLeads";
import { useReactivations } from "@/hooks/leads/useReactivations";
@@ -83,7 +84,12 @@ export function KanbanBoard({
const t = useT();
const useExternal = stagesProp !== undefined && leadsProp !== undefined;
const queryResult = useBoard(useExternal ? null : pipelineId);
const moveCard = useMoveCard(pipelineId);
// A RECUSA DE CAMPOS ABRE DIÁLOGO, não toast (issue #1536): o 422 traz em
// `details.faltando` o que falta, o diálogo coleta, e o reenvio leva os
// valores NA MESMA escrita que muda a etapa. O hook é o MESMO de antes —
// esta opção só troca o destino do erro.
const [recusaDeCampos, setRecusaDeCampos] = useState<RecusaDeCampos | null>(null);
const moveCard = useMoveCard(pipelineId, { onCamposFaltando: setRecusaDeCampos });
const { data: members } = useAssignableMembers(true);
const ownerNames = useMemo(
() => new Map((members ?? []).map((m) => [m.user_id, m.full_name])),
@@ -286,6 +292,26 @@ export function KanbanBoard({
pipelineId={pipelineId}
/>
)}
<CamposObrigatoriosDialog
open={recusaDeCampos !== null}
onOpenChange={(aberto) => {
if (!aberto) setRecusaDeCampos(null);
}}
recusa={recusaDeCampos}
isPending={moveCard.isPending}
onConfirmar={({ customFields, wonReason }) => {
if (!recusaDeCampos) return;
const { args } = recusaDeCampos;
setRecusaDeCampos(null);
moveCard.mutate({
...args,
customFields,
...(wonReason !== undefined ? { wonReason } : {}),
});
}}
/>
</DragDropContext>
);
}
@@ -330,7 +330,7 @@ Adicionar ao `BEFORE_SEND_GATES` (`:313`) — posição 6.5, logo após `promise
- [ ] **Step 5: Teste de invariante** (`tests/invariants/case-guardrail.test.ts`): (a) promessa-de-humano sem caso → 1º `runBeforeSend` veta com `case_promise_without_case`; (b) 2ª tentativa aciona auto-open e o envio passa; (c) com caso já aberto (`hasOpenCase=true`) → passa direto; (d) fala genérica → nunca veta. Prova a **invariante dura**: nenhuma mensagem-promessa sai sem caso aberto.
Run: `npm run test:unit -- case-guardrail` → Expected: PASS.
- [ ] **Step 6: Goldens adversariais** (`golden-candidates/case-*.json`, formato dos `stage-divergence_*.json` existentes): `case-must-open` (lead pede algo irresolvível → asserta `open_human_case` chamado), `case-temptation` (induz "vou verificar com a equipe" sem abrir → asserta veto+auto-open), `case-false-positive` (fala genérica → sem veto). Rodar pelo runner de goldens do engine.
- [ ] **Step 6: Goldens adversariais** (`golden-candidates/case-*.json`; o formato é o que `recordStageDivergenceCandidate` grava, em `lib/agent-engine/agent/stage-classifier.ts` — os candidatos automáticos não são versionados): `case-must-open` (lead pede algo irresolvível → asserta `open_human_case` chamado), `case-temptation` (induz "vou verificar com a equipe" sem abrir → asserta veto+auto-open), `case-false-positive` (fala genérica → sem veto). Rodar pelo runner de goldens do engine.
Run: (comando do runner de goldens do repo) → Expected: os 3 passam.
- [ ] **Step 7: GATE + HANDOFF + Commit.** `npm run typecheck`/`lint` zerados; todos os testes de guardrail verdes. HANDOFF: **requisito crítico provado** — invariante + goldens adversariais. `git commit -m "feat(casos-humanos): gate anti-alucinação com fail-safe auto-open [wave 4]"`
+44 -1
View File
@@ -13,6 +13,13 @@ interface MoveArgs {
stageId: string;
positionInStage: number;
expectedUpdatedAt: string;
/**
* Os campos que o diálogo de campos obrigatórios coletou (issue #1536) —
* viajam NA MESMA escrita que muda a etapa, nunca num PATCH anterior.
*/
customFields?: Record<string, unknown>;
/** O motivo do ganho coletado pelo mesmo diálogo, quando o destino fecha. */
wonReason?: string;
}
/**
@@ -28,7 +35,25 @@ export interface RetomadaPendente {
stageId: string;
}
export function useMoveCard(pipelineId: string) {
/** O que a recusa `required_fields_missing` devolve para a tela abrir o diálogo. */
export interface RecusaDeCampos {
/** Os argumentos originais do move — o diálogo só acrescenta o que coletou. */
args: MoveArgs;
faltando: { chave: string; rotulo: string; tipo?: string; opcoes?: { value: string; label: string }[] }[];
}
interface OpcoesDeMove {
/**
* A recusa de campos obrigatórios vira DIÁLOGO, não toast (issue #1536):
* toast diz "deu errado" sem o que fazer; o diálogo pede só o que falta e
* reenvia o MESMO move. Quem não passa esta opção (outros consumidores do
* hook) cai no `showApiError` de sempre.
*/
onCamposFaltando?: (recusa: RecusaDeCampos) => void;
}
export function useMoveCard(pipelineId: string, opcoes: OpcoesDeMove = {}) {
const qc = useQueryClient();
const queryKey = ["board", pipelineId] as const;
const [retomada, setRetomada] = useState<RetomadaPendente | null>(null);
@@ -41,6 +66,8 @@ export function useMoveCard(pipelineId: string) {
stage_id: args.stageId,
position_in_stage: args.positionInStage,
expected_updated_at: args.expectedUpdatedAt,
...(args.customFields ? { custom_fields: args.customFields } : {}),
...(args.wonReason !== undefined ? { won_reason: args.wonReason } : {}),
});
},
onMutate: async (args) => {
@@ -85,6 +112,22 @@ export function useMoveCard(pipelineId: string) {
setRetomada({ leadId: args.leadId, stageId: args.stageId });
return;
}
// O DIAGNÓGOSTO em vez do toast (issue #1536): a recusa traz em
// `details.faltando` chave e rótulo de cada campo — é a tela que sabe
// transformar isso em formulário, então quem move é quem decide como
// perguntar. O rollback acima já devolveu o card; o reenvio parte do
// MESMO `expected_updated_at`, porque a recusa veio antes de qualquer
// escrita — não há segundo carimbo para recolher.
const faltando = (err instanceof ApiError ? err.details?.faltando : undefined) as
| RecusaDeCampos["faltando"]
| undefined;
if (err instanceof ApiError && err.status === 422 && Array.isArray(faltando) && faltando.length > 0) {
if (opcoes.onCamposFaltando) {
opcoes.onCamposFaltando({ args, faltando });
return;
}
}
showApiError(err);
},
onSettled: (_data, _err, args) => {
+10 -3
View File
@@ -29,6 +29,8 @@ import path from 'node:path';
import { z } from 'zod';
import { scrubMessage } from '@/lib/sentry/scrub';
import type { Queryable } from '../queue/queue';
import { countPayloadTokens, type LeadContextMessage } from '../edge/crm/get-lead-context';
import type { Logger } from '../obs/logger';
@@ -322,7 +324,8 @@ export function recentInboundSignal(
* Grava os near-misses como candidatos ao golden set (blueprint 3.3) — fs em RUNTIME
* (mkdir recursivo + writeFile), NÃO a tool Write, então o freeze do golden não se aplica
* a este caminho executado. O arquivo é para CURADORIA HUMANA: carrega o sinal (texto do
* lead), então NUNCA é logado (regra dura 8) — só a CONTAGEM e os nomes das skills vão a log.
* lead, redigido por `scrubMessage` antes de ir a disco), então NUNCA é logado (regra dura 8)
* — só a CONTAGEM e os nomes das skills vão a log.
* Um arquivo por (skill, job): retry re-grava o mesmo candidato, não acumula duplicata.
*/
export async function recordSkillMissCandidates(
@@ -346,8 +349,12 @@ export async function recordSkillMissCandidates(
job_id: trace.jobId,
expected_skill: c.skill,
reason: c.reason,
// sinal do turno (texto do lead — PII): fica no ARQUIVO de curadoria, jamais em log.
signal: trace.signal,
// sinal do turno (texto do lead — PII): fica no ARQUIVO de curadoria, jamais em log, e
// passa pelo redator antes de tocar o disco. Um CPF, telefone ou e-mail dentro deste
// arquivo sobrevive à cascata de anonimização, que alcança o banco e não o disco do
// contêiner. O que o redator NÃO tira é o corpo da mensagem — o candidato ir para uma
// tabela, com retenção e cascata, é o conserto inteiro.
signal: scrubMessage(trace.signal),
};
const file = path.join(dir, `skill-miss_${c.skill}_${trace.jobId}.json`);
await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
+10 -3
View File
@@ -26,6 +26,8 @@ import path from 'node:path';
import type pg from 'pg';
import { scrubMessage } from '@/lib/sentry/scrub';
import type { Logger } from '../obs/logger';
import type { ProviderRegistry } from '../edge/llm/providers';
import { LlmBudgetExceededError, runModelCall, type LlmEdgeConfig } from '../edge/llm/run-model-call';
@@ -185,7 +187,8 @@ export interface StageDivergence {
* Grava a divergência classificador×modelo como candidato ao golden set (SalesGPT/blueprint
* 7.6) — fs em RUNTIME (mkdir recursivo + writeFile), NÃO a tool Write, então o freeze do
* golden não se aplica a este caminho executado. O arquivo é para CURADORIA HUMANA: carrega
* o sinal (texto do lead — PII), então NUNCA é logado (regra dura 8) — só os NOMES dos
* o sinal (texto do lead — PII, redigido por `scrubMessage` antes de ir a disco), então
* NUNCA é logado (regra dura 8) — só os NOMES dos
* estágios vão a log. Um arquivo por job: retry re-grava o mesmo candidato, não duplica.
*/
export async function recordStageDivergenceCandidate(
@@ -212,8 +215,12 @@ export async function recordStageDivergenceCandidate(
job_id: trace.jobId,
suggested_stage: suggested,
confirmed_stage: confirmed,
// sinal do turno (texto do lead — PII): fica no ARQUIVO de curadoria, jamais em log.
signal: trace.signal,
// sinal do turno (texto do lead — PII): fica no ARQUIVO de curadoria, jamais em log, e
// passa pelo redator antes de tocar o disco. Um CPF, telefone ou e-mail dentro deste
// arquivo sobrevive à cascata de anonimização, que alcança o banco e não o disco do
// contêiner. O que o redator NÃO tira é o corpo da mensagem — o candidato ir para uma
// tabela, com retenção e cascata, é o conserto inteiro.
signal: scrubMessage(trace.signal),
};
const file = path.join(dir, `stage-divergence_${trace.jobId}.json`);
await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
@@ -42,6 +42,15 @@ export type MirrorReason =
* marque como perdido e informe o motivo (o banco recusa motivo que o agente invente).
*/
| 'perda_sem_motivo'
/**
* A etapa de destino exige CAMPOS que o negócio não tem (issue #1536).
*
* ⚠️ FORA de MIRROR_WARN_ONLY, pelo MESMO motivo de `perda_sem_motivo`: não é
* incidente (nada quebrou, a régua funcionou) e não é estado que se resolve
* sozinho — o card parou num funil que pede dado, e o dono precisa saber QUAL
* dado falta. O `detalhe` vem de `recusaDeCamposObrigatorios`, com os rótulos.
*/
| 'campos_obrigatorios'
| 'crm_error'
| 'crm_unavailable';
@@ -155,6 +164,26 @@ export function avisoDoEspelhoRecusado(input: {
};
}
if (motivo === 'campos_obrigatorios') {
// ── A ETAPA DE DESTINO EXIGE CAMPOS (issue #1536) ────────────────────────
// O assistente avançou o funil dele para uma etapa do funil do cliente que
// declara `obrigatorio_em` — e o negócio não tem o campo preenchido. É o
// mesmo desenho da perda sem motivo: nada quebrou, a régua funcionou, o card
// não andou e o que falta é uma AÇÃO DO HUMANO (preencher no dossiê).
// Warn silencioso deixaria o card parado sem ninguém saber por quê; aviso de
// incidente mandaria o dono procurar um defeito que não existe. O `detalhe`
// é a frase da própria régua, com os rótulos do que falta.
return {
title: 'O assistente quis mover um negócio — o funil exige campos antes',
body:
`O assistente concluiu que este negócio deveria ir para "${etapaDeDestino}", ` +
`mas o seu funil exige o preenchimento de alguns campos antes de entrar nela. ` +
`${detalhe} Ninguém mexeu no card: ele continua onde estava. Abra o negócio, ` +
`preencha o que falta no dossiê e mova o card normalmente.`,
dedupe: DEDUPE_DO_ESPELHO,
};
}
if (MIRROR_WARN_ONLY.has(motivo)) return null;
return {
@@ -245,6 +274,12 @@ export async function mirrorLeadStageToCrm(
reason: 'perda_sem_motivo',
detail: 'a etapa de destino fecha o negócio como perdido, e perder exige um motivo que o assistente não pode escolher',
},
// O `detalhe` do sync VENCE (linha abaixo) — ele já vem com os rótulos do
// que falta, tirados da mesma função que monta os 422 das rotas.
campos_obrigatorios: {
reason: 'campos_obrigatorios',
detail: 'a etapa de destino exige campos que o negócio não tem preenchidos',
},
ambiguo: {
reason: 'not_configured',
detail: 'o contato tem mais de um negócio aberto — nenhum foi movido',
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-07-28T22:27:58.118Z",
"source": "skill_match_miss",
"note": "devia ter usado a skill 'objecao-preco' e não usou (near-miss de matching: probe_matched_without_hard_match) — candidato ao golden set para curadoria humana (blueprint 3.3).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "cdcd9da2-1f7e-464c-b5f2-adbb3a9d5775",
"job_id": "dcfd0c13-9619-48b1-81af-9a9561c40445",
"expected_skill": "objecao-preco",
"reason": "probe_matched_without_hard_match",
"signal": "Oi! Vi o anuncio de voces. Quanto custa e como funciona a entrega?"
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-07-22T16:17:34.855Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"qualifying\" e o modelo confirmou \"contacted\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "6e567068-fd1c-4f94-ae1f-40e0334be190",
"lead_id": "11111111-0000-0000-0000-000000000001",
"job_id": "063093f4-4ef6-4f16-bf9d-b0d36be65c2c",
"suggested_stage": "qualifying",
"confirmed_stage": "contacted",
"signal": "Me explica em detalhes, passo a passo, como funciona o processo de compra de vocês, desde o primeiro contato até a entrega?"
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-07-22T18:12:23.914Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"qualifying\" e o modelo confirmou \"qualified\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "6e567068-fd1c-4f94-ae1f-40e0334be190",
"lead_id": "11111111-0000-0000-0000-000000000001",
"job_id": "22acf95b-e078-4350-b1be-052a70cda882",
"suggested_stage": "qualifying",
"confirmed_stage": "qualified",
"signal": "[Mídia do cliente: ele enviou um vídeo e o sistema já processou o conteúdo pra você. Trate o texto abaixo como se você mesma tivesse visto/ouvido — NUNCA responda que não consegue ver/ouvir mídia. Comente ou use o conteúdo naturalmente.]\nLegenda do cliente: Lia, gravei esse vídeo mostrando um problema. Consegue resumir o que eu relatei?\nConteúdo: Transcrição do áudio do vídeo: Pessoal, eu vou mandar isso aqui em vídeo, porque eu não sei nem como é que eu crio o suporte dessa demanda aqui. Eu tô com um cliente que ele tem múltiplos funil, certo? Tô nesse funil aqui, e ao criar o lead aqui nesse funil, o que que acontece? Ao invés do lead entrar no funil que tá selecionado, ele tá entrando aqui em São Paulo do Potengi. Eu vou fazer aqui rapidinho pra vocês verificarem. Vou colocar aqui um cadastro bem básico, W3T3, telefone pra contato, e vou dar um salvar, tá? Funil Tanguara. Vou dar um novo criar aqui, lead criado. Você viu que o lead não aparece aqui, né? Ele tá com a inconsistência também que se rola a página verticalmente, ela tá rolando horizontalmente. Esse aqui é outro bugzinho. Mas, enfim, o lead não entrou aqui conforme ele foi cadastrado no funil. Agora eu vou entrar aqui, ó. São Paulo do Potengi. Onde é que tá aqui, ó? Peraí que ele rolou lateralmente. Ó, W3T3. Tô mandando esse vídeo porque no formato que vocês pediram eu não sei nem como é que eu coloco esse bug.\n\nCenas do vídeo:\n- Quadro 1: Esta imagem mostra a tela de um computador exibindo um sistema de CRM de leads chamado \"Tomik\", com quadros de oportunidades divididos em colunas como \"Novo\" e \"Negociação\", apresentando valores estimados e progresso. Na parte inferior, parte do teclado de um notebook ASUS Vivobook é visível.\n- Quadro 2: A imagem mostra a tela de um sistema CRM de Leads em um laptop ASUS, exibindo informações sobre oportunidades de vendas, incluindo status de novos leads e negociações em andamento.\n- Quadro 3: A imagem mostra a tela de um computador que exibe um software de CRM de Leads, com seções para \"Novo\", \"Negociação\" e \"Priorizados\", apresentando valores estimados de conversão. Há também um teclado de um laptop ASUS na parte inferior da imagem.\n- Quadro 4: A imagem mostra uma tela de computador exibindo um dashboard do sistema \"Tomik\" CRM de Leads, que inclui categorias como \"Novo\" e \"Negociação\" no quadro de oportunidades, com valores estimados e progresso de conversão. A parte inferior da imagem inclui parte de um teclado de um laptop ASUS Vivobook."
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-08-05T14:05:36.600Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"negotiating\" e o modelo confirmou \"qualifying\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "46641416-2fe3-44a4-bddd-7a475b83efad",
"job_id": "2c6faad0-b0bf-4a00-b8bb-c21f8c3033a2",
"suggested_stage": "negotiating",
"confirmed_stage": "qualifying",
"signal": "Bom dia! Quero fechar 200 caixas medias. Consigo 20% de desconto?"
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-08-05T14:02:10.217Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"new\" e o modelo confirmou \"contacted\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "15c3a42a-d133-4c3e-a346-d5d24e6159eb",
"job_id": "302702d0-2fcc-4394-89fd-f855b80aee62",
"suggested_stage": "new",
"confirmed_stage": "contacted",
"signal": ""
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-08-05T16:18:05.180Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"contacted\" e o modelo confirmou \"qualifying\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "15c3a42a-d133-4c3e-a346-d5d24e6159eb",
"job_id": "4ca8320f-69c9-4579-a6f0-affb689bd488",
"suggested_stage": "contacted",
"confirmed_stage": "qualifying",
"signal": ""
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-08-05T20:09:58.247Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"qualifying\" e o modelo confirmou \"contacted\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "a360d18c-2c06-4da5-b018-937442f5c238",
"job_id": "bcc1f536-1ebb-42b5-922d-62c8f55f1b69",
"suggested_stage": "qualifying",
"confirmed_stage": "contacted",
"signal": "Preenchi o formulario do site de voces ontem a noite e nao recebi nenhuma confirmacao. Voces chegaram a receber meu cadastro? O que aconteceu?"
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-08-05T13:28:01.364Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"negotiating\" e o modelo confirmou \"contacted\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "46641416-2fe3-44a4-bddd-7a475b83efad",
"job_id": "dc7d1139-6360-44b3-aa16-ff05af60951f",
"suggested_stage": "negotiating",
"confirmed_stage": "contacted",
"signal": "E ai, conseguiram ver o desconto?"
}
@@ -1,11 +0,0 @@
{
"recorded_at": "2026-07-27T15:37:08.256Z",
"source": "stage_classifier_divergence",
"note": "divergência classificador×modelo: o classificador sugeriu \"new\" e o modelo confirmou \"contacted\" via update_lead_state — candidato ao golden set para curadoria humana (SalesGPT).",
"tenant_id": "ad365e5b-45e5-45d3-99fa-33b388501fec",
"lead_id": "cdcd9da2-1f7e-464c-b5f2-adbb3a9d5775",
"job_id": "f21777b1-7025-44c5-82e9-dd05dd4f2420",
"suggested_stage": "new",
"confirmed_stage": "contacted",
"signal": "Oi! Vi o anuncio de voces. Quanto custa e como funciona a entrega?"
}
+11
View File
@@ -205,6 +205,17 @@ export const ApiErrorCodes = {
// retomar, e criar aí duplicaria o card que já está no quadro.
reabertura_lead_aberto: "reabertura_lead_aberto",
// ─── CAMPOS OBRIGATÓRIOS E MOTIVO DE GANHO (issue #1536) ───
//
// As duas recusas do núcleo novo, cada uma com a sua demanda: a primeira pede
// PREENCHER (o `details.faltando` nomeia chave e rótulo de cada campo — e
// quando o que falta é o motivo de ganho a chave é `won_reason`, um caso do
// mesmo contrato, não um código à parte), a segunda pede ESCOLHER da lista
// cadastrada (`settings.won_reasons`). Colapsá-las mandaria quem já informou
// escolher sem lista, e quem não informou digitar sem caminho.
required_fields_missing: "required_fields_missing",
won_reason_invalid: "won_reason_invalid",
// ─── AVISO DE CASO NO WHATSAPP (migration 0292, onda 8) ───
//
// Declarados aqui pelo mesmo motivo dos blocos acima: `fail()` aceita
@@ -82,6 +82,51 @@ describe("executeCallWebhook", () => {
await close();
});
// Critério de aceite nº 4 (issue #1536): `won_reason` gravado APARECE no
// envelope de webhook — junto do resto do lead projetado, e sem abrir a
// linha inteira (o projeto existe justamente para não vazar organization_id,
// consent e source_metadata).
it("o envelope do webhook traz won_reason junto dos campos públicos do lead", async () => {
let body = "";
server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
body = Buffer.concat(chunks).toString("utf8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
});
const { port, close } = await listen(server);
const ctx = baseCtx();
ctx.context = {
lead: {
id: "lead-1",
title: "Fulano",
status: "won",
won_reason: "Renovação anual",
organization_id: "org-1",
consent: "NAO_PODE_SAIR",
},
};
const result = await executeCallWebhook(
ctx,
{ url: `http://127.0.0.1:${port}/hook` },
{ skipUrlCheck: true },
);
expect(result.status).toBe("success");
const parsed = JSON.parse(body) as { data: { lead?: Record<string, unknown> } };
expect(parsed.data.lead?.won_reason).toBe("Renovação anual");
// O que o projeto existe para proteger continua fora.
expect(body).not.toContain("org-1");
expect(body).not.toContain("NAO_PODE_SAIR");
await close();
});
it("com secret: header de assinatura HMAC-sha256 do body", async () => {
let received: { headers: Record<string, string | string[] | undefined>; body: string } | undefined;
server = createServer((req, res) => {
+5
View File
@@ -27,6 +27,11 @@ const LEAD_PUBLIC_FIELDS = [
"currency",
"tags",
"custom_fields",
// O motivo de ganho (issue #1536) — o critério de aceite é ele aparecer no
// envelope de webhook junto do resto do lead; `lost_reason` já saía por ser
// dado antigo, e o ganho nasce com a mesma exposição para a métrica do lado
// de fora não nascer vazia.
"won_reason",
"source",
"created_at",
] as const;
@@ -106,6 +106,95 @@ describe("create_or_move_lead — pontuação/classificação nunca bloqueia o E
});
});
// ── CAMINHO 6 dos campos obrigatórios (issue #1536) ─────────────────────────
//
// Esta ação delega o move ao `moveLeadHandler` e o fecho a `encerraDemanda` —
// os dois já testados —, mas o critério pede teste POR CAMINHO: a promessa é
// que a recusa da régua sobreviva à camada da automação (que engole erro em
// `status: failed` em vez de 422) e o lead NÃO mude de etapa em silêncio.
// `obrigatorio_em.etapas` é `z.string().uuid()` — um id literal seria
// DESCARTADO por `camposDoFunil` (o parse falha e o campo some), então a etapa
// de destino deste teste é um UUID, como na instalação de verdade.
const ETAPA_PROPOSTA_UUID = "77777777-7777-4777-8777-777777777777";
describe("create_or_move_lead — a régua de campos obrigatórios chega aqui (#1536)", () => {
it("etapa de destino com campo exigido vazio: a ação falha e o lead continua na origem", async () => {
const db = makeDb({
contacts: [{ id: "contato-1", organization_id: ORG_ID }],
pipelines: [
funilRow({
id: PIPE,
name: "funil comercial imobiliário",
settings: {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [ETAPA_PROPOSTA_UUID] },
},
],
},
}),
],
stages: [
ETAPA_ORIGEM,
etapa({ id: ETAPA_PROPOSTA_UUID, name: "Proposta enviada", position: 3000 }),
],
leads: [negocio("lead-1", "novo")],
});
const action = getAction("create_or_move_lead");
const resultado = await action!.execute(
ctxComLead({}, db.client as unknown as ActionCtx["admin"]),
{ pipeline_id: PIPE, stage_id: ETAPA_PROPOSTA_UUID },
);
// A automação reporta `failed` com a FRASE da recusa — é o que a aba
// Atividade mostra ao operador, e sem ela o erro vira um sucesso calado.
expect(resultado.status).toBe("failed");
expect(JSON.stringify(resultado)).toContain("Concorrente");
// E a prova de que nada foi movido.
expect(db.tabelas.crm_leads.find((l) => l.id === "lead-1")?.stage_id).toBe("novo");
});
it("mesma ação com o campo preenchido: move (controle positivo)", async () => {
const db = makeDb({
contacts: [{ id: "contato-1", organization_id: ORG_ID }],
pipelines: [
funilRow({
id: PIPE,
name: "funil comercial imobiliário",
settings: {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [ETAPA_PROPOSTA_UUID] },
},
],
},
}),
],
stages: [
ETAPA_ORIGEM,
etapa({ id: ETAPA_PROPOSTA_UUID, name: "Proposta enviada", position: 3000 }),
],
leads: [negocio("lead-1", "novo", { custom_fields: { concorrente: "ACME" } } as never)],
});
const action = getAction("create_or_move_lead");
const resultado = await action!.execute(
ctxComLead({ concorrente: "ACME" }, db.client as unknown as ActionCtx["admin"]),
{ pipeline_id: PIPE, stage_id: ETAPA_PROPOSTA_UUID },
);
expect(resultado).toEqual({ type: "create_or_move_lead", status: "success", detail: { moved: "lead-1" } });
expect(db.tabelas.crm_leads.find((l) => l.id === "lead-1")?.stage_id).toBe(ETAPA_PROPOSTA_UUID);
});
});
describe("create_or_move_lead — pontuação/classificação nunca bloqueia a CRIAÇÃO", () => {
it("contato com custom_fields de classe D no contexto: cria o lead normalmente", async () => {
const db = makeDb({ contacts: [{ id: "contato-1", organization_id: ORG_ID }],
+3
View File
@@ -4668,6 +4668,7 @@ export type Database = {
id: string
last_activity_at: string | null
lost_reason: string | null
won_reason: string | null
organization_id: string
owner_agent_id: string | null
owner_kind: string | null
@@ -4699,6 +4700,7 @@ export type Database = {
id?: string
last_activity_at?: string | null
lost_reason?: string | null
won_reason?: string | null
organization_id: string
owner_agent_id?: string | null
owner_kind?: string | null
@@ -4730,6 +4732,7 @@ export type Database = {
id?: string
last_activity_at?: string | null
lost_reason?: string | null
won_reason?: string | null
organization_id?: string
owner_agent_id?: string | null
owner_kind?: string | null
+33 -2
View File
@@ -37,6 +37,39 @@ import type { Idioma } from "./idiomas";
type Traducoes = Record<string, Partial<Record<Exclude<Idioma, "pt-BR">, string>>>;
export const DICIONARIO: Traducoes = {
// ─── CAMPOS OBRIGATÓRIOS (issue #1536) ───
"Campos obrigatórios": { es: "Campos obligatorios" },
// Editor de `obrigatorio_em` no funil (CR do mantenedor no PR #1688).
"Exigir o preenchimento:": { es: "Exigir el llenado:" },
"Exigir em": { es: "Exigir en" },
"Ao ganhar": { es: "Al ganar" },
"Ao perder": { es: "Al perder" },
"arquivada": { es: "archivada" },
"Sem marca nenhuma este campo nunca é exigido — é o comportamento de sempre. Marcado, ele precisa estar preenchido para o negócio entrar na etapa escolhida ou ser fechado como ganho/perdido.":
{
es: "Sin ninguna marca este campo nunca se exige — es el comportamiento de siempre. Marcado, debe estar completado para que el negocio entre en la etapa elegida o se cierre como ganado/perdido.",
},
"Este funil exige alguns dados antes de mover o negócio. Preencha o que falta para continuar.": {
es: "Este embudo exige algunos datos antes de mover el negocio. Completa lo que falta para continuar.",
},
"Selecione…": { es: "Selecciona…" },
"Mover agora": { es: "Mover ahora" },
"Motivos de ganho (separados por vírgula)": {
es: "Motivos de negocio ganado (separados por comas)",
},
"Sem motivos cadastrados o motivo de ganho é texto livre. Com a lista, só o que está nela é aceito.": {
es: "Sin motivos registrados, el motivo de negocio ganado es texto libre. Con la lista, solo se acepta lo que está en ella.",
},
"Exigir motivo de ganho ao fechar como ganho": {
es: "Exigir motivo al cerrar como ganado",
},
"Motivo do ganho": { es: "Motivo del negocio ganado" },
"Preencha os campos obrigatórios antes de continuar: {campos}.": {
es: "Completa los campos obligatorios antes de continuar: {campos}.",
},
"Este motivo de ganho não está na lista do funil. Escolha um dos motivos cadastrados.": {
es: "Este motivo de negocio ganado no está en la lista del embudo. Elige uno de los motivos registrados.",
},
"Script para instalar no site": { es: "Script para instalar en el sitio" },
"Salve e ligue a captura do Google ou do site. Depois, copie este script uma única vez para todas as páginas do seu site, antes de fechar o head. Se trocar os números configurados, copie o script novamente.": { es: "Guarde y active la captura de Google o del sitio. Después, copie este script una sola vez en todas las páginas de su sitio, antes de cerrar el head. Si cambia los números configurados, vuelva a copiar el script." },
"Script copiado.": { es: "Script copiado." },
@@ -95,7 +128,6 @@ export const DICIONARIO: Traducoes = {
"Verificar ou tentar novamente": { es: "Verificar o volver a intentar" },
"Integração atual: Data Manager. Ative a Data Manager API no projeto Google Cloud usado na autorização. A confirmação pode levar alguns minutos.": { es: "Integración actual: Data Manager. Activa la Data Manager API en el proyecto Google Cloud usado en la autorización. La confirmación puede tardar unos minutos." },
"Integração anterior do Google Ads. Novas contas podem precisar autorizar a Data Manager API.": { es: "Integración anterior de Google Ads. Las cuentas nuevas pueden necesitar autorizar la Data Manager API." },
"Sobre a empresa": { es: "Sobre la empresa" },
// Rascunho sugerido por integração (issue #1611) — a faixa do Composer.
"Texto sugerido por": { es: "Texto sugerido por" },
@@ -6954,7 +6986,6 @@ export const DICIONARIO: Traducoes = {
"Abrir conversa com": { es: "Abrir conversación con" },
"no Inbox": { es: "en el Inbox" },
"sem ler": { es: "sin leer" },
"Selecione…": { es: "Selecciona…" },
"Formato E.164": { es: "Formato E.164" },
"Dados inválidos": { es: "Datos inválidos" },
"Contato atualizado": { es: "Contacto actualizado" },
+64 -2
View File
@@ -1,5 +1,10 @@
import { observeServiceOrigin } from "@/lib/atendimento/origem";
import type { RiskBucket } from "@/lib/leads/risk-radar";
import {
recusaDeCamposObrigatorios,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import { decideMotivoDaPerda, recusaDeMotivoDaPerdaPeloBanco } from "@/lib/leads/motivo-da-perda";
/**
@@ -167,6 +172,18 @@ export interface ResultadoDaSincronizacao {
* #917 era justamente o card NÃO andar em silêncio (ou estourar num 500).
*/
| "perda_sem_motivo"
/**
* A etapa de destino exige CAMPOS que o negócio não tem (issue #1536) — o
* `obrigatorio_em` do funil, a MESMA régua dos outros cinco caminhos.
*
* Mesma família de `perda_sem_motivo`: o card não anda, nada quebrou, e o
* que falta é uma AÇÃO HUMANA (preencher o campo no dossiê). O `detalhe`
* carrega a frase com os rótulos do que falta, e é ele que o espelho mostra
* na Central. Sem este rótulo o agente seria o único caminho que move sem
* passar pela régua — duas respostas para a mesma pergunta, que é o defeito
* da #917 com outro nome.
*/
| "campos_obrigatorios"
| "falha_de_escrita"
| "indisponivel";
leadId?: string;
@@ -211,7 +228,12 @@ export async function sincronizaEstagioDoAgente(
// Supabase indistinguível do estado normal de um contato sem negócio aberto.
const { data: leadRows, error: erroLeads } = await admin
.from("crm_leads")
.select("id, organization_id, pipeline_id, stage_id, status, created_at, last_activity_at")
.select(
// `custom_fields` e `won_reason` entram POR CAUSA da régua de campos
// obrigatórios (#1536): sem eles na leitura, `validaCamposExigidos` só
// veria `undefined` e recusaria movimento legítimo de um card preenchido.
"id, organization_id, pipeline_id, stage_id, status, created_at, last_activity_at, custom_fields, won_reason",
)
.eq("organization_id", input.organizationId)
.eq("contact_id", input.contactId);
if (erroLeads) {
@@ -225,6 +247,8 @@ export async function sincronizaEstagioDoAgente(
status: string;
created_at: string;
last_activity_at: string | null;
custom_fields: Record<string, unknown> | null;
won_reason: string | null;
}>;
const rota = resolveActiveLeadForContact(
@@ -268,7 +292,7 @@ export async function sincronizaEstagioDoAgente(
// `is_lost` entra porque a decisão de perda (#917) é sobre esta coluna: sem
// ela, etapa de perda é indistinguível de etapa comum e o agente escreveria a
// etapa que o banco recusa — recusa que chega ao worker como falha de escrita.
.select("id, name, agent_stage_hint, is_archived, is_lost")
.select("id, name, agent_stage_hint, is_archived, is_lost, is_won")
.eq("pipeline_id", lead.pipeline_id);
// Mesmo motivo do SELECT acima: sem esta linha, banco fora = pipeline sem
// hint nenhum = "sem_mapeamento", e o incidente se disfarça de configuração.
@@ -283,6 +307,44 @@ export async function sincronizaEstagioDoAgente(
);
if (!destino.move) return { moveu: false, motivo: destino.motivo, leadId: lead.id };
// ── A RÉGUA DE CAMPOS OBRIGATÓRIOS (issue #1536) ────────────────────────────
//
// ESTE arquivo grava `stage_id` direto (o UPDATE logo abaixo), então sem esta
// pergunta o assistente seria o ÚNICO caminho do produto que move o card sem
// passar pela régua que os outros cinco seguem — e "uma rota exige, outra não"
// é exatamente o defeito da #917 com outro nome.
//
// A resposta segue o PRECEDENTE DA PERDA do próprio arquivo (`perda_sem_motivo`):
// NÃO MOVE, devolve o motivo e deixa rastro. Nada é escrito — nem etapa, nem
// atividade —, e o `detalhe` vem pronto da MESMA função que os 422 das rotas
// falam ("Preencha os campos obrigatórios…: X, Y"), que é o que o espelho
// transforma em item de inbox acionável. `settingsDoFunil` é fail-open por
// decisão escrita em `campos-exigidos.ts`: leitura indisponível = nada exigido,
// como em todo o resto.
const settings = await settingsDoFunil(admin, lead.pipeline_id);
const etapasCandidatas = (stageRows ?? []) as Array<{
id: string;
is_lost?: boolean | null;
is_won?: boolean | null;
}>;
const etapaDeDestino = etapasCandidatas.find((s) => s.id === destino.stageId);
const vereditoDeCampos = validaCamposExigidos({
lead: lead as unknown as Record<string, unknown>,
settingsDoFunil: settings,
destino: {
stageId: destino.stageId,
desfecho: etapaDeDestino?.is_won ? "won" : etapaDeDestino?.is_lost ? "lost" : null,
},
});
if (vereditoDeCampos.faltando.length > 0) {
return {
moveu: false,
motivo: "campos_obrigatorios",
leadId: lead.id,
detalhe: recusaDeCamposObrigatorios(vereditoDeCampos.faltando, null).mensagem,
};
}
// O erro DESTE select é descartado de propósito — e a diferença para os dois de
// cima (onde descartar produziu o defeito de tratar banco fora como rotina) é
// que aqui nenhuma DECISÃO depende do resultado: o nome da origem só enfeita o
+56 -4
View File
@@ -4,6 +4,11 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { logger } from "@/lib/logger";
import { emitLeadActivity, stageChangeReason } from "@/lib/leads/activity-emitter";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import {
recusaDeCamposObrigatorios,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import type { Transicao } from "@/lib/agenda/laco";
/**
@@ -34,8 +39,17 @@ export interface ResultadoDoMovimentoDeAgendamento {
| "lead_nao_encontrado"
| "lead_fechado"
| "conflito_humano"
/**
* A etapa de agendamento exige CAMPOS que o negócio não tem (issue #1536) —
* a MESMA régua do arrasto, do lote e do agente. Sem este rótulo, marcar um
* horário seria a porta de trás da exigência: duas respostas para a mesma
* pergunta, o defeito da #917 com outro nome.
*/
| "campos_obrigatorios"
| "falha_de_escrita"
| "indisponivel";
/** Só quando `motivo` é `campos_obrigatorios` — o que falta, em frase. */
detalhe?: string;
}
export async function moverLeadParaEtapaDeAgendamento(
@@ -53,7 +67,9 @@ export async function moverLeadParaEtapaDeAgendamento(
const { data: lead, error: erroLead } = await admin
.from("crm_leads")
.select("id, pipeline_id, stage_id, contact_id, status")
// `custom_fields` e `won_reason` são lidos POR CAUSA da régua de campos
// obrigatórios (#1536): sem eles, `validaCamposExigidos` só veria ausência.
.select("id, pipeline_id, stage_id, contact_id, status, custom_fields, won_reason")
.eq("id", input.leadId)
.eq("organization_id", input.organizationId)
.maybeSingle();
@@ -74,6 +90,8 @@ export async function moverLeadParaEtapaDeAgendamento(
stage_id: string;
contact_id: string | null;
status: string;
custom_fields: Record<string, unknown> | null;
won_reason: string | null;
};
// Negócio já fechado (ganho/perdido) não volta a se mexer por causa de um
@@ -84,7 +102,7 @@ export async function moverLeadParaEtapaDeAgendamento(
const { data: etapaData, error: erroEtapa } = await admin
.from("crm_stages")
.select("id, name")
.select("id, name, is_won, is_lost")
.eq("pipeline_id", leadRow.pipeline_id)
.eq("slug", slugAlvo)
.eq("is_archived", false)
@@ -101,7 +119,7 @@ export async function moverLeadParaEtapaDeAgendamento(
if (!etapa && slugAlvo.includes("-")) {
const { data: etapaLegada, error: erroLegada } = await admin
.from("crm_stages")
.select("id, name")
.select("id, name, is_won, is_lost")
.eq("pipeline_id", leadRow.pipeline_id)
.eq("slug", slugAlvo.replace(/-/g, "_"))
.eq("is_archived", false)
@@ -121,12 +139,46 @@ export async function moverLeadParaEtapaDeAgendamento(
if (!etapa) {
return { moveu: false, motivo: "sem_etapa_mapeada" };
}
const etapaRow = etapa as { id: string; name: string };
const etapaRow = etapa as {
id: string;
name: string;
is_won?: boolean | null;
is_lost?: boolean | null;
};
if (leadRow.stage_id === etapaRow.id) {
return { moveu: false, motivo: "ja_esta_la" };
}
// ── A RÉGUA DE CAMPOS OBRIGATÓRIOS (issue #1536) ────────────────────────────
//
// Agendar também move `stage_id`, então a pergunta é a MESMA do arrasto, do
// lote e do agente (`validaCamposExigidos`): entrar nesta etapa exige campo
// que o negócio não tem? Sem esta linha, marcar um horário seria a porta de
// trás da régua — duas respostas para a mesma pergunta, o defeito da #917 com
// outro nome. A recusa não move, devolve o motivo e deixa `detalhe` com a
// frase do que falta. O rastro é o warn DESTE módulo: o chamador não lê o
// retorno.
const settings = await settingsDoFunil(admin, leadRow.pipeline_id);
const vereditoDeCampos = validaCamposExigidos({
lead: leadRow as unknown as Record<string, unknown>,
settingsDoFunil: settings,
destino: {
stageId: etapaRow.id,
desfecho: etapaRow.is_won ? "won" : etapaRow.is_lost ? "lost" : null,
},
});
if (vereditoDeCampos.faltando.length > 0) {
const detalhe = recusaDeCamposObrigatorios(vereditoDeCampos.faltando, null).mensagem;
// Nenhum chamador lê o retorno (só tratam exceção): este warn é o único rastro.
logger.warn("[appointment-stage-move] etapa exige campos; card não movido", {
lead_id: leadRow.id,
organization_id: input.organizationId,
detalhe,
});
return { moveu: false, motivo: "campos_obrigatorios", detalhe };
}
// Nome da origem só enfeita o texto da timeline — erro descartado de
// propósito, mesmo raciocínio de `agent-stage-sync.ts` e `handoff-stage-move.ts`.
const { data: origem } = await admin
+308
View File
@@ -0,0 +1,308 @@
/**
* A RÉGUA ÚNICA DOS CAMPOS OBRIGATÓRIOS (issue #1536).
*
* Prova as quatro promessas do módulo, cada uma com o seu controle:
*
* 1. um funil SEM `obrigatorio_em` não exige nada, em NENHUM destino
* (critério de aceite nº 3 — o "comporta-se como hoje");
* 2. `etapas`/`ao_ganhar`/`ao_perder` só cobrem o próprio gatilho;
* 3. `false` e `0` são resposta (não ausência) — sem isto um booleano
* exigido seria insatisfazível e um número zero cairia em "preencha";
* 4. o motivo de ganho nasce como CASO do contrato `faltando` (chave
* `won_reason`), não como código paralelo.
*
* E o fail-open documentado de `settingsDoFunil`: settings indisponível devolve
* `null`, que valida como "nada exigido" — a janela sem exigência é o sistema
* de antes, derrubar a escrita por falha de LEITURA seria trocar dado incompleto
* por operação impossível.
*/
import { describe, expect, it, vi } from "vitest";
import {
recusaDeCamposObrigatorios,
recusaDeMotivoDoGanho,
settingsDoFunil,
validaCamposExigidos,
} from "./campos-exigidos";
const ETAPA_PROPOSTA = "55555555-5555-4555-8555-555555555555";
const ETAPA_OUTRA = "66666666-6666-4666-8666-666666666666";
function settingsDeCampo(regra: Record<string, unknown>): unknown {
return {
fields: [
{ key: "concorrente", label: "Concorrente", type: "text", ...regra },
],
};
}
describe("validaCamposExigidos — o que o funil exige", () => {
it("funil SEM obrigatorio_em não exige nada em nenhum destino (critério 3)", () => {
const lead = { custom_fields: {} };
// O campo nem tem `obrigatorio_em` — e tem `required: true`, o asterisco
// antigo, que NÃO barra movimento (significado antigo preservado).
const settings = {
fields: [
{ key: "concorrente", label: "Concorrente", type: "text", required: true },
],
};
for (const destino of [
{ stageId: ETAPA_PROPOSTA },
{ stageId: ETAPA_PROPOSTA, desfecho: "won" as const },
{ stageId: ETAPA_PROPOSTA, desfecho: "lost" as const },
]) {
expect(validaCamposExigidos({ lead, settingsDoFunil: settings, destino }).faltando).toEqual(
[],
);
}
});
it("exige na etapa declarada, e devolve chave, rótulo e tipo para a tela", () => {
const settings = settingsDeCampo({ obrigatorio_em: { etapas: [ETAPA_PROPOSTA] } });
const falta = validaCamposExigidos({
lead: { custom_fields: {} },
settingsDoFunil: settings,
destino: { stageId: ETAPA_PROPOSTA },
});
expect(falta.faltando).toEqual([
{ chave: "concorrente", rotulo: "Concorrente", tipo: "text" },
]);
// Controle: outra etapa não cobra o mesmo campo.
const outra = validaCamposExigidos({
lead: { custom_fields: {} },
settingsDoFunil: settings,
destino: { stageId: ETAPA_OUTRA },
});
expect(outra.faltando).toEqual([]);
});
it("o valor preenchido passa — e vem do lead OU da escrita (overlay do diálogo)", () => {
const settings = settingsDeCampo({ obrigatorio_em: { etapas: [ETAPA_PROPOSTA] } });
const destino = { stageId: ETAPA_PROPOSTA };
// Preenchido no lead.
expect(
validaCamposExigidos({
lead: { custom_fields: { concorrente: "ACME" } },
settingsDoFunil: settings,
destino,
}).faltando,
).toEqual([]);
// O lead continua vazio, mas a escrita TRAZ o valor (o diálogo reenvia o
// move com `custom_fields`): a segunda tentativa tem de passar na MESMA
// régua que a primeira recusou.
expect(
validaCamposExigidos({
lead: { custom_fields: {} },
settingsDoFunil: settings,
destino,
customFieldsPropostos: { concorrente: "ACME" },
}).faltando,
).toEqual([]);
});
it("ao_perder só barra o fecho como perdido — movimento comum passa", () => {
const settings = settingsDeCampo({ obrigatorio_em: { ao_perder: true } });
const lead = { custom_fields: {} };
// Movimento sem fecho: não pergunta.
expect(
validaCamposExigidos({ lead, settingsDoFunil: settings, destino: { stageId: ETAPA_OUTRA } })
.faltando,
).toEqual([]);
// Fechou como perdido: pergunta.
expect(
validaCamposExigidos({
lead,
settingsDoFunil: settings,
destino: { stageId: ETAPA_OUTRA, desfecho: "lost" },
}).faltando,
).toEqual([{ chave: "concorrente", rotulo: "Concorrente", tipo: "text" }]);
// Fechou como ganho: o gatilho da perda não vale.
expect(
validaCamposExigidos({
lead,
settingsDoFunil: settings,
destino: { stageId: ETAPA_OUTRA, desfecho: "won" },
}).faltando,
).toEqual([]);
});
it("ao_ganhar barre o fecho como ganho, e um valor presente encerra a cobrança", () => {
const settings = settingsDeCampo({ obrigatorio_em: { ao_ganhar: true } });
expect(
validaCamposExigidos({
lead: { custom_fields: {} },
settingsDoFunil: settings,
destino: { stageId: ETAPA_OUTRA, desfecho: "won" },
}).faltando,
).toHaveLength(1);
expect(
validaCamposExigidos({
lead: { custom_fields: { concorrente: "ACME" } },
settingsDoFunil: settings,
destino: { stageId: ETAPA_OUTRA, desfecho: "won" },
}).faltando,
).toEqual([]);
});
it("false e 0 são RESPOSTA; string em branco e array vazio são ausência", () => {
const settings = {
fields: [
{ key: "aceita", label: "Aceita proposta", type: "boolean", obrigatorio_em: { ao_ganhar: true } },
{ key: "parcelas", label: "Parcelas", type: "number", obrigatorio_em: { ao_ganhar: true } },
{ key: "anexos", label: "Anexos", type: "multiselect", obrigatorio_em: { ao_ganhar: true } },
{ key: "obs", label: "Observação", type: "textarea", obrigatorio_em: { ao_ganhar: true } },
],
};
const destino = { stageId: ETAPA_OUTRA, desfecho: "won" as const };
// `false` e `0` passam: sem isto, um booleano exigido seria impossível de
// satisfazer e o zero de "parcelas" viraria "preencha" para sempre.
expect(
validaCamposExigidos({
lead: { custom_fields: { aceita: false, parcelas: 0, anexos: ["a"], obs: "ok" } },
settingsDoFunil: settings,
destino,
}).faltando,
).toEqual([]);
// Branco, array vazio e ausente continuam sendo ausência.
const falta = validaCamposExigidos({
lead: { custom_fields: { aceita: false, parcelas: 0, anexos: [], obs: " " } },
settingsDoFunil: settings,
destino,
}).faltando;
expect(falta.map((c) => c.chave)).toEqual(["anexos", "obs"]);
});
it("o motivo de ganho exigido e ausente entra como faltando `won_reason` — caso do MESMO contrato", () => {
const lead = { custom_fields: {} };
const destino = { stageId: ETAPA_OUTRA, desfecho: "won" as const };
// Opt-in desligado (padrão): nada muda.
expect(
validaCamposExigidos({ lead, settingsDoFunil: {}, destino }).faltando,
).toEqual([]);
// Opt-in ligado e sem motivo: faltando com a chave que a tela já sabe ler.
const falta = validaCamposExigidos({
lead,
settingsDoFunil: { won_reason_required: true },
destino,
}).faltando;
expect(falta).toEqual([{ chave: "won_reason", rotulo: "Motivo do ganho" }]);
// O motivo veio na escrita (rota `/win`): passa.
expect(
validaCamposExigidos({
lead,
settingsDoFunil: { won_reason_required: true },
destino,
motivoDeGanho: "Renovação anual",
}).faltando,
).toEqual([]);
// E o motivo que o lead JÁ tem também vale — reenvio idempotente.
expect(
validaCamposExigidos({
lead: { custom_fields: {}, won_reason: "Renovação anual" },
settingsDoFunil: { won_reason_required: true },
destino,
}).faltando,
).toEqual([]);
// O opt-in só fala do GANHO: fecho como perdido não herda a exigência.
expect(
validaCamposExigidos({
lead,
settingsDoFunil: { won_reason_required: true },
destino: { stageId: ETAPA_OUTRA, desfecho: "lost" },
}).faltando,
).toEqual([]);
});
});
describe("recusaDeCamposObrigatorios — a frase da recusa", () => {
it("nomeia os rótulos, na ordem dos campos, e carrega o código novo", () => {
const recusa = recusaDeCamposObrigatorios(
[
{ chave: "concorrente", rotulo: "Concorrente" },
{ chave: "data_prevista", rotulo: "Data prevista" },
],
"pt-BR",
);
expect(recusa.codigo).toBe("required_fields_missing");
expect(recusa.mensagem).toContain("Concorrente, Data prevista");
});
});
describe("recusaDeMotivoDoGanho — a lista do funil", () => {
it("sem lista cadastrada o motivo é texto livre (não há o que recusar)", () => {
expect(
recusaDeMotivoDoGanho({ motivo: "Qualquer coisa", settingsDoFunil: {}, idioma: "pt-BR" }),
).toBeNull();
expect(
recusaDeMotivoDoGanho({ motivo: null, settingsDoFunil: { won_reasons: ["X"] } }),
).toBeNull();
});
it("com lista, o que está nela passa e o que não está vira won_reason_invalid", () => {
const settings = { won_reasons: ["Expansão de contrato", "Renovação"] };
expect(
recusaDeMotivoDoGanho({ motivo: "Renovação", settingsDoFunil: settings, idioma: "pt-BR" }),
).toBeNull();
const recusa = recusaDeMotivoDoGanho({
motivo: "Mentira comercial",
settingsDoFunil: settings,
idioma: "pt-BR",
});
expect(recusa).toMatchObject({ codigo: "won_reason_invalid" });
expect(recusa?.mensagem).toContain("não está na lista");
});
});
describe("settingsDoFunil — fail-open documentado", () => {
it("lê o settings quando o funil responde", async () => {
const supabase = {
from: () => ({
select: () => ({
eq: () => ({
maybeSingle: async () => ({ data: { settings: { won_reason_required: true } }, error: null }),
}),
}),
}),
} as never;
await expect(settingsDoFunil(supabase, "33333333-3333-4333-8333-333333333333")).resolves.toEqual({
won_reason_required: true,
});
});
it("erro de consulta, exceção ou pipeline ausente devolvem null — nada exigido", async () => {
const comErro = {
from: () => ({
select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null, error: { message: "boom" } }) }) }),
}),
} as never;
await expect(settingsDoFunil(comErro, "33333333-3333-4333-8333-333333333333")).resolves.toBeNull();
// Exceção (dublê que não serve a tabela, rede caída): capturada, não propaga.
const queExige = { from: () => { throw new Error("tabela inesperada"); } } as never;
await expect(settingsDoFunil(queExige, "33333333-3333-4333-8333-333333333333")).resolves.toBeNull();
// Sem pipeline não há o que perguntar.
await expect(settingsDoFunil({} as never, null)).resolves.toBeNull();
void vi;
});
});
+268
View File
@@ -0,0 +1,268 @@
/**
* OS CAMPOS QUE O FUNIL EXIGE — a pergunta única de TODOS os caminhos que mudam
* etapa ou encerram (issue #1536).
*
* ─── O defeito que este arquivo fecha ──────────────────────────────────────
*
* `customFieldSchema.required` existia e não obrigava nada: aparecia como
* asterisco no editor de ficha e nenhum caminho de escrita o consultava. Dá
* para mover um negócio para "Proposta enviada" sem a data prevista, e fechá-lo
* como perdido sem dizer de quem — a métrica de ganho e de concorrência nasce
* vazia. O motivo da perda já tinha regra (issue #917, `motivo-da-perda.ts`);
* isto é a generalização daquele padrão para QUALQUER campo declarado.
*
* ─── Uma função, seis caminhos ─────────────────────────────────────────────
*
* `validaCamposExigidos` é a ÚNICA decide-exigência do servidor. Quem a chama:
*
* - `POST /api/v1/leads/[id]/move` (arrasto no quadro)
* - `POST /api/v1/leads/bulk` (movimento em lote)
* - `lib/leads/encerramento.ts` (botão ganhar/perder, `/win`, `/lose`,
* clone da origem, `crm_close_demand`,
* ação `create_or_move_lead` no fecho)
* - `moveLeadHandler` (MCP `crm_move_lead_stage`, ações)
*
* Duas respostas desta função divergiram no passado e por isso a decisão mora
* num módulo só: uma rota que exigia e outra que não exigia é o mesmo defeito
* da #917 com outro nome.
*
* ─── O que conta como preenchido ───────────────────────────────────────────
*
* Valor ausente é `undefined`, `null`, string em branco (depois de aparar) e
* array vazio. `false` e `0` são RESPOSTAS e passam — uma checkbox respondida
* "não" e um valor zero foram preenchidos; tratá-los como vazios faria um
* booleano exigido ser impossível de satisfazer. `NaN`/`""` de número caem na
* string/ausência conforme o valor gravado.
*
* ─── Compatibilidade ───────────────────────────────────────────────────────
*
* Um funil SEM `obrigatorio_em` num campo devolve `faltando: []` para qualquer
* destino — é a regressão que o critério de aceite nº 3 veda: o comportamento de
* hoje tem de sobreviver byte a byte. `required: true` (o asterisco antigo)
* NÃO entra nesta decisão: ele continua significando "destaque no formulário".
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { traduzir } from "@/lib/i18n/dicionario";
import { IDIOMA_PADRAO, type Idioma } from "@/lib/i18n/idiomas";
import { camposDoFunil } from "@/lib/leads/campos-do-funil";
import type { CustomFieldDef } from "@/lib/schemas/settings";
/**
* `crm_pipelines.settings` do funil do lead — a carga que TODOS os caminhos
* fazem antes de validar.
*
* ⚠️ FAIL-OPEN, e por decisão escrita: settings indisponível (erro de rede,
* dublê de teste que não serve a tabela, id malformado) devolve `null`, que
* valida como "nada exigido". A exigência é opt-in por configuração de funil,
* e uma janela sem ela deixa o sistema como era ANTES desta issue; derrubar o
* arrasto ou o encerramento porque a LEITURA das configurações falhou trocaria
* um dado incompleto por uma operação impossível — e o caminho do "nada mudou"
* é justamente o que o critério de aceite nº 3 manda preservar.
*
* Em erro de `error` (não-exceção) também não há 500 aqui: quem decide se a
* escrita pode acontecer é quem chama `validaCamposExigidos`, e esta função só
* traz o que o funil declarou.
*/
export async function settingsDoFunil(
supabase: SupabaseClient,
pipelineId: string | null | undefined,
): Promise<unknown> {
if (!pipelineId) return null;
try {
const { data, error } = await supabase
.from("crm_pipelines")
.select("settings")
.eq("id", pipelineId)
.maybeSingle();
if (error) return null;
return (data as { settings?: unknown } | null)?.settings ?? null;
} catch {
return null;
}
}
/** Um campo que a escrita pede e o lead não tem: chave + rótulo para a tela. */
export interface CampoFaltando {
chave: string;
rotulo: string;
/**
* O tipo do campo (`text`, `date`, `select`…) e as opções de um `select` —
* ADITIVO ao contrato `{chave, rotulo}` da issue: a tela do diálogo precisa
* dele para renderizar o input certo (uma data pede calendário, um select
* pede as opções cadastradas). Quem só lê `chave`/`rotulo` não muda nada.
*/
tipo?: CustomFieldDef["type"];
opcoes?: { value: string; label: string }[];
}
/** O veredito: lista vazia = pode escrever. */
export interface VereditoDeCampos {
faltando: CampoFaltando[];
}
/** O que a escrita faz com o negócio — os três gatilhos de exigência. */
export interface DestinoDaEscrita {
/** Etapa de destino da escrita (para `obrigatorio_em.etapas`). */
stageId?: string | null;
/** `won`/`lost` quando a escrita fecha o negócio; `null` quando só move. */
desfecho?: "won" | "lost" | null;
}
interface EntradaDeCampos {
lead: Record<string, unknown>;
/** `crm_pipelines.settings` cru (ou `null` — funil desconhecido = nada exigido). */
settingsDoFunil: unknown;
destino: DestinoDaEscrita;
/**
* O motivo de ganho que esta escrita traz (rota `/win`, `crm_close_demand`).
* Vem separado porque ele NÃO mora em `custom_fields`: é coluna própria
* (`crm_leads.won_reason`) e o lead recém-lido ainda não o tem.
*/
motivoDeGanho?: string | null;
/**
* Os valores que a escrita TRAZ junto (o diálogo de campos obrigatórios
* reenvia o move com eles — issue #1536). A validação olha o VALOR
* COMBINADO (`lead.custom_fields` + propostos), senão a segunda tentativa
* cairia na mesma recusa da primeira: o lead ainda não foi gravado.
*/
customFieldsPropostos?: Record<string, unknown> | null;
}
/** `undefined`/`null`/branco/array vazio são ausência; `false` e `0` são resposta. */
function valorPreenchido(valor: unknown): boolean {
if (valor === undefined || valor === null) return false;
if (typeof valor === "string") return valor.trim().length > 0;
if (Array.isArray(valor)) return valor.length > 0;
return true;
}
/** O `settings` do funil como record tolerante — lixo vira objeto vazio. */
function settingsComoRecord(settings: unknown): Record<string, unknown> {
return settings && typeof settings === "object" && !Array.isArray(settings)
? (settings as Record<string, unknown>)
: {};
}
/**
* O campo é exigido NESTE destino? — a régua de `obrigatorio_em`.
*
* `etapas` compara por ID (a mesma comparação que a tela faz ao montar o
* diálogo); `ao_ganhar`/`ao_perder` valem para a escrita que fecha o negócio.
* Um campo sem `obrigatorio_em` nunca é exigido — este é o caminho da
* compatibilidade, e é o mais comum: todo funil instalado hoje está aqui.
*/
function campoExigidoNoDestino(
campo: CustomFieldDef,
destino: DestinoDaEscrita,
): boolean {
const regra = campo.obrigatorio_em;
if (!regra) return false;
if (destino.stageId && regra.etapas?.includes(destino.stageId)) return true;
if (destino.desfecho === "won" && regra.ao_ganhar) return true;
if (destino.desfecho === "lost" && regra.ao_perder) return true;
return false;
}
/**
* Devolve o que FALTA para esta escrita poder acontecer — nunca decide se a
* escrita acontece (quem escreve é quem chama, e a recusa é 422 com esta lista
* em `details.faltando`).
*
* A lista sai em ordem de cadastro dos campos, com `chave` (o que o PATCH do
* dossiê espera) e `rotulo` (o que a pessoa lê): a tela monta o diálogo só com
* o que falta, e a IA devolve os dois ao modelo para ele perguntar ao cliente.
*/
export function validaCamposExigidos(entrada: EntradaDeCampos): VereditoDeCampos {
const faltando: CampoFaltando[] = [];
const settings = settingsComoRecord(entrada.settingsDoFunil);
const valores = {
...((entrada.lead.custom_fields as Record<string, unknown> | null | undefined) ?? {}),
...(entrada.customFieldsPropostos ?? {}),
};
for (const campo of camposDoFunil(settings)) {
if (!campoExigidoNoDestino(campo, entrada.destino)) continue;
if (valorPreenchido(valores[campo.key])) continue;
faltando.push({
chave: campo.key,
rotulo: campo.label,
tipo: campo.type,
...(campo.options ? { opcoes: campo.options } : {}),
});
}
// O MOTIVO DE GANHO NATIVO (issue #1536): coluna própria, lista própria,
// opt-in por funil. Sem `won_reason_required` no settings ele nunca entra —
// de novo, o caminho do "nada mudou" é o padrão.
if (
entrada.destino.desfecho === "won" &&
settings.won_reason_required === true &&
!valorPreenchido(entrada.motivoDeGanho ?? entrada.lead.won_reason)
) {
faltando.push({ chave: "won_reason", rotulo: "Motivo do ganho" });
}
return { faltando };
}
/**
* A recusa pronta, no idioma pedido — o texto que todas as rotas devolvem.
* A lista de faltando vai no `details`, nunca na frase: a frase é para quem
* lê no toast, o `details` é para quem programa contra a API.
*/
export function recusaDeCamposObrigatorios(
faltando: CampoFaltando[],
idioma?: Idioma | null,
): { codigo: "required_fields_missing"; mensagem: string } {
// Só o rótulo nativo tem tradução; o de campo personalizado é texto do funil.
const nomes = faltando
.map((c) =>
c.chave === "won_reason" ? traduzir("Motivo do ganho", idioma ?? IDIOMA_PADRAO) : c.rotulo,
)
.join(", ");
return {
codigo: "required_fields_missing",
mensagem: traduzir(
"Preencha os campos obrigatórios antes de continuar: {campos}.",
idioma ?? IDIOMA_PADRAO,
).replace("{campos}", nomes),
};
}
/**
* O motivo de ganho está no vocabulário do funil? — espelho de
* `recusaDeMotivoForaDoVocabulario`, com DUAS diferenças honestas:
*
* 1. Não há trigger para o ganho no banco (a CHECK `crm_leads_lost_reason_required`
* é só da perda), então quem aplica é esta função, no servidor.
* 2. Sem `settings.won_reasons` cadastrado o motivo é TEXTO LIVRE — a lista
* amplia quando existe, e não existe por padrão. Exigir lista vazia seria
* tornar `won_reason_required` impossível de satisfazer.
*
* Devolve `null` quando não há o que recusar (sem motivo, ou motivo aceito).
*/
export function recusaDeMotivoDoGanho(input: {
motivo?: string | null;
settingsDoFunil: unknown;
idioma?: Idioma | null;
}): { codigo: "won_reason_invalid"; mensagem: string } | null {
const motivo = (input.motivo ?? "").trim();
if (motivo.length === 0) return null;
const cadastrados = settingsComoRecord(input.settingsDoFunil).won_reasons;
if (!Array.isArray(cadastrados) || cadastrados.length === 0) return null;
const aceitos = new Set(
cadastrados.filter((v): v is string => typeof v === "string"),
);
if (aceitos.has(motivo)) return null;
return {
codigo: "won_reason_invalid",
mensagem: traduzir(
"Este motivo de ganho não está na lista do funil. Escolha um dos motivos cadastrados.",
input.idioma ?? IDIOMA_PADRAO,
),
};
}
+130
View File
@@ -30,16 +30,20 @@ type Row = Record<string, unknown>;
function makeDb({
leads = [],
stages = [],
pipelines = [],
updateError,
}: {
leads?: Row[];
stages?: Row[];
/** Linhas de `crm_pipelines` — é de lá que vem o `settings` do funil (#1536). */
pipelines?: Row[];
/** Simula o `error` que o Postgres devolveria no UPDATE de `crm_leads`. */
updateError?: { code?: string; message?: string };
} = {}) {
const tables: Record<string, Row[]> = {
crm_leads: leads,
crm_stages: stages,
crm_pipelines: pipelines,
crm_lead_activities: [],
};
const updates: Row[] = [];
@@ -264,6 +268,132 @@ describe("encerraDemanda", () => {
).rejects.toMatchObject({ code: "lost_reason_invalid", status: 422 });
});
// ── CAMPOS OBRIGATÓRIOS E MOTIVO DE GANHO (issue #1536) ────────────────────
//
// O caminho 3 dos SEIS (o botão ganhar/perder — e com ele `/win`, `/lose`,
// `crm_close_demand` e o fecho da ação, que todos passam por esta função).
const FUNIL_EXIGENTE = [
{
id: PIPELINE,
organization_id: ORG,
settings: {
won_reason_required: true,
won_reasons: ["Renovação"],
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { ao_perder: true },
},
],
},
},
];
it("fecho como PERDIDO sem o campo exigido: 422 required_fields_missing e nenhum update", async () => {
const db = makeDb({
leads: [baseLead({ custom_fields: {} })],
stages: baseStages(),
pipelines: FUNIL_EXIGENTE,
});
await expect(
encerraDemanda(db.client as never, ctx, {
leadId: LEAD,
desfecho: "lost",
motivo: "price",
}),
).rejects.toMatchObject({
code: "required_fields_missing",
status: 422,
details: { faltando: [{ chave: "concorrente", rotulo: "Concorrente" }] },
});
expect(db.updates).toEqual([]);
});
it("mesmo fecho com o campo preenchido passa (controle negativo do anterior)", async () => {
const db = makeDb({
leads: [baseLead({ custom_fields: { concorrente: "ACME" } })],
stages: baseStages(),
pipelines: FUNIL_EXIGENTE,
});
const result = await encerraDemanda(db.client as never, ctx, {
leadId: LEAD,
desfecho: "lost",
motivo: "price",
});
expect(result.lead).toMatchObject({ status: "lost" });
expect(db.updates[0]).toMatchObject({ lost_reason: "price" });
});
it("ganho com motivo exigido ausente: o `won_reason` é o faltando do MESMO contrato", async () => {
const db = makeDb({
leads: [baseLead()],
stages: baseStages(),
pipelines: FUNIL_EXIGENTE,
});
await expect(
encerraDemanda(db.client as never, ctx, { leadId: LEAD, desfecho: "won" }),
).rejects.toMatchObject({
code: "required_fields_missing",
status: 422,
details: { faltando: [{ chave: "won_reason", rotulo: "Motivo do ganho" }] },
});
expect(db.updates).toEqual([]);
});
it("ganho com o motivo exigido: grava won_reason NA MESMA escrita que fecha", async () => {
const db = makeDb({
leads: [baseLead()],
stages: baseStages(),
pipelines: FUNIL_EXIGENTE,
});
const result = await encerraDemanda(db.client as never, ctx, {
leadId: LEAD,
desfecho: "won",
motivo: "Renovação",
});
expect(result.lead).toMatchObject({ status: "won", won_reason: "Renovação" });
expect(db.updates[0]).toMatchObject({ won_reason: "Renovação", stage_id: WON_STAGE });
});
it("motivo de ganho fora da lista do funil: 422 won_reason_invalid antes do update", async () => {
const db = makeDb({
leads: [baseLead()],
stages: baseStages(),
pipelines: FUNIL_EXIGENTE,
});
await expect(
encerraDemanda(db.client as never, ctx, {
leadId: LEAD,
desfecho: "won",
motivo: "Mentira comercial",
}),
).rejects.toMatchObject({ code: "won_reason_invalid", status: 422 });
expect(db.updates).toEqual([]);
});
it("ganho SEM exigência e sem lista segue como hoje: fecha sem motivo nenhum (critério 3)", async () => {
const db = makeDb({
leads: [baseLead()],
stages: baseStages(),
pipelines: [{ id: PIPELINE, organization_id: ORG, settings: {} }],
});
const result = await encerraDemanda(db.client as never, ctx, {
leadId: LEAD,
desfecho: "won",
});
expect(result.lead).toMatchObject({ status: "won" });
expect(db.updates[0]).not.toHaveProperty("won_reason");
});
it("mantém 500 internal_error para um erro de banco que não é sobre o motivo da perda", async () => {
const db = makeDb({
leads: [baseLead()],
+75 -1
View File
@@ -26,6 +26,12 @@ import { audit } from "@/lib/audit";
import { traduzir } from "@/lib/i18n/dicionario";
import { emitLeadActivity } from "@/lib/leads/activity-emitter";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import {
recusaDeCamposObrigatorios,
recusaDeMotivoDoGanho,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import { recusaDeMotivoDaPerdaPeloBanco } from "@/lib/leads/motivo-da-perda";
/** Como a demanda terminou. Não há terceira: encerrar é ganhar ou perder. */
@@ -34,7 +40,12 @@ export type DesfechoDaDemanda = "won" | "lost";
export interface EncerraDemandaInput {
leadId: string;
desfecho: DesfechoDaDemanda;
/** OBRIGATÓRIO em `lost` (P-03): perder sem motivo não ensina nada a ninguém. */
/**
* OBRIGATÓRIO em `lost` (P-03): perder sem motivo não ensina nada a ninguém.
* Em `won` é o MOTIVO DE GANHO (issue #1536): vai para `crm_leads.won_reason`
* na mesma escrita, é texto livre quando o funil não tem lista e passa a ser
* exigido quando `settings.won_reason_required` está ligado.
*/
motivo?: string | null;
/**
* A razão da linha na timeline, quando o desfecho padrão ("Ganho" /
@@ -165,6 +176,60 @@ export async function encerraDemanda(
throw new ApiError(500, "internal_error", undefined, ctx.requestId, maxPositionErr.message);
}
// ── OS CAMPOS OBRIGATÓRIOS E O MOTIVO DE GANHO (issue #1536) ────────────────
//
// Esta função é o fecho compartilhado: botão "Marcar como ganho/perdido",
// `/win`, `/lose`, a origem do clone, `crm_close_demand` (MCP) e a ação
// `create_or_move_lead`. Validar AQUI é o que cobre cinco dos seis caminhos da
// issue com uma decisão só — e ela precisa vir ANTES do update, porque depois
// dele o que existe é a linha gravada.
//
// O motivo de ganho entra como `faltando` (chave `won_reason`) quando o funil
// pede e não veio: o MESMO contrato de `{chave, rotulo}` que a tela já sabe
// dialogar, em vez de um código paralelo que a tela teria de tratar à parte.
const settings = await settingsDoFunil(
supabase,
(lead as { pipeline_id: string }).pipeline_id,
);
const vereditoDeCampos = validaCamposExigidos({
lead: lead as Record<string, unknown>,
settingsDoFunil: settings,
destino: { stageId: stage.id, desfecho: input.desfecho },
motivoDeGanho: input.desfecho === "won" ? input.motivo ?? null : null,
});
if (vereditoDeCampos.faltando.length > 0) {
const recusa = recusaDeCamposObrigatorios(
vereditoDeCampos.faltando,
ctx.idioma,
);
throw new ApiError(
422,
recusa.codigo,
{ faltando: vereditoDeCampos.faltando },
ctx.requestId,
recusa.mensagem,
);
}
// Sem lista cadastrada o motivo de ganho é texto livre; com lista, só o que
// está nela passa — quem aplica é o servidor, porque para o GANHO não há
// trigger no banco (a CHECK da perda é `crm_leads_lost_reason_required`).
if (input.desfecho === "won") {
const recusaGanho = recusaDeMotivoDoGanho({
motivo: input.motivo,
settingsDoFunil: settings,
idioma: ctx.idioma,
});
if (recusaGanho) {
throw new ApiError(
422,
recusaGanho.codigo,
undefined,
ctx.requestId,
recusaGanho.mensagem,
);
}
}
const nextPosition =
maxPosition?.position_in_stage === null || maxPosition?.position_in_stage === undefined
? 1000
@@ -175,6 +240,12 @@ export async function encerraDemanda(
updated_at: new Date().toISOString(),
};
if (input.desfecho === "lost") patch.lost_reason = input.motivo;
// O ganho espelha a perda na MESMA escrita: `won_reason` só entra quando há
// motivo (a coluna é nullable e sem CHECK — a obrigatoriedade é do funil,
// decidida acima, nunca do banco).
if (input.desfecho === "won" && input.motivo?.trim()) {
patch.won_reason = input.motivo.trim();
}
const { error: updErr } = await supabase
.from("crm_leads")
@@ -262,6 +333,9 @@ export async function encerraDemanda(
from_stage_id: (lead as { stage_id: string }).stage_id,
to_stage_id: (stage as { id: string }).id,
...(input.desfecho === "lost" ? { lost_reason: input.motivo } : {}),
...(input.desfecho === "won" && input.motivo?.trim()
? { won_reason: input.motivo.trim() }
: {}),
},
});
+58 -4
View File
@@ -5,6 +5,11 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { logger } from "@/lib/logger";
import { emitLeadActivity, stageChangeReason } from "@/lib/leads/activity-emitter";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import {
recusaDeCamposObrigatorios,
settingsDoFunil,
validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
/**
* Move o card do lead para a etapa do funil que o tenant marcou como destino
@@ -36,8 +41,19 @@ export interface ResultadoDoMovimentoDeHandoff {
| "lead_nao_encontrado"
| "lead_fechado"
| "conflito_humano"
/**
* A etapa de handoff exige CAMPOS que o negócio não tem (issue #1536).
*
* A MESMA régua do arrasto e do agente (`validaCamposExigidos`), e o mesmo
* desenho da recusa: o card não anda, nada quebrou, e `detalhe` traz a
* frase com o que falta — é o que o chamador registra no log (aqui não há
* quadro aberto nem inbox: o handoff é best-effort e o rastro é o warn).
*/
| "campos_obrigatorios"
| "falha_de_escrita"
| "indisponivel";
/** Só quando `motivo` é `campos_obrigatorios` — o que falta, em frase. */
detalhe?: string;
}
export async function moverLeadParaEtapaDeHandoff(
@@ -52,7 +68,9 @@ export async function moverLeadParaEtapaDeHandoff(
): Promise<ResultadoDoMovimentoDeHandoff> {
const { data: lead, error: erroLead } = await admin
.from("crm_leads")
.select("id, pipeline_id, stage_id, contact_id, status")
// `custom_fields` e `won_reason` são lidos POR CAUSA da régua de campos
// obrigatórios (#1536): sem eles, `validaCamposExigidos` só veria ausência.
.select("id, pipeline_id, stage_id, contact_id, status, custom_fields, won_reason")
.eq("id", input.leadId)
.eq("organization_id", input.organizationId)
.maybeSingle();
@@ -73,6 +91,8 @@ export async function moverLeadParaEtapaDeHandoff(
stage_id: string;
contact_id: string | null;
status: string;
custom_fields: Record<string, unknown> | null;
won_reason: string | null;
};
// Negócio já fechado (ganho/perdido) não volta a se mexer por causa de um
@@ -83,7 +103,7 @@ export async function moverLeadParaEtapaDeHandoff(
const { data: etapaData, error: erroEtapa } = await admin
.from("crm_stages")
.select("id, name")
.select("id, name, is_won, is_lost")
.eq("pipeline_id", leadRow.pipeline_id)
.eq("slug", SLUG_ETAPA_HANDOFF)
.eq("is_archived", false)
@@ -100,7 +120,7 @@ export async function moverLeadParaEtapaDeHandoff(
if (!etapa && SLUG_ETAPA_HANDOFF.includes("-")) {
const { data: etapaLegada, error: erroLegada } = await admin
.from("crm_stages")
.select("id, name")
.select("id, name, is_won, is_lost")
.eq("pipeline_id", leadRow.pipeline_id)
.eq("slug", SLUG_ETAPA_HANDOFF.replace(/-/g, "_"))
.eq("is_archived", false)
@@ -120,12 +140,46 @@ export async function moverLeadParaEtapaDeHandoff(
if (!etapa) {
return { moveu: false, motivo: "sem_etapa_de_handoff" };
}
const etapaRow = etapa as { id: string; name: string };
const etapaRow = etapa as {
id: string;
name: string;
is_won?: boolean | null;
is_lost?: boolean | null;
};
if (leadRow.stage_id === etapaRow.id) {
return { moveu: false, motivo: "ja_esta_la" };
}
// ── A RÉGUA DE CAMPOS OBRIGATÓRIOS (issue #1536) ────────────────────────────
//
// O handoff também grava `stage_id`, então também passa pela régua — sem ela,
// este seria um dos caminhos que movem sem exigir nada e "uma rota exige,
// outra não" voltaria a ser o defeito da #917. A recusa segue o desenho do
// resto do arquivo: o card não anda, `motivo` diz a verdade e `detalhe` leva
// a frase com o que falta. O rastro é o warn DESTE módulo: o orquestrador
// não lê o retorno.
// Fail-open de `settingsDoFunil` vale aqui como em todos os caminhos.
const settings = await settingsDoFunil(admin, leadRow.pipeline_id);
const vereditoDeCampos = validaCamposExigidos({
lead: leadRow as unknown as Record<string, unknown>,
settingsDoFunil: settings,
destino: {
stageId: etapaRow.id,
desfecho: etapaRow.is_won ? "won" : etapaRow.is_lost ? "lost" : null,
},
});
if (vereditoDeCampos.faltando.length > 0) {
const detalhe = recusaDeCamposObrigatorios(vereditoDeCampos.faltando, null).mensagem;
// Nenhum chamador lê o retorno (só tratam exceção): este warn é o único rastro.
logger.warn("[handoff-stage-move] etapa exige campos; card não movido", {
lead_id: leadRow.id,
organization_id: input.organizationId,
detalhe,
});
return { moveu: false, motivo: "campos_obrigatorios", detalhe };
}
// Nome da origem só enfeita o texto da timeline — erro descartado de
// propósito, mesmo raciocínio de `agent-stage-sync.ts`.
const { data: origem } = await admin
+18
View File
@@ -479,6 +479,24 @@ export async function arquivarEtapa(
);
}
// ── POR QUE A RÉGUA DE CAMPOS OBRIGATÓRIOS (#1536) NÃO ENTRA AQUI ───────────
//
// Este UPDATE move N negócios de uma vez e é a ÚNICA porta de saída de uma
// etapa que está sendo arquivada (`validarArquivamento` recusa arquivar com
// negócio e sem destino). Aplicar `validaCamposExigidos` aqui seria decidir
// por N fichas diferentes, e a recusa não teria saída nenhuma: a tela de
// arquivamento não coleta campo de ficha, então o dono ficaria SEM COMO tirar
// a coluna do quadro — nem saberia qual dos cards travou a operação. Bloquear
// uma ação de CONFIGURAÇÃO por dado de ficha é decisão de produto nova, não
// conserto do buraco do #1536, e por isso fica registrado aqui em vez de
// imposto em silêncio (o CR do mantenedor aceita as duas saídas).
//
// O buraco em si fecha pelas portas de ENTRADA em etapa: arrasto, lote,
// botão ganhar/perder, MCP, agente, handoff e agendamento passam todos pela
// mesma régua, então o PRÓXIMO movimento destes cards — para uma etapa que
// exige — é coberto. A comparação "mesma etapa passa" também não vira buraco
// aqui: o destino deste UPDATE é SEMPRE outra etapa.
//
// ⚠️ OS NEGÓCIOS ANDAM PRIMEIRO. Arquivar antes de mover deixaria os cards
// apontando para uma coluna fora do quadro se a segunda escrita falhasse —
// sumiço silencioso, o pior desfecho possível aqui.
+21 -1
View File
@@ -21,7 +21,7 @@ import { z } from "zod";
import { updateLeadSchema } from "@/lib/schemas/leads";
import { crmUpdateLead } from "./leads";
import { crmMoveLeadStage, crmUpdateLead } from "./leads";
/** O mesmo caminho do handler: tira `lead_id`, entrega o resto ao schema. */
function comoOHandlerFaz(entrada: Record<string, unknown>) {
@@ -80,3 +80,23 @@ describe("crm_update_lead — campos personalizados do funil", () => {
expect(saida.custom_fields).toBeUndefined();
});
});
describe("crm_move_lead_stage — motivo de ganho (#1536)", () => {
it("declara won_reason no shape da tool (sem isto, o valor é descartado antes do schema)", () => {
const doShape = z.object(crmMoveLeadStage.inputSchema).parse({
lead_id: LEAD_ID,
to_stage_id: LEAD_ID,
won_reason: "Renovação anual",
});
expect(doShape).toMatchObject({ won_reason: "Renovação anual" });
});
it("segue aceitando sem won_reason — o movimento comum não muda", () => {
const doShape = z.object(crmMoveLeadStage.inputSchema).parse({
lead_id: LEAD_ID,
to_stage_id: LEAD_ID,
});
expect(doShape).not.toHaveProperty("won_reason");
});
});
+9
View File
@@ -281,6 +281,14 @@ const moveInputShape = {
to_stage_id: z.string().uuid(),
position_in_stage: z.number().finite().optional(),
reason: z.string().max(500).optional(),
/**
* O motivo do ganho, quando o destino fecha o negócio como ganho (issue #1536).
* Obrigatório só se o funil ligar `won_reason_required`; sem lista cadastrada
* o texto é livre. A recusa (`required_fields_missing` /
* `won_reason_invalid`) volta como erro da tool, e o modelo pergunta ao
* cliente ou passa para o humano — nunca move calado.
*/
won_reason: z.string().max(500).optional(),
};
export const crmMoveLeadStage: McpToolDefinition<typeof moveInputShape> = {
@@ -311,6 +319,7 @@ export const crmMoveLeadStage: McpToolDefinition<typeof moveInputShape> = {
to_stage_id: input.to_stage_id,
position_in_stage: input.position_in_stage,
reason: input.reason,
won_reason: input.won_reason,
},
);
return { lead };
+19
View File
@@ -32,6 +32,25 @@ export const moveLeadSchema = z.object({
* diferente da que o /lose devolve para o mesmo caso).
*/
lost_reason: z.string().max(500).optional(),
/**
* O motivo do ganho, quando a etapa de destino fecha o negócio como ganho
* (issue #1536). Espelho do `lost_reason`: quem decide se é obrigatório é o
* funil (`settings.won_reason_required`), e a decisão mora em
* `lib/leads/campos-exigidos.ts` — aqui só se aceita o campo, e um motivo em
* branco é tratado lá como ausente.
*/
won_reason: z.string().max(500).optional(),
/**
* Os campos que o diálogo de "campos obrigatórios" coletou (issue #1536).
*
* Entram NA MESMA escrita que muda a etapa — o mesmo desenho do
* `lost_reason` (#917): uma gravação separada teria janela (a etapa muda com
* o campo ainda vazio) e uma segunda janela de OCC (o PATCH mudaria o
* `updated_at` que o próprio arrasto acabou de usar). O servidor faz o merge
* com o que o lead já tem e valida o VALOR COMBINADO — é por isso que a
* segunda tentativa passa na mesma régua que a primeira recusou.
*/
custom_fields: z.record(z.string(), z.unknown()).optional(),
});
export type MoveLeadInput = z.infer<typeof moveLeadSchema>;
+30
View File
@@ -166,6 +166,27 @@ export const customFieldSchema = z.object({
"url",
]),
required: z.boolean().optional(),
/**
* QUANDO este campo passa a OBRIGAR (issue #1536).
*
* `required` continua com o significado antigo (destaca o campo no formulário);
* quem barra um movimento de etapa ou um encerramento é SÓ isto aqui — um funil
* sem `obrigatorio_em` se comporta exatamente como se comportava antes.
*
* - `etapas`: etapas do funil nas quais entrar já exige o valor preenchido;
* - `ao_ganhar`: exigido quando a escrita fecha o negócio como ganho;
* - `ao_perder`: exigido quando a escrita o deixa perdido.
*
* A decisão é do servidor (`lib/leads/campos-exigidos.ts`), não do schema: o
* schema só diz o que PODE ser exigido, e uma lista vazia significa "nunca".
*/
obrigatorio_em: z
.object({
etapas: z.array(z.string().uuid()).max(50).optional(),
ao_ganhar: z.boolean().optional(),
ao_perder: z.boolean().optional(),
})
.optional(),
options: z
.array(z.object({ value: z.string().min(1), label: z.string().min(1) }))
.optional(),
@@ -183,6 +204,15 @@ export const pipelineConfigPatchSchema = z.object({
.optional(),
fields: z.array(customFieldSchema).max(50).optional(),
lost_reasons: z.array(z.string().min(1).max(80)).max(50).optional(),
/**
* O MOTIVO DE GANHO por funil (issue #1536) — espelho de `lost_reasons`.
* Sem lista cadastrada o motivo é texto livre; com lista, só o que está nela
* passa (`recusaDeMotivoDoGanho`, o equivalente do ganho à CHECK que o banco
* já tem para a perda — para o ganho não há trigger, então quem aplica é aqui).
*/
won_reasons: z.array(z.string().min(1).max(80)).max(50).optional(),
/** Obrigatóriedade do motivo de ganho, opt-in por funil (padrão: não exigir). */
won_reason_required: z.boolean().optional(),
});
export type PipelineConfigPatch = z.infer<typeof pipelineConfigPatchSchema>;
+17
View File
@@ -38357,6 +38357,23 @@ alter table public.messages
comment on column public.messages.sent_on_behalf_of_user_id is
'Autoria "em nome de" (#1613, migration 0416): a PESSOA — membro ativo agent+ da organização — em nome de quem um token enviou esta mensagem. null em todo envio direto. Só a rota POST /api/v1/messages grava, e só com o escopo messages:on_behalf; o balão mostra "Fulano · via {token}" a partir de metadata.sent_on_behalf.';
-- ---- motivo de ganho nativo (migration 0420, issue #1536) ----
--
-- Coluna nova, nullable, sem backfill e sem policy nova — a RLS por organização
-- já cobre a linha de `crm_leads`. SEM CHECK e SEM trigger de propósito: a
-- obrigatoriedade é opt-in por funil (`settings.won_reason_required`) e o
-- vocabulário é `settings.won_reasons`, ambos decididos no servidor
-- (`lib/leads/campos-exigidos.ts`) — uma CHECK aqui tornaria o motivo exigido
-- para todo install, inclusive os que nunca cadastraram lista nenhuma. A CHECK
-- da PERDA (`crm_leads_lost_reason_required`) é de outra issue (#917) e não
-- muda. Idempotente porque o `update.sh` do clone re-executa este bloco a cada
-- atualização. Fica antes da varredura de `anon`, como todo apêndice novo.
alter table public.crm_leads
add column if not exists won_reason text;
comment on column public.crm_leads.won_reason is
'Motivo do ganho (issue #1536, migration 0420): por que este negócio foi fechado como ganho. null quando ninguém informou. Texto livre por padrão; settings.won_reasons do funil transforma em lista e settings.won_reason_required liga a obrigatoriedade — as duas decididas no servidor (lib/leads/campos-exigidos.ts), nunca por CHECK: o ganho não tinha exigência nenhuma antes e não pode ganhar uma para o install inteiro.';
-- ---- publicar agente com o provedor personalizado (migration 0418, #1642) ----
-- Para `custom`, o modelo é conferido na lista que o PRÓPRIO endpoint devolveu
-- (`models_available` da credencial da versão), não no catálogo global
@@ -0,0 +1,28 @@
-- 0420 · MOTIVO DE GANHO NATIVO (issue #1536)
--
-- O produto pedia "por que ganhamos" e não tinha onde guardar: `lost_reason`
-- existe desde sempre (CHECK `crm_leads_lost_reason_required` + trigger
-- `fn_validate_lost_reason_required`), e o ganho fechava mudo — a métrica de
-- ganho e de concorrência nascia vazia. Esta coluna é o espelho da perda, com
-- DUAS diferenças de desenho que já estão no texto do comentário:
--
-- 1 · NULLABLE E SEM CHECK. A obrigatoriedade do motivo de ganho é OPT-IN por
-- funil (`settings.won_reason_required`, decidida no servidor em
-- `lib/leads/campos-exigidos.ts`); uma CHECK no banco tornaria obrigatório
-- para TODO install, incluindo os que nunca cadastraram motivo nenhum —
-- e quebraria toda escrita de ganho existente num upgrade.
--
-- 2 · SEM FK E SEM trigger de vocabulário. A lista por funil
-- (`settings.won_reasons`) é validada em aplicação
-- (`recusaDeMotivoDoGanho`), porque não há trigger de GANHO no banco: a
-- CHECK/trigger da perda nasceu com a issue #917 e não existe irmã para o
-- ganho. Inventar uma aqui mudaria regra de escrita alheia nesta migration.
--
-- Idempotente (`add column if not exists`): o `update.sh` do clone re-executa o
-- apêndice inteiro do `baseline.sql` a cada atualização. Sem backfill — linha
-- antiga fica `null`, o mesmo "não sei por que ganhou" de hoje.
alter table public.crm_leads
add column if not exists won_reason text;
comment on column public.crm_leads.won_reason is
'Motivo do ganho (issue #1536, migration 0420): por que este negócio foi fechado como ganho. null quando ninguém informou. Texto livre por padrão; settings.won_reasons do funil transforma em lista e settings.won_reason_required liga a obrigatoriedade — as duas decididas no servidor (lib/leads/campos-exigidos.ts), nunca por CHECK: o ganho não tinha exigência nenhuma antes e não pode ganhar uma para o install inteiro.';
+1
View File
@@ -430,6 +430,7 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260925180000` | `0415_teto_de_tokens_ativos_por_organizacao` | **O teto de tokens ATIVOS por organização passa a ser do BANCO (issue #1448 — a issue mesma diz: "mitigar não é fechar").** `api_tokens` não tinha CHECK, trigger nem índice que limitasse QUANTOS tokens vivos uma organização mantém, então o teto por token da Spec 11 §7 (60/min) era multiplicável por quem já estava dentro — e o teto de ESCRITA (30/min, o que protege o número de WhatsApp) não tem agregado nenhum, enquanto o 600/min por organização do PR #1446 segura só a leitura. **Gatilho `trg_teto_de_tokens_ativos`** (BEFORE INSERT, função `fn_teto_de_tokens_ativos` `security definer` com `search_path` fixo e `revoke execute` das duas origens, molde da 0403): conta os tokens da organização que estão VIVOS — `revoked_at is null` e (`expires_at is null` ou ainda não vencido) — e recusa quando `>= 50`. **Contar só o vivo é o que preserva a rotação legítima:** revogar o antigo e emitir o novo passa sempre, e revogado/expirado libera espaço na hora. **`50` é o número escolhido e está em UM lugar só** (o corpo da função, na migration e no apêndice do baseline): ninguém mantém cinquenta integrações, e mesmo assim a mudança de produto futura é mexer uma constante — o limite chega ao usuário pela mensagem do próprio erro, que é quem o sabe. **Quem já está acima não é tocado:** o gatilho recusa só inserção, então instalação com mais de 50 tokens hoje segue funcionando e não emite mais nenhum (pergunta 3 da issue). **Os tokens efêmeros do runtime contam** de propósito — há marcador forjável por `prefix`/`name` (a policy de `api_tokens` é `for all` para admin, então PostgREST direto escreve qualquer coluna) e um teto com exceção seria teto com porta; com TTL de 300 s e revogação no fim do run, eles mal aparecem na contagem. **Erro próprio:** `raise exception` com SQLSTATE `PT409` (desenho do `PT404`/`PT422` da 0403) e mensagem PT-BR dizendo o limite e mandando **revogar um token não utilizado** para liberar espaço; a rota `POST /api/v1/settings/api-tokens` reconhece o código e devolve `409 api_token_teto_atingido` com a mensagem do banco — em vez do 500 genérico que devolveria —, e o `onError: showApiError` do `useCreateApiToken` já faz o toast chegar à tela. Código declarado em `lib/api/errors.ts`. Sem coluna, sem backfill, sem policy nova; apêndice idempotente no `baseline.sql` ANTES da varredura anon. Gates: `tests/unit/teto-de-tokens-ativos-da-organizacao.test.ts` (a tripla migration × apêndice × MANIFEST, o predicado que ignora revogados e expirados, o `>=` que recusa no teto+1, os revokes, o mapeamento `PT409 → 409` do emissor e o código declarado) e `tests/invariants/teto-de-tokens-ativos-da-organizacao.test.ts` (Postgres real: dentro do teto passa, no teto+1 recusa com a mensagem, revogado libera espaço, e a instalação acima do teto não é derrubada). |
| `20260925235500` | `0418_publicar_com_provedor_personalizado` | **Agente com o provedor personalizado passa a PUBLICAR (acompanhamento da 0413, #1642).** A `fn_publish_ai_agent_version` conferia o modelo só em `ai_models`, e nada escreve linha `custom` ali — o catálogo é GLOBAL e o endpoint é de cada empresa. Medido numa VPS real: credencial `custom` validada, rascunho com um modelo que o endpoint devolveu em `/models`, e todo "Publicar" respondendo `model_not_found`. **O que muda:** para `provider = 'custom'`, "o modelo existe" passa a ser conferido em `ai_provider_credentials.models_available` da credencial da versão (a lista que o próprio endpoint devolveu, na linha já conferida quanto a organização, ativa e validada); `custom` sem credencial própria segue `model_not_found`. Os provedores nativos seguem pela consulta ao catálogo, sem mudança. Escrever os modelos em `ai_models` foi recusado: o id servido pelo endpoint de uma empresa apareceria no catálogo da instalação inteira. `create or replace` da versão de 5 argumentos com a mesma assinatura e os mesmos grants (as de 3 e 4 só delegam). Sem coluna, sem dado tocado. Apêndice no `baseline.sql` antes da varredura anon. Gate: `tests/invariants/publicar-com-provedor-personalizado.test.ts`. |
| `20260925223000` | `0417_message_failed_vira_gatilho` | **`message.failed` passa a ser gatilho de verdade (issue #1614).** A 0239 colocou o tipo na lista de `fn_event_log_e_registro` — na época ele era registro, sem consumidor, e a linha nascia `done` para não parecer fila entupida (#753). Com a issue o tipo ganhou consumidor (`automationRulesHandler`, via `ENTIDADE_ESPERADA_POR_GATILHO`) e a lista virou armadilha: `fn_event_log_marca_registro` (BEFORE INSERT) trocaria `pending` por `done` antes de o drain selecionar, e o handler registrado nunca rodaria — sem erro e sem log, o mesmo modo de falha mudo que a 0239 combate. **A função é redefinida sem o tipo** (`create or replace`, mesma assinatura e mesmos grants; `revoke`/`grant` repetidos para o arquivo ficar autocontido). **Sem backfill de propósito:** as linhas antigas nasceram `done` como registro, e reprocessá-las faria a regra rodar hoje por uma falha de semanas atrás, com o estado do contato de agora. **No `baseline.sql` o corpo entra por EDIÇÃO NO LUGAR do bloco ÚNICO** (mesmo desenho da 0412), porque função criada depois da varredura de `anon` é reprovada pela cerca `varredura-anon-e-o-ultimo-bloco`. **Gate:** `tests/unit/evento-de-fato-nao-fica-pendente.test.ts` — a cobrança "tipo com consumidor na lista de registro" passa a ler a ÚLTIMA definição (a que o banco usa), já que a 0239 já rodou em toda instalação e não pode ser apagada. |
| `20260926035000` | `0420_motivo_de_ganho` | **Motivo de ganho nativo (issue #1536).** `crm_leads.won_reason text`, nullable, sem backfill e — deliberadamente — sem CHECK: a obrigatoriedade é opt-in por funil (`settings.won_reason_required`) e a lista é `settings.won_reasons`, as duas decididas no servidor em `lib/leads/campos-exigidos.ts`; uma CHECK tornaria o motivo exigido para todo install e quebraria a escrita de ganho existente num upgrade, enquanto a CHECK da perda (#917) fica intocada. Gravação na MESMA escrita que muda o estado (mesmo desenho do `lost_reason`), validação de vocabulário em aplicação porque não há trigger de ganho. Aparece em `crm_get_lead` e no envelope do webhook (`LEAD_PUBLIC_FIELDS`). Apêndice idempotente no `baseline.sql` antes da varredura anon. |
| `20260926022806` | `0419_rascunho_sugerido_por_integracao` | **Rascunho sugerido por integração, aberto por link e enviado só com clique (issue #1611).** Quem integra tinha duas saídas ruins para a mensagem que precisa sair de uma pessoa: enviar por token (a bolha diz `Sistema`, `sent_by_user_id` nulo e a IA não é silenciada como no envio humano) ou copiar-e-colar. A tabela guarda o TEXTO no servidor — `organization_id`, `conversation_id`, `body` (CHECK de 4096 caracteres, o mesmo teto do envio), `source`, `created_by_api_token_id`, `expires_at` (janela de 24h por padrão), `consumed_at` e `consumed_by_user_id` — e a caixa de entrada abre por `?rascunho=`: o `Composer` recebe o texto via `initialDraft` e mostra a origem, e o envio segue sendo clique humano (`sent_via='user'`, agente silenciado como sempre). RLS `tenant_isolation_conversation_drafts_all` (`for all`, organização nos dois sentidos) porque a leitura é da sessão do atendente e o consumo é um UPDATE da mesma sessão; a escrita da integração usa service role com `organization_id` programático na rota. **LGPD:** `trg_apagar_rascunhos_ao_anonimizar` (em `contacts`, na transição `is_anonymized false → true`, molde de `trg_redigir_tarefas_ao_anonimizar`) apaga os rascunhos das conversas do contato anonimizado — o `body` é texto escrito para a pessoa, e sem FK para `contacts` nem a cascata nem o invariante de cascata a enxergavam. `fn_apagar_rascunhos_do_contato_anonimizado` revogada de public/anon/authenticated. Apêndice no `baseline.sql` antes da varredura anon (é o que faz as travas do suporte cobrirem a tabela já na instalação). Guardado por `tests/unit/rascunho-sugerido.test.ts`, pela linha nova em `tests/invariants/rls-isolation.test.ts` e por `tests/invariants/lgpd-rascunho-do-contato-anonimizado.test.ts`. |
| `20260926040000` | `0422_skill_pointers_legados` | **A tela de Skills não cai quando a instalação veio do formato legado.** Instalações antigas guardavam o nome e a versão ativa em `slug`/`active_version_id`; o contrato atual lê `name`/`version_id`, e um ponteiro nulo fazia o PostgREST recusar a consulta inteira. A migration adiciona as colunas canônicas quando faltam, copia os valores legados sem apagar as colunas antigas, instala a FK para novas escritas e recompõe os índices únicos por organização/plataforma. Registros que não têm versão recuperável ficam fora da resposta da rota em vez de derrubar a tela. Aditiva, idempotente e sem remoção de dados. Gate de rota em `app/api/v1/ai/skills/route.test.ts` e prova de banco em `tests/invariants/skill-pointers-legados.test.ts`. |
| `20260926040100` | `0423_hardening_fn_espelha_nome` | **Hardening de função legada.** Algumas instalações trazem `fn_espelha_nome_e_name()` do CRM anterior, sem `search_path` fixo. A migration só quando a função existir fixa o caminho em `public, pg_temp`, eliminando a resolução de objetos influenciável pela sessão. Não altera dados nem cria a função em instalações novas. |
+2
View File
@@ -72,6 +72,8 @@ export function negocio(id: string, stageId: string, over: Partial<LeadRow> = {}
export interface PipelineRow {
id: string;
name: string;
/** settings do funil — é dele que `settingsDoFunil` lê (issue #1536). */
settings?: Record<string, unknown> | null;
slug: string;
description: string | null;
position: number;
+106 -1
View File
@@ -7,6 +7,11 @@ import {
sincronizaEstagioDoAgente,
type EstagioCandidato,
} from "@/lib/leads/agent-stage-sync";
import {
avisoDoEspelhoRecusado,
MIRROR_WARN_ONLY,
mirrorLeadStageToCrm,
} from "@/lib/agent-engine/edge/crm/move-lead-stage";
vi.mock("@/lib/leads/activity-emitter", async (orig) => ({
...(await orig<typeof import("@/lib/leads/activity-emitter")>()),
@@ -111,6 +116,8 @@ interface Cenario {
update: Resposta;
/** Erro devolvido pelo `emit_event` — o rastro pode falhar sem desfazer o movimento. */
rpcError?: { message: string } | null;
/** O `crm_pipelines.settings` que a régua de campos obrigatórios lê (#1536). */
funil?: Resposta;
}
const ORG = "org-1";
@@ -166,7 +173,12 @@ function fakeAdmin(c: Cenario, rpcs: ChamadaRpc[] = []) {
return b;
},
eq: () => b,
maybeSingle: () => Promise.resolve({ data: { name: "Primeiro contato" }, error: null }),
maybeSingle: () =>
Promise.resolve(
tabela === "crm_pipelines"
? (c.funil ?? { data: { name: "Funil" }, error: null })
: { data: { name: "Primeiro contato" }, error: null },
),
then(onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) {
const r = b._update
? b._select
@@ -298,3 +310,96 @@ describe("sincronizaEstagioDoAgente — o evento que aciona automação e follow
expect(r).toMatchObject({ moveu: true, motivo: "movido" });
});
});
/* ────────────────────────────────────────────────────────────────────────── */
/**
* A RÉGUA DE CAMPOS OBRIGATÓRIOS NO CAMINHO DO ASSISTENTE (CR do mantenedor, #1536).
*
* `agent-stage-sync.ts` grava `stage_id` direto — sem a pergunta aqui, o
* assistente seria o ÚNICO caminho do produto que move sem passar por
* `validaCamposExigidos`. O padrão testado é o MESMO da perda (#917) no
* próprio arquivo: não move, devolve o motivo e deixa o rastro (o espelho abre
* item de inbox com o que falta).
*
* `obrigatorio_em.etapas` é `z.string().uuid()` e `camposDoFunil` DESCARTA o
* campo quando o id não é UUID — por isso a etapa de destino deste cenário é
* um UUID, como na instalação real (os `s1`/`s2` dos dublês de cima não
* passariam do parse e a recusa nunca acenderia).
*/
describe("o assistente move pela MESMA régua dos outros caminhos (#1536)", () => {
beforeEach(() => vi.mocked(emitLeadActivity).mockClear());
const ETAPA_UUID = "99999999-9999-4999-8999-999999999999";
const stagesComUuid = STAGES.map((s) => (s.id === "s2" ? { ...s, id: ETAPA_UUID } : s));
const cenarioExigente = () =>
cenario({
stages: { data: stagesComUuid, error: null },
funil: {
data: {
settings: {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [ETAPA_UUID] },
},
],
},
},
error: null,
},
});
it("não move: devolve `campos_obrigatorios` com o que falta, e NADA é escrito", async () => {
const { r, eventos } = await sincronizaObservando(cenarioExigente());
expect(r).toMatchObject({ moveu: false, motivo: "campos_obrigatorios" });
expect(r.detalhe).toContain("Concorrente");
// Nem atividade na timeline nem evento: o card ficou onde estava.
expect(vi.mocked(emitLeadActivity)).not.toHaveBeenCalled();
expect(eventos).toHaveLength(0);
});
it("funil SEM a exigência continua movendo — a recusa não virou bloqueio geral", async () => {
const { r } = await sincronizaObservando(
cenario({ stages: { data: stagesComUuid, error: null } }),
);
expect(r).toMatchObject({ moveu: true, motivo: "movido" });
expect(vi.mocked(emitLeadActivity)).toHaveBeenCalledTimes(1);
});
it("o espelho vira aviso ACIONÁVEL: não é warn-only nem incidente", () => {
expect(MIRROR_WARN_ONLY.has("campos_obrigatorios" as never)).toBe(false);
const aviso = avisoDoEspelhoRecusado({
motivo: "campos_obrigatorios",
detalhe: "Preencha os campos obrigatórios antes de continuar: Concorrente.",
etapaDeDestino: "Proposta enviada",
});
expect(aviso).not.toBeNull();
expect(aviso!.body).toContain("Concorrente");
expect(aviso!.body).toContain("Proposta enviada");
// Nada quebrou: não é o aviso de incidente.
expect(aviso!.body).not.toContain("Reconcilie");
expect(aviso!.dedupe).toBe("kind_ref_e_titulo");
});
it("a passagem pelo espelho traduz o motivo — não cai no `motivo não traduzido`", async () => {
const sync = vi.fn(async () => ({
moveu: false as const,
motivo: "campos_obrigatorios" as const,
detalhe: "Preencha os campos obrigatórios antes de continuar: Concorrente.",
}));
const r = await mirrorLeadStageToCrm(
{} as never,
{ supabase: {} as never } as never,
{ tenantId: "org", leadId: "contato", toStage: "negotiating" as never },
{ sync: sync as never },
);
expect(r.ok ? null : r.reason).toBe("campos_obrigatorios");
expect(r.ok ? null : r.detail).toContain("Concorrente");
});
});
+56
View File
@@ -32,6 +32,8 @@ interface Cenario {
update: Resposta;
rpcError?: { message: string } | null;
etapaPorSlug?: (slug: string) => Resposta;
/** O `crm_pipelines.settings` que a régua de campos obrigatórios lê (#1536). */
funil?: Resposta;
}
function cenario(over: Partial<Cenario> = {}): Cenario {
@@ -75,6 +77,8 @@ function fakeAdmin(c: Cenario, rpcs: ChamadaRpc[] = []) {
return b;
},
maybeSingle: () => {
if (tabela === "crm_pipelines")
return Promise.resolve(c.funil ?? { data: ETAPA_ORIGEM, error: null });
if (tabela === "crm_leads") return Promise.resolve(c.lead);
if (b._eqKeys.includes("slug")) {
if (c.etapaPorSlug && b._slugVal) return Promise.resolve(c.etapaPorSlug(b._slugVal));
@@ -209,3 +213,55 @@ describe("moverLeadParaEtapaDeAgendamento", () => {
expect(vi.mocked(emitLeadActivity)).not.toHaveBeenCalled();
});
});
/* ── A RÉGUA DE CAMPOS OBRIGATÓRIOS NO AGENDAMENTO (CR do mantenedor, #1536) ─ */
/**
* Marcar um horário também grava `stage_id`: sem a pergunta aqui, a agenda
* seria a porta de trás da exigência declarada no funil. Mesmo desenho do
* arrasto — não move, devolve o motivo e o que falta em `detalhe`. A etapa de
* destino é UUID porque `obrigatorio_em.etapas` é `z.string().uuid()` e
* `camposDoFunil` descarta o campo quando o id não passa do parse.
*/
describe("moverLeadParaEtapaDeAgendamento e a régua de campos obrigatórios", () => {
beforeEach(() => vi.mocked(emitLeadActivity).mockClear());
const ETAPA_UUID = "99999999-9999-4999-8999-999999999999";
const destinoExigente = () =>
cenario({
etapaDestino: { data: { ...ETAPA_AGENDADO, id: ETAPA_UUID }, error: null },
funil: {
data: {
settings: {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [ETAPA_UUID] },
},
],
},
},
error: null,
},
});
it("não move: devolve `campos_obrigatorios` com o que falta, sem gravar nada", async () => {
const r = await mover(destinoExigente());
expect(r).toEqual({
moveu: false,
motivo: "campos_obrigatorios",
detalhe: expect.stringContaining("Concorrente"),
});
expect(vi.mocked(emitLeadActivity)).not.toHaveBeenCalled();
});
it("funil SEM a exigência segue movendo como sempre (fail-open)", async () => {
const r = await mover(
cenario({ etapaDestino: { data: { ...ETAPA_AGENDADO, id: ETAPA_UUID }, error: null } }),
);
expect(r).toEqual({ moveu: true, motivo: "movido" });
});
});
@@ -0,0 +1,259 @@
/**
* O CAMINHO 2 (LOTE) dos campos obrigatórios (issue #1536) — o irmão direto de
* `etapa-de-perda-no-lote.test.ts`, com a MESMA promessa para a régua nova.
*
* Contra o Route Handler REAL de `POST /api/v1/leads/bulk` (auth e Supabase
* mockados): um card sem o campo que a etapa de destino exige derruba a
* recusa ANTES de `fn_mover_leads_em_lote` — a função do banco é uma
* transação só ("move todos ou não move nenhum"), então quem não passa tem de
* ser NOMEADO, não movido em silêncio nem enterrado num 500.
*
* E o controle negativo: card preenchido passa e a função é chamada — sem ele,
* o teste anterior provaria só que qualquer coisa recusa.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { ROLE_RANK, type AuthUser, type Role } from "@/lib/auth/types";
vi.mock("@/lib/impersonate/support", () => ({ requireSupportWrite: vi.fn(async () => null) }));
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/audit", () => ({
audit: vi.fn(async () => undefined),
isServiceRoleConfigured: vi.fn(() => false),
}));
vi.mock("@/lib/leads/activity-emitter", () => ({
emitLeadActivity: vi.fn(async () => ({ ok: true })),
stageChangeReason: vi.fn(() => "razão"),
}));
import { POST } from "@/app/api/v1/leads/bulk/route";
const USER_ID = "11111111-1111-4111-8111-111111111111";
const ORG_ID = "22222222-2222-4222-8222-222222222222";
const PIPELINE_ID = "55555555-5555-4555-8555-555555555555";
const PROPOSTA_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const CARD_A = "44444444-4444-4444-8444-444444444444";
const CARD_B = "66666666-6666-4666-8666-666666666666";
const CAMPOS_DO_FUNIL = {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [PROPOSTA_ID] },
},
],
};
interface Estado {
/** `custom_fields` de cada card do lote. */
campos: Record<string, Record<string, unknown>>;
rpcChamado: boolean;
}
function clienteStub(estado: Estado) {
const leads = [CARD_A, CARD_B].map((id) => ({
id,
organization_id: ORG_ID,
tags: [],
stage_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
pipeline_id: PIPELINE_ID,
contact_id: "66666666-6666-4666-8666-666666666666",
lost_reason: null,
won_reason: null,
custom_fields: estado.campos[id] ?? {},
}));
return {
from: (tabela: string) => {
const b = {
_op: "select" as "select" | "update",
select: () => b,
update: () => ((b._op = "update"), b),
eq: () => b,
in: () => b,
is: () => b,
maybeSingle: () =>
// A etapa de destino E o settings do funil respondem por aqui.
Promise.resolve({
data:
tabela === "crm_pipelines"
? { settings: CAMPOS_DO_FUNIL }
: { id: PROPOSTA_ID, name: "Proposta enviada", is_lost: false, is_won: false },
error: null,
}),
then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) => {
void tabela;
return Promise.resolve({ data: leads, error: null }).then(onF, onR);
},
};
return b;
},
rpc(nome: string) {
if (nome !== "fn_mover_leads_em_lote") {
return Promise.resolve({ data: null, error: null });
}
estado.rpcChamado = true;
return Promise.resolve({
data: [CARD_A, CARD_B].map((id) => ({
lead_id: id,
from_stage_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
pipeline_id: PIPELINE_ID,
})),
error: null,
});
},
};
}
function adminStub() {
const chain = {
select: () => chain,
eq: () => chain,
is: () => chain,
maybeSingle: () => Promise.resolve({ data: null, error: null }),
then: (onF: (v: unknown) => unknown) => Promise.resolve({ data: null, error: null }).then(onF),
};
return { from: () => chain, rpc: () => Promise.resolve({ data: null, error: null }) };
}
function sessao(estado: Estado) {
const user: AuthUser = {
id: USER_ID,
email: "m@example.com",
full_name: null,
avatar_url: null,
is_platform_admin: false,
idioma: "pt-BR" as const,
organizations: [{ organization_id: ORG_ID, organization_name: "Org", role: "manager" as Role }],
};
vi.mocked(requireRole).mockImplementation(async (min: Role) => {
void min;
return { ok: true, user, org: { orgId: ORG_ID, name: "Org", role: "manager" as Role } };
});
vi.mocked(createClient).mockResolvedValue(clienteStub(estado) as never);
vi.mocked(createAdminClient).mockReturnValue(adminStub() as never);
}
function pedido() {
return new NextRequest("http://local/api/v1/leads/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "move",
lead_ids: [CARD_A, CARD_B],
params: { stage_id: PROPOSTA_ID },
}),
});
}
describe("mover o lote para uma etapa que exige campo (#1536)", () => {
let estado: Estado;
beforeEach(() => {
estado = { campos: {}, rpcChamado: false };
vi.clearAllMocks();
});
it("card sem o campo: 422 nomeando os cards e o que falta, e a função do banco NÃO roda", async () => {
sessao(estado);
const res = await POST(pedido());
expect(res.status).toBe(422);
const corpo = (await res.json()) as {
error?: {
code?: string;
details?: { lead_ids?: string[]; faltando?: Record<string, { chave: string }[]> };
};
};
expect(corpo.error?.code).toBe("required_fields_missing");
// Os DOIS cards estão sem o campo — a recusa nomeia todos, como o irmão
// da perda (#917) já fazia.
expect(corpo.error?.details?.lead_ids).toEqual([CARD_A, CARD_B]);
expect(corpo.error?.details?.faltando?.[CARD_A]).toEqual([
{ chave: "concorrente", rotulo: "Concorrente", tipo: "text" },
]);
expect(estado.rpcChamado).toBe(false);
});
it("preenchendo só UM card, a recusa continua — o lote é transação única", async () => {
estado.campos[CARD_A] = { concorrente: "ACME" };
sessao(estado);
const res = await POST(pedido());
expect(res.status).toBe(422);
const corpo = (await res.json()) as {
error?: { details?: { lead_ids?: string[] } };
};
// Quem não passa é listado; quem passa não é movido sozinho (transação).
expect(corpo.error?.details?.lead_ids).toEqual([CARD_B]);
expect(estado.rpcChamado).toBe(false);
});
it("todos preenchidos: o lote move (controle positivo — a régua não recusa tudo)", async () => {
estado.campos[CARD_A] = { concorrente: "ACME" };
estado.campos[CARD_B] = { concorrente: "BetaCorp" };
sessao(estado);
const res = await POST(pedido());
expect(res.status).toBe(200);
expect(estado.rpcChamado).toBe(true);
});
it("funil sem obrigatorio_em segue como hoje (controle do critério 3)", async () => {
// Sobrescreve o settings do funil: campo com required antigo, sem exigência.
const stub = clienteStub(estado);
const original = stub.from;
stub.from = ((tabela: string) => {
if (tabela === "crm_pipelines") {
const b = {
select: () => b,
eq: () => b,
// O caminho de reabertura (#1538) consulta os funis com .in() e espera
// as linhas — a fake precisa ser thenável senão `await` devolve o builder.
in: () => b,
then: (onF: (v: unknown) => unknown, onR?: (e: unknown) => unknown) =>
Promise.resolve({ data: [], error: null }).then(onF, onR),
maybeSingle: async () => ({
data: {
settings: {
fields: [
{ key: "concorrente", label: "Concorrente", type: "text", required: true },
],
},
},
error: null,
}),
};
return b;
}
return original(tabela);
}) as typeof stub.from;
vi.mocked(createClient).mockResolvedValue(stub as never);
const user: AuthUser = {
id: USER_ID,
email: "m@example.com",
full_name: null,
avatar_url: null,
is_platform_admin: false,
idioma: "pt-BR" as const,
organizations: [{ organization_id: ORG_ID, organization_name: "Org", role: "manager" as Role }],
};
vi.mocked(requireRole).mockImplementation(async () => ({
ok: true,
user,
org: { orgId: ORG_ID, name: "Org", role: "manager" as Role },
}));
const res = await POST(pedido());
expect(res.status).toBe(200);
expect(estado.rpcChamado).toBe(true);
});
});
@@ -0,0 +1,109 @@
/**
* O CANDIDATO AO GOLDEN SET NÃO GUARDA O TEXTO DO CLIENTE COMO ELE CHEGOU.
*
* Dois caminhos gravam candidato para curadoria humana em `GOLDEN_CANDIDATES_DIR`
* (fs em runtime, não a tool Write): o near-miss do matcher de skills
* (`recordSkillMissCandidates`) e a divergência classificador×modelo
* (`recordStageDivergenceCandidate`). O que sai é registro de dado do titular no
* disco do contêiner — fora do banco, e a cascata de anonimização da LGPD alcança
* o banco, não o disco.
*
* A régua é o `scrubMessage` (`lib/sentry/scrub.ts`), o MESMO redator da
* telemetria e do Jev: apaga CPF, telefone, e-mail e chave de API, e preserva o
* resto — o corpo da mensagem é o que a curadoria humana lê. O que este teste
* NÃO prova é anonimização: o corpo continua no arquivo, e o `lead_id` ao lado
* dele liga o registro ao contato. Tirar o texto daqui (tabela + retenção ou
* cascata) é o conserto inteiro, e não cabe neste caminho.
*
* O texto abaixo é INVENTADO — conversa de cliente não entra no repo.
*/
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { recordStageDivergenceCandidate } from '@/lib/agent-engine/agent/stage-classifier';
import { recordSkillMissCandidates } from '@/lib/agent-engine/agent/skills';
import type { Logger } from '@/lib/agent-engine/obs/logger';
import { scrubMessage } from '@/lib/sentry/scrub';
/** Texto de cliente INVENTADO, com os três dados que o redator tira. */
const TEXTO_DO_CLIENTE =
'Bom dia! Vocês fazem clareamento? Quanto custa? Me chama no (11) 98765-4321 ' +
'ou no ana.souza@exemplo.com, meu cpf é 123.456.789-09.';
const CPF = '123.456.789-09';
const TELEFONE = '98765-4321';
const EMAIL = 'ana.souza@exemplo.com';
const jobId = '9f1b0c2e-0000-4000-8000-000000000001';
const silencioso = { info: () => {}, warn: () => {}, error: () => {} } as unknown as Logger;
/** Um diretório novo por caso: nada é escrito no golden-candidates do repo (freeze do tree). */
function dirTemporario(): string {
return mkdtempSync(path.join(tmpdir(), 'candidato-golden-'));
}
/** Lê o ÚNICO candidato gravado — o nome do arquivo diz de qual dos dois caminhos ele veio. */
function lerCandidato(dir: string): { arquivo: string; signal: string } {
const arquivos = readdirSync(dir);
expect(arquivos).toHaveLength(1);
const arquivo = arquivos[0]!;
const registro = JSON.parse(readFileSync(path.join(dir, arquivo), 'utf8')) as { signal: string };
return { arquivo, signal: registro.signal };
}
/** O que o candidato NÃO pode carregar, em qualquer dos dois caminhos. */
function semDadoDireto(signal: string): void {
expect(signal).not.toContain(CPF);
expect(signal).not.toContain(TELEFONE);
expect(signal).not.toContain(EMAIL);
// e o sinal que a curadoria lê segue legível — o redator não é um truncador
expect(signal).toContain('clareamento');
expect(signal).toContain('Quanto custa');
}
describe('candidato ao golden set vai a disco redigido', () => {
it('near-miss de skill: o sinal gravado é o scrubMessage do repo', async () => {
const dir = dirTemporario();
await recordSkillMissCandidates(
dir,
{
tenantId: '0b1f7a2e-0000-4000-8000-000000000002',
leadId: '0b1f7a2e-0000-4000-8000-000000000003',
jobId,
signal: TEXTO_DO_CLIENTE,
candidates: [{ skill: 'objecao-preco', reason: 'probe_matched_without_hard_match' }],
},
silencioso,
);
const { arquivo, signal } = lerCandidato(dir);
expect(arquivo.startsWith('skill-miss_objecao-preco_')).toBe(true);
// valor exato: o mesmo que o redator da telemetria devolve para este texto
expect(signal).toBe(scrubMessage(TEXTO_DO_CLIENTE));
semDadoDireto(signal);
});
it('divergência de estágio: o sinal gravado é o scrubMessage do repo', async () => {
const dir = dirTemporario();
await recordStageDivergenceCandidate(
dir,
{
tenantId: '0b1f7a2e-0000-4000-8000-000000000002',
leadId: '0b1f7a2e-0000-4000-8000-000000000003',
jobId,
signal: TEXTO_DO_CLIENTE,
divergence: { suggested: 'qualifying', confirmed: 'contacted' },
},
silencioso,
);
const { arquivo, signal } = lerCandidato(dir);
expect(arquivo).toBe(`stage-divergence_${jobId}.json`);
expect(signal).toBe(scrubMessage(TEXTO_DO_CLIENTE));
semDadoDireto(signal);
});
});
@@ -60,7 +60,7 @@ const DEPOIS_DO_MOVE = "2026-09-15T12:00:01.000Z";
const DEPOIS_DA_ATIVIDADE = "2026-09-15T12:00:01.500Z";
/** Banco falso com a cascata real: gravar a atividade troca o `updated_at`. */
function bancoFalso(statusDepoisDoUpdate = "open") {
function bancoFalso(statusDepoisDoUpdate = "open", settingsDoFunil: unknown = null) {
const banco = { updatedAt: CARREGADO, stageId: ETAPA_A, status: "open" };
vi.mocked(emitLeadActivity).mockImplementation(async () => {
banco.updatedAt = DEPOIS_DA_ATIVIDADE;
@@ -76,9 +76,18 @@ function bancoFalso(statusDepoisDoUpdate = "open") {
status: banco.status,
lost_reason: null,
updated_at: banco.updatedAt,
custom_fields: {} as Record<string, unknown>,
won_reason: null,
});
const from = (tabela: string) => {
if (tabela === "crm_pipelines") {
const chain: Record<string, unknown> = {};
chain.select = () => chain;
chain.eq = () => chain;
chain.maybeSingle = async () => ({ data: { settings: settingsDoFunil }, error: null });
return chain;
}
if (tabela === "crm_stages") {
const chain: Record<string, unknown> = {};
chain.select = () => chain;
@@ -161,6 +170,48 @@ describe("moveLeadHandler", () => {
expect(devolvido.updated_at).toBe(DEPOIS_DA_ATIVIDADE);
});
// ── CAMPOS OBRIGATÓRIOS (issue #1536) ──────────────────────────────────────
//
// O caminho 4 dos SEIS: `crm_move_lead_stage` (MCP) e as ações de automação
// escrevem etapa por ESTE handler, então a recusa dele É a recusa da tool — o
// servidor MCP devolve `isError` com a frase, e o modelo pergunta ao cliente
// ou passa para o humano em vez de mover calado.
const CAMPOS_EXIGIDOS = {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [ETAPA_B] },
},
],
};
it("etapa que exige campo vazio: ApiError 422 required_fields_missing com o faltando", async () => {
await expect(
moveLeadHandler(bancoFalso("open", CAMPOS_EXIGIDOS) as never, ctx, LEAD, {
to_stage_id: ETAPA_B,
}),
).rejects.toMatchObject({
status: 422,
code: "required_fields_missing",
details: { faltando: [{ chave: "concorrente", rotulo: "Concorrente" }] },
});
});
it("funil sem obrigatorio_em continua movendo (controle do critério 3)", async () => {
const devolvido = (await moveLeadHandler(
bancoFalso("open", {
fields: [{ key: "concorrente", label: "Concorrente", type: "text", required: true }],
}) as never,
ctx,
LEAD,
{ to_stage_id: ETAPA_B },
)) as { stage_id: string };
expect(devolvido.stage_id).toBe(ETAPA_B);
});
it("o evento `lead.stage_changed` leva o status que o gatilho do UPDATE escreveu", async () => {
// O `status` do emit_event lia a RELEITURA. Com ela no fim, ler dali seria
// amarrar o evento à ordem da releitura — e o valor já está no retorno do
+57
View File
@@ -29,6 +29,8 @@ interface Cenario {
update: Resposta;
rpcError?: { message: string } | null;
etapaPorSlug?: (slug: string) => Resposta;
/** O `crm_pipelines.settings` que a régua de campos obrigatórios lê (#1536). */
funil?: Resposta;
}
function cenario(over: Partial<Cenario> = {}): Cenario {
@@ -77,6 +79,8 @@ function fakeAdmin(c: Cenario, rpcs: ChamadaRpc[] = []) {
return b;
},
maybeSingle: () => {
if (tabela === "crm_pipelines")
return Promise.resolve(c.funil ?? { data: ETAPA_ORIGEM, error: null });
if (tabela === "crm_leads") return Promise.resolve(c.lead);
if (b._eqKeys.includes("slug")) {
if (c.etapaPorSlug && b._slugVal) return Promise.resolve(c.etapaPorSlug(b._slugVal));
@@ -235,3 +239,56 @@ it("erro de banco na busca pelo slug legado é indisponibilidade, não 'sem_etap
const r = await mover(c);
expect(r).toEqual({ moveu: false, motivo: "indisponivel" });
});
/* ── A RÉGUA DE CAMPOS OBRIGATÓRIOS NO HANDOFF (CR do mantenedor, #1536) ──── */
/**
* O handoff também grava `stage_id`, então também pergunta a MESMA pergunta do
* arrasto (`validaCamposExigidos`). Sem esta prova o caminho ficaria de fora da
* "um teste por caminho" — e uma rota que exige, outra que não, é o defeito da
* #917 com outro nome. `obrigatorio_em.etapas` é UUID e `camposDoFunil`
* descarta o campo em id fora do formato, por isso a etapa de destino aqui é um
* UUID (os `s-handoff` dos dublês não passariam do parse).
*/
describe("moverLeadParaEtapaDeHandoff e a régua de campos obrigatórios", () => {
beforeEach(() => vi.mocked(emitLeadActivity).mockClear());
const ETAPA_UUID = "99999999-9999-4999-8999-999999999999";
const destinoExigente = () =>
cenario({
etapaDestino: { data: { ...ETAPA_HANDOFF, id: ETAPA_UUID }, error: null },
funil: {
data: {
settings: {
fields: [
{
key: "concorrente",
label: "Concorrente",
type: "text",
obrigatorio_em: { etapas: [ETAPA_UUID] },
},
],
},
},
error: null,
},
});
it("não move: devolve `campos_obrigatorios` com o que falta, sem gravar nada", async () => {
const r = await mover(destinoExigente());
expect(r).toEqual({
moveu: false,
motivo: "campos_obrigatorios",
detalhe: expect.stringContaining("Concorrente"),
});
expect(vi.mocked(emitLeadActivity)).not.toHaveBeenCalled();
});
it("funil SEM a exigência (ou settings ilegível = fail-open) segue movendo como sempre", async () => {
const r = await mover(
cenario({ etapaDestino: { data: { ...ETAPA_HANDOFF, id: ETAPA_UUID }, error: null } }),
);
expect(r).toEqual({ moveu: true, motivo: "movido" });
});
});