Merge PR #326 — normalizar o payload aninhado do Respondi

Contribuição externa do @matheuspedro360: o webhook do Respondi manda o payload aninhado (form + respondent.answers), o mapeador genérico não o reconhecia, e a captação devolvia 400 sem criar lead nenhum. O PR traz o normalizador, uma fixture real, 195 linhas de teste e um apêndice ao invariante de webhooks.

Triagem com 4 lentes e cético por achado: 13 achados sobreviveram, 4 mecânicos consertados aqui e 3 perguntas de desenho devolvidas sem bloquear. O que ele acertou e vale registrar: o organization_id vem só do token do path (plantamos um id falso em quatro lugares do corpo e o lead nasceu na org certa), type guard escrito à mão em vez de cast, e não tocou no crm_lead_activities.type — que é vocabulário aberto de propósito e teria quebrado o update.sh de todo clone.

Consertos de mantenedor: o external_id do fallback escapava do truncamento que o irmão faz na mesma função (btree recusa chave incompressível acima de ~2.669 bytes, medido em Postgres real); o slug de perguntas cortava em 60 chars e colidia em silêncio, contra a promessa 'NUNCA descartadas' do próprio módulo; e o caso 8 do teste cross-tenant não alcançava o ramo de busca por e-mail — sabotando o filtro de organização, a suíte inteira ficava verde. O caso 9 fecha esse ponto cego: com o filtro caído, exatamente 1 falha.

Entrou depois da v1.5.0 de propósito, para não ir dentro dela sem estar no texto. Abre o ciclo 1.6.0. Cinco checks obrigatórios verdes em bc97d2b6.
This commit is contained in:
Rafael Melgaço
2026-08-25 13:42:36 -03:00
committed by GitHub
7 changed files with 1110 additions and 8 deletions
+109 -8
View File
@@ -15,8 +15,16 @@ import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
import { createLeadHandler } from "@/app/api/v1/leads/_handler";
import { emitLeadActivity } from "@/lib/leads/activity-emitter";
import type { CreateLeadInput } from "@/lib/schemas";
import { mapInboundPayload, verifyInboundSignature, type FieldMap } from "@/lib/webhooks/inbound";
import {
buildContactConsentGrant,
isRespondiPayload,
mapRespondiPayload,
respondiLeadTitle,
type RespondiMapped,
} from "@/lib/webhooks/respondi";
import { origemDaPagina, registrarCaptacao } from "@/lib/webhooks/captacao";
import { ipDoClienteParaInet } from "@/lib/http/ip-do-cliente";
import { decryptWebhookSecret } from "@/lib/webhooks/secrets";
@@ -158,13 +166,37 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
attempts: 0,
});
// Respondi manda `{ form: {...}, respondent: { answers: {...} } }` — dois
// níveis aninhados que o mapeador genérico descarta por desenho (ele só lê
// chave de topo). Detecta a forma UMA vez aqui; `respondiMapped` alimenta
// tanto o idempotency key quanto o mapeamento de campos mais abaixo.
const respondiMapped: RespondiMapped | null = isRespondiPayload(payload)
? mapRespondiPayload(payload)
: null;
// Idempotência (spec §5): `external_id` é campo reservado do envio — quem
// integra via sistema (Zapier/n8n/loja) manda o ID único do disparo e o
// reenvio automático (retry por timeout) NUNCA duplica o lead. O índice
// uniq_crm_leads_org_source_external garante a corrida; aqui vai o fast-path.
// Respondi não manda `external_id` de topo — manda `respondent.respondent_id`
// aninhado; sem este fallback, um retry do Respondi (timeout, reenvio manual)
// criaria um segundo lead para a mesma resposta de formulário.
const externalIdRaw = payload["external_id"];
const externalId =
typeof externalIdRaw === "string" && externalIdRaw.trim() ? externalIdRaw.trim().slice(0, 255) : null;
typeof externalIdRaw === "string" && externalIdRaw.trim()
? externalIdRaw.trim().slice(0, 255)
// O MESMO corte do ramo acima. A assimetria era de uma linha e o desfecho
// não: `uniq_crm_leads_org_source_external` é btree, e btree recusa chave
// que não caiba em ~2.704 bytes. Medido em Postgres 17 real, com conteúdo
// INCOMPRESSÍVEL (o pglz comprime `repeat('a')` e mascara o limite): a
// partir de ~2.669 bytes o INSERT sai com sqlstate 54000, o handler
// devolve 500 e NENHUM lead entra.
//
// Não é alcançável pelo Respondi real — o `respondent_id` do formulário é
// uuid de 36 chars, ~60× abaixo do limiar. É higiene de simetria: dois
// ramos do mesmo `?:` produzindo a mesma coluna com regras diferentes é o
// tipo de coisa que só aparece quando alguém manda um corpo fabricado.
: (respondiMapped?.externalId?.slice(0, 255) ?? null);
const respondWithLead = (leadId: string): NextResponse => {
if (isForm && source.redirect_to) {
@@ -200,7 +232,11 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
// ferramenta apareceria na tela como uma linha sem nome nem telefone, e quem
// olha não distinguiria "reenvio do mesmo lead" de "formulário vazio".
// `mapInboundPayload` é puro, então adiantá-lo não muda desfecho nenhum.
const mapped = mapInboundPayload(externalId ? payloadForMapping : payload, fieldMap);
//
// O `respondiMapped ??` é do PR #326: sem ele o payload aninhado do Respondi
// volta a cair no mapeador genérico, que é o defeito que aquele PR conserta.
const mapped =
respondiMapped ?? mapInboundPayload(externalId ? payloadForMapping : payload, fieldMap);
if (!mapped.phone) {
const rawPhone = findRawPhoneIfUnnormalized(payload, fieldMap);
if (rawPhone) mapped.source_metadata.raw_phone = rawPhone;
@@ -261,6 +297,22 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
.is("is_merged_into", null)
.maybeSingle();
// uniq_contacts_org_email (baseline.sql) é um SEGUNDO índice único parcial,
// independente de uniq_contacts_org_phone — um INSERT pode colidir nele
// mesmo com telefone inédito (mesma pessoa manda e-mail repetido, telefone
// novo). email_normalized é coluna GERADA (`lower(trim(email))`), então a
// comparação replica exatamente essa normalização — não `email` bruto.
const selectActiveByEmail = (): ReturnType<typeof selectActiveByPhone> | null => {
if (!mapped.email) return null;
return admin
.from("contacts")
.select("id")
.eq("organization_id", source.organization_id)
.eq("email_normalized", mapped.email.trim().toLowerCase())
.is("is_merged_into", null)
.maybeSingle();
};
const { data: existing } = await selectActiveByPhone();
if (existing) {
contactId = existing.id as string;
@@ -274,16 +326,34 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
email: mapped.email,
source: "webhook",
source_metadata: { webhook_source_id: source.id, ...mapped.source_metadata },
// Consentimento explícito só quando o Respondi confirmou concessão.
// Recusa NUNCA vira concessão por omissão: sem esta chave o INSERT
// usa o DEFAULT da coluna (tudo null == não concedido), que já é o
// estado correto — a recusa fica registrada no lead (custom_fields
// + atividade na timeline), não fabricada aqui como consentimento.
...(respondiMapped?.consent.granted
? { consent: buildContactConsentGrant(respondiMapped.custom_fields.respondi_form_id ?? null) }
: {}),
})
.select("id")
.maybeSingle();
if (insertErr) {
if (insertErr.code === "23505") {
// Corrida: outro POST concorrente com o mesmo telefone novo já
// criou o contato entre o select e o insert. Re-seleciona o
// vencedor em vez de deixar o lead órfão.
const { data: winner } = await selectActiveByPhone();
contactId = (winner?.id as string | undefined) ?? undefined;
// Corrida OU duplicidade real: outro contato já existe com o mesmo
// TELEFONE (corrida clássica: dois POSTs concorrentes) ou com o
// mesmo E-MAIL (telefone inédito, e-mail repetido — o caso que
// órfãava o lead antes desta linha, achado em 2026-08-25 rodando os
// testes do fix do Respondi). Telefone primeiro — é o identificador
// mais confiável do produto; e-mail só como fallback, e só quando o
// payload realmente trouxe um.
const { data: winnerByPhone } = await selectActiveByPhone();
if (winnerByPhone) {
contactId = winnerByPhone.id as string;
} else {
const byEmail = selectActiveByEmail();
const { data: winnerByEmail } = byEmail ? await byEmail : { data: null };
contactId = (winnerByEmail?.id as string | undefined) ?? undefined;
}
} else {
logger.error("[webhooks.inbound] contact insert failed", {
webhookSourceId: source.id,
@@ -305,7 +375,9 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
} = {
pipeline_id: source.default_pipeline_id,
stage_id: source.default_stage_id,
title: mapped.name ?? mapped.phone ?? mapped.email ?? "Lead sem nome",
title: respondiMapped
? respondiLeadTitle(respondiMapped)
: (mapped.name ?? mapped.phone ?? mapped.email ?? "Lead sem nome"),
contact_id: contactId,
currency: "BRL",
tags: [],
@@ -375,6 +447,35 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
metadata: { webhook_source_id: source.id },
});
// Recusa de consentimento é sinal, não ausência de sinal (mesma regra da
// timeline pra veto/handoff): registrada aqui pra quem olha o dossiê saber
// POR QUE nenhuma automação de 1º toque disparou pra este lead — nunca pra
// autorizar contato, só pra deixar a recusa visível.
if (respondiMapped && !respondiMapped.consent.granted) {
const atividade = await emitLeadActivity(admin, {
organizationId: source.organization_id,
leadId: String(lead.id),
contactId: contactId ?? null,
type: "consent_declined",
sourceModule: "webhook",
sourceId: source.id,
actor: { type: "webhook_source", id: source.id },
reason: "Respondente não concedeu consentimento de contato no formulário Respondi.",
payload: {
webhook_source_id: source.id,
detected_via: respondiMapped.consent.detectedVia,
raw_answer: respondiMapped.consent.rawAnswer,
},
});
if (!atividade.ok) {
logger.error("[webhooks.inbound] consent_declined activity failed", {
webhookSourceId: source.id,
organizationId: source.organization_id,
leadId: String(lead.id),
error: atividade.error,
});
}
}
await registrarCaptacao(admin, {
...fonteDaCaptacao,
...origemDaCaptacao,
+8
View File
@@ -51,6 +51,13 @@ export type ActivityType =
| "followup_step_skipped"
| "demand_closed"
| "promise_unowned"
/**
* O respondente disse NÃO no formulário de captação (ex.: Respondi). A
* recusa é sinal, não ausência de sinal — sem linha na timeline, "por que
* ninguém mandou WhatsApp pra este lead" fica sem resposta visível, e é
* justamente esse silêncio que a automação de 1º toque precisa respeitar.
*/
| "consent_declined"
/**
* A TROCA DE COMANDO ENTRE PESSOAS. A ida e a volta IA↔humano já estavam aqui
* (`handoff_triggered`/`handoff_resolved`); assumir, transferir e liberar não
@@ -133,6 +140,7 @@ export const ACTIVITY_LABELS: Record<ActivityType, string> = {
// invisível na timeline — só existia em audit e event_log, que ninguém lê na
// tela — e o dossiê de um negócio fechado terminava sem dizer que fechou.
demand_closed: "Demanda encerrada",
consent_declined: "Consentimento de contato recusado no formulário",
// Rótulos com OBJETO, nunca verbo nu: "Liberou" sozinho não diz o quê, e numa
// clínica "liberar" é o que se faz com um exame. O resto do arquivo já segue
// essa régua ("Retorno agendado", "Demanda encerrada").
+195
View File
@@ -0,0 +1,195 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
buildContactConsentGrant,
isRespondiPayload,
mapRespondiPayload,
respondiLeadTitle,
type RespondiPayload,
} from "@/lib/webhooks/respondi";
import { mapInboundPayload } from "@/lib/webhooks/inbound";
/**
* Fixture sanitizada, mesma FORMA do payload real capturado de
* `webhook_events_log` em produção (2026-08-25) — nome/telefone/e-mail/
* empresa trocados por dado fictício. Ver PROMPT_AUDITORIA_ORQUESTRACAO.md.
*/
const FIXTURE = JSON.parse(
readFileSync("tests/fixtures/webhooks/respondi-imobiliario.json", "utf8"),
) as RespondiPayload;
describe("isRespondiPayload", () => {
it("reconhece a forma real do Respondi", () => {
expect(isRespondiPayload(FIXTURE)).toBe(true);
});
it("payload genérico (form HTML/Zapier) não é confundido com Respondi", () => {
expect(isRespondiPayload({ nome: "Ana", telefone: "11998765432" })).toBe(false);
});
it("respondent sem answers não é Respondi (evita falso positivo)", () => {
expect(isRespondiPayload({ form: {}, respondent: { status: "completed" } })).toBe(false);
});
it("form ou respondent como string não é Respondi", () => {
expect(isRespondiPayload({ form: "x", respondent: { answers: {} } })).toBe(false);
});
it("payload vazio/nulo não quebra", () => {
expect(isRespondiPayload({})).toBe(false);
expect(isRespondiPayload(null)).toBe(false);
expect(isRespondiPayload("string")).toBe(false);
});
});
describe("mapRespondiPayload — o bug real (payload aninhado)", () => {
it("nome/telefone/e-mail saem de dentro de respondent.answers, não do topo", () => {
const m = mapRespondiPayload(FIXTURE);
expect(m.name).toBe("Maria Exemplo");
expect(m.phone).toBe("+5515988887777"); // "55 15988887777" normalizado
expect(m.email).toBe("maria.exemplo@example.com");
});
it("o mapeador GENÉRICO, no mesmo payload, não acha nada (é o bug medido em produção)", () => {
const generico = mapInboundPayload(FIXTURE as unknown as Record<string, unknown>);
expect(generico.name).toBeNull();
expect(generico.phone).toBeNull();
expect(generico.email).toBeNull();
});
it("mapeia os campos de negócio pedidos pro custom_fields", () => {
const m = mapRespondiPayload(FIXTURE);
expect(m.custom_fields).toMatchObject({
company_name: "Exemplo Incorporadora",
instagram_or_site: "@exemploincorporadora",
city_state: "Sorocaba - SP",
segment: "Incorporadora",
role: "Diretor(a) ou gestor(a)",
monthly_revenue_range: "De R$ 30 mil a R$ 50 mil",
current_marketing_structure: "Fazemos internamente, mas sem processo definido",
current_marketing_investment: "De R$ 5 mil a R$ 10 mil",
viable_investment_range: "De R$ 4 mil a R$ 7 mil por mês",
commercial_challenge: "Aumentar a conversão da equipe comercial",
start_timeline: "Mais adiante",
respondi_score: "55",
respondi_status: "completed",
respondi_form_id: "9FiY9mrO",
respondi_form_name: "Imobiliárias e Incorporadoras",
respondi_respondent_id: "00000000-0000-4000-8000-000000000001",
});
});
it("identificador do envio vira external_id prefixado (idempotência)", () => {
const m = mapRespondiPayload(FIXTURE);
expect(m.externalId).toBe("respondi:00000000-0000-4000-8000-000000000001");
});
it("consentimento concedido: detecta via legaltext estrutural, não via texto em PT", () => {
const m = mapRespondiPayload(FIXTURE);
expect(m.consent).toMatchObject({ granted: true, detectedVia: "legaltext" });
expect(m.custom_fields.consent_marketing_status).toBe("granted");
});
it("título do card prioriza empresa + contato", () => {
const m = mapRespondiPayload(FIXTURE);
expect(respondiLeadTitle(m)).toBe("Exemplo Incorporadora — Maria Exemplo");
});
});
describe("mapRespondiPayload — campos ausentes (Respondi não manda tudo sempre)", () => {
const semEmpresaNemUtm: RespondiPayload = {
form: { form_id: "abc123", form_name: "Form sem empresa" },
respondent: {
status: "completed",
score: 10,
answers: {
"Qual é o seu nome?": "João Sozinho",
"Qual é o melhor WhatsApp para falarmos sobre essa análise?": "55 11988887777",
},
raw_answers: [],
respondent_id: "resp-sem-empresa-1",
},
};
it("sem nome de empresa: título cai pro nome da pessoa, nunca um rótulo genérico fixo", () => {
const m = mapRespondiPayload(semEmpresaNemUtm);
expect(m.companyName).toBeNull();
expect(respondiLeadTitle(m)).toBe("João Sozinho");
});
it("sem sinal de consentimento nenhum: recusa por padrão, nunca concessão por omissão", () => {
const m = mapRespondiPayload(semEmpresaNemUtm);
expect(m.consent).toMatchObject({ granted: false, detectedVia: "not_found", rawAnswer: null });
});
it("pergunta nova, sem alias conhecido, não é descartada — vira custom_field derivado", () => {
const payload: RespondiPayload = {
form: { form_id: "novo", form_name: "Form com pergunta nova" },
respondent: {
status: "completed",
answers: {
"Qual é o seu nome?": "Ana Nova",
"Uma pergunta que ainda não existia quando este código foi escrito?": "resposta X",
},
respondent_id: "resp-pergunta-nova",
},
};
const m = mapRespondiPayload(payload);
const derivedKey = Object.keys(m.custom_fields).find((k) => k.startsWith("respondi_q_"));
expect(derivedKey).toBeDefined();
expect(derivedKey).toMatch(/^respondi_q_uma_pergunta_que_ainda_nao_existia/);
expect(m.custom_fields[derivedKey!]).toBe("resposta X");
});
it("respondent_id ausente: external_id null (não idempotente, mas não quebra)", () => {
const payload: RespondiPayload = {
form: { form_id: "x" },
respondent: { answers: { "Qual é o seu nome?": "Sem ID" } },
};
const m = mapRespondiPayload(payload);
expect(m.externalId).toBeNull();
});
});
describe("mapRespondiPayload — consentimento recusado (decisão 10: nunca vira concessão)", () => {
it("legaltext com resposta negativa: granted=false, mas a resposta crua fica registrada", () => {
const payload: RespondiPayload = {
form: { form_id: "f1" },
respondent: {
answers: { "Qual é o seu nome?": "Recusou Silva" },
raw_answers: [
{
answer: "no",
question: { question_id: "x1", question_type: "legaltext", question_title: "Autorização de contato" },
},
],
respondent_id: "resp-recusa-1",
},
};
const m = mapRespondiPayload(payload);
expect(m.consent).toMatchObject({ granted: false, detectedVia: "legaltext", rawAnswer: "no" });
expect(m.custom_fields.consent_marketing_status).toBe("declined");
});
it("fallback por texto em PT quando não há raw_answers (form antigo/sem essa coluna)", () => {
const payload: RespondiPayload = {
form: { form_id: "f1" },
respondent: {
answers: {
"Qual é o seu nome?": "Aceitou Sem Raw",
"Autorização de contato": "Aceito",
},
},
};
const m = mapRespondiPayload(payload);
expect(m.consent).toMatchObject({ granted: true, detectedVia: "text_label" });
});
});
describe("buildContactConsentGrant", () => {
it("preenche marketing e deixa transactional/profiling explicitamente nulos", () => {
const grant = buildContactConsentGrant("form-123");
expect(grant.marketing.source).toBe("webhook:respondi");
expect(grant.marketing.version).toBe("form-123");
expect(grant.marketing.granted_at).toBeTruthy();
expect(grant.transactional).toEqual({ granted_at: null, source: null, version: null });
expect(grant.profiling).toEqual({ granted_at: null, source: null, version: null });
});
});
+299
View File
@@ -0,0 +1,299 @@
/**
* Normalizador específico do Respondi — o form builder por trás da fonte
* "Respondi — Decola Aí Imobiliário".
*
* ═══ O BUG, MEDIDO ═══
*
* O payload real do Respondi tem dois níveis de aninhamento — `{ form: {...},
* respondent: { answers: {...} } }` — e `mapInboundPayload` (inbound.ts) só
* olha CHAVE DE TOPO; objeto aninhado é descartado por desenho ("v1", ver o
* comentário na função). Resultado: `nameHit`/`phoneHit`/`emailHit` batiam
* `null` para os TRÊS, a rota devolvia 400 "Nenhum campo mapeável" — depois
* de já ter gravado a linha em `webhook_events_log` (por isso "Últimos
* recebimentos" mostrava ponto verde: aquele insert roda ANTES do mapeamento
* e não sabe se o resto vai falhar). Nenhum contato, nenhum card. Medido
* lendo `webhook_events_log` de produção em 2026-08-25 (dois envios reais,
* mesma forma, zero lead criado) — ver PROMPT_AUDITORIA_ORQUESTRACAO.md.
*
* ═══ ESTE MÓDULO NÃO SUBSTITUI O GENÉRICO ═══
*
* A rota chama `isRespondiPayload` primeiro; só quando bate, usa
* `mapRespondiPayload`. Qualquer outro envio (form HTML solto, Zapier, n8n,
* o botão interno "Enviar lead de teste") segue `mapInboundPayload`
* inalterado — é o requisito explícito de preservar o comportamento atual.
*/
import type { MappedLead } from "@/lib/webhooks/inbound";
import { normalizePhoneBR } from "@/lib/webhooks/inbound";
interface RespondiRawAnswer {
answer: unknown;
question: { question_id?: unknown; question_type?: unknown; question_title?: unknown };
}
export interface RespondiPayload {
form: { form_id?: unknown; form_name?: unknown };
respondent: {
date?: unknown;
score?: unknown;
status?: unknown;
answers: Record<string, unknown>;
raw_answers?: unknown;
respondent_id?: unknown;
};
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/**
* True só quando o payload tem a forma exata que o Respondi manda:
* `form` e `respondent.answers` como objetos. Qualquer outra coisa (payload
* genérico, `{form: "texto"}`, `respondent` sem `answers`) devolve `false` e
* a rota cai no caminho de sempre — detecção deliberadamente estrita para
* nunca capturar por engano um payload de outra origem.
*/
export function isRespondiPayload(payload: unknown): payload is RespondiPayload {
if (!isRecord(payload)) return false;
const { form, respondent } = payload;
return isRecord(form) && isRecord(respondent) && isRecord(respondent.answers);
}
/**
* Um campo canônico por chave — cada um com a lista de textos de pergunta
* conhecidos (mesmo padrão de `DEFAULT_FIELD_MAP` em inbound.ts: lista, não
* string única, pra aceitar variante sem tocar código). Hoje só tem o texto
* exato do form "Imobiliárias e Incorporadoras" (form_id 9FiY9mrO). Se a
* Decola AÍ reusar este normalizador noutro form do Respondi com perguntas
* reescritas, adicione a variante na lista — não crie um segundo arquivo.
*/
type RespondiField =
| "name"
| "phone"
| "email"
| "company_name"
| "instagram_or_site"
| "city_state"
| "segment"
| "role"
| "monthly_revenue_range"
| "current_marketing_structure"
| "current_marketing_investment"
| "viable_investment_range"
| "commercial_challenge"
| "start_timeline"
| "consent";
const RESPONDI_QUESTION_ALIASES: Record<RespondiField, string[]> = {
name: ["Qual é o seu nome?"],
phone: ["Qual é o melhor WhatsApp para falarmos sobre essa análise?"],
email: ["Qual é o seu melhor e-mail?"],
company_name: ["Qual é o nome da sua empresa?"],
instagram_or_site: ["Qual é o Instagram ou site da empresa?"],
city_state: ["Em qual cidade e estado sua empresa atua?"],
segment: ["Qual é o seguimento da sua empresa?", "Qual é o segmento da sua empresa?"],
role: ["Qual é a sua função na empresa?"],
monthly_revenue_range: ["Qual é o faturamento médio mensal da empresa?"],
current_marketing_structure: ["Como sua empresa faz marketing e atendimento hoje?"],
current_marketing_investment: ["Quanto sua empresa investe atualmente em marketing por mês?"],
viable_investment_range: [
"Considerando estratégia, tecnologia, atendimento e mídia, qual faixa de investimento seria viável para sua empresa crescer?",
],
commercial_challenge: ["Hoje, qual é o principal desafio comercial da sua empresa?"],
start_timeline: ["Quando você pretende começar a melhorar essa operação?"],
consent: ["Autorização de contato"],
};
/** Campos que viram custom_fields diretamente (fora nome/telefone/email/consentimento, que têm destino próprio). */
const CUSTOM_FIELD_KEYS: Exclude<RespondiField, "name" | "phone" | "email" | "consent">[] = [
"company_name",
"instagram_or_site",
"city_state",
"segment",
"role",
"monthly_revenue_range",
"current_marketing_structure",
"current_marketing_investment",
"viable_investment_range",
"commercial_challenge",
"start_timeline",
];
/**
* O comentário deste módulo promete que pergunta sem alias é "NUNCA descartada".
* Um corte cego em 60 caracteres descartava: duas perguntas longas com os mesmos
* 60 primeiros caracteres do slug colidem no `Record`, e a última sobrescreve a
* primeira — sem erro, sem log, sem nada na tela.
*
* Medido: divergindo no caractere 60 saem 2 chaves; no 61, sai 1. Hoje é
* LATENTE — as 15 perguntas do formulário real têm alias em
* `RESPONDI_QUESTION_ALIASES` e nenhuma chega aqui —, mas o dia em que alguém
* acrescenta duas perguntas parecidas no Respondi é justamente o dia em que
* este caminho passa a valer, e é para ele que a promessa foi escrita.
*
* O sufixo só aparece quando o título de fato excede o corte, então nenhuma
* chave existente muda de nome. São 8 caracteres de um hash não-criptográfico
* (FNV-1a) do título COMPLETO: não precisa resistir a adversário, precisa
* distinguir dois títulos que compartilham prefixo.
*/
function slugifyQuestion(title: string): string {
const base = title
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
if (base.length <= 60) return base;
return `${base.slice(0, 60)}_${hashCurto(title)}`;
}
/** FNV-1a de 32 bits, em hex. Distinguir prefixo igual, não resistir a ataque. */
function hashCurto(s: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i += 1) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
export type RespondiConsentSource = "legaltext" | "text_label" | "not_found";
export interface RespondiConsent {
granted: boolean;
rawAnswer: string | null;
detectedVia: RespondiConsentSource;
}
const AFFIRMATIVE = /^(yes|true|sim|aceito|autorizo|concordo)\b/i;
/**
* Consentimento: prioriza o SINAL ESTRUTURAL do Respondi (`question_type ===
* "legaltext"` em `raw_answers`, valor booleano-ish "yes"/"no") sobre o texto
* em português do rótulo da pergunta — o rótulo pode ser reescrito no editor
* do form sem quebrar nada; o `question_type` não muda.
*
* Silêncio (campo ausente, resposta vazia, texto não reconhecido) NUNCA vira
* concessão — a leitura padrão é recusa, porque autorizar contato automático
* por engano é o lado caro do erro.
*/
function extractConsent(respondent: RespondiPayload["respondent"]): RespondiConsent {
const rawAnswers = Array.isArray(respondent.raw_answers) ? respondent.raw_answers : [];
const legaltext = rawAnswers.find(
(r): r is RespondiRawAnswer =>
isRecord(r) && isRecord(r.question) && r.question.question_type === "legaltext",
);
if (legaltext) {
const raw = typeof legaltext.answer === "string" ? legaltext.answer : JSON.stringify(legaltext.answer);
const granted = typeof legaltext.answer === "string" && AFFIRMATIVE.test(legaltext.answer.trim());
return { granted, rawAnswer: raw, detectedVia: "legaltext" };
}
for (const alias of RESPONDI_QUESTION_ALIASES.consent) {
const v = respondent.answers[alias];
if (typeof v === "string" && v.trim()) {
return { granted: AFFIRMATIVE.test(v.trim()), rawAnswer: v.trim(), detectedVia: "text_label" };
}
}
return { granted: false, rawAnswer: null, detectedVia: "not_found" };
}
export interface RespondiMapped extends MappedLead {
externalId: string | null;
consent: RespondiConsent;
companyName: string | null;
}
export function mapRespondiPayload(payload: RespondiPayload): RespondiMapped {
const { respondent, form } = payload;
const answers = respondent.answers;
const pick = (field: RespondiField): string | null => {
for (const alias of RESPONDI_QUESTION_ALIASES[field]) {
const v = answers[alias];
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
};
const custom_fields: Record<string, string> = {};
for (const field of CUSTOM_FIELD_KEYS) {
const v = pick(field);
if (v !== null) custom_fields[field] = v;
}
// Perguntas do form sem alias conhecido: NUNCA descartadas (diferente do
// genérico) — entram sob uma chave derivada do próprio texto, pra
// sobreviver a uma pergunta nova adicionada no Respondi sem exigir deploy
// antes de captar. Nenhuma delas começa com "utm_" nos envios reais
// observados (Respondi não manda UTM neste payload); se um dia mandar,
// cai em source_metadata como o resto do produto espera.
const matchedTitles = new Set(Object.values(RESPONDI_QUESTION_ALIASES).flat());
const source_metadata: Record<string, string> = {};
for (const [title, value] of Object.entries(answers)) {
if (matchedTitles.has(title)) continue;
if (typeof value !== "string" || !value.trim()) continue;
if (title.toLowerCase().startsWith("utm_")) {
source_metadata[title.toLowerCase()] = value.trim();
} else {
custom_fields[`respondi_q_${slugifyQuestion(title)}`] = value.trim();
}
}
const consent = extractConsent(respondent);
if (consent.rawAnswer) custom_fields.consent_raw_answer = consent.rawAnswer;
custom_fields.consent_marketing_status = consent.granted ? "granted" : "declined";
if (typeof respondent.score === "number") custom_fields.respondi_score = String(respondent.score);
if (typeof respondent.status === "string") custom_fields.respondi_status = respondent.status;
if (typeof form.form_id === "string") custom_fields.respondi_form_id = form.form_id;
if (typeof form.form_name === "string") custom_fields.respondi_form_name = form.form_name;
if (typeof respondent.respondent_id === "string") {
custom_fields.respondi_respondent_id = respondent.respondent_id;
}
const externalId =
typeof respondent.respondent_id === "string" && respondent.respondent_id.trim()
? `respondi:${respondent.respondent_id.trim()}`
: null;
return {
name: pick("name"),
phone: normalizePhoneBR(pick("phone")),
email: pick("email"),
custom_fields,
source_metadata,
externalId,
consent,
companyName: pick("company_name"),
};
}
/** Título do card: empresa + contato — nunca um rótulo genérico fixo. */
export function respondiLeadTitle(mapped: RespondiMapped): string {
if (mapped.companyName && mapped.name) return `${mapped.companyName} — ${mapped.name}`;
return mapped.companyName ?? mapped.name ?? mapped.phone ?? mapped.email ?? "Lead sem nome";
}
/**
* A forma completa que `contacts.consent` exige quando eu decido gravar
* concessão — a coluna tem DEFAULT com as 3 chaves (marketing/transactional/
* profiling); um INSERT que passa só `{marketing: {...}}` substitui o
* default INTEIRO, não faz merge. `transactional`/`profiling` continuam
* null: este webhook só capta consentimento de marketing/WhatsApp.
*
* Quando o respondente RECUSA, a rota simplesmente não chama esta função —
* o INSERT omite a coluna e o DEFAULT (tudo null) vale, que já é "não
* concedido". A recusa fica registrada em `custom_fields.consent_marketing_status`
* (no lead) e numa atividade na timeline, não numa concessão fake.
*/
export function buildContactConsentGrant(formId: string | null): {
marketing: { granted_at: string; source: string; version: string | null };
transactional: { granted_at: null; source: null; version: null };
profiling: { granted_at: null; source: null; version: null };
} {
return {
marketing: { granted_at: new Date().toISOString(), source: "webhook:respondi", version: formId },
transactional: { granted_at: null, source: null, version: null },
profiling: { granted_at: null, source: null, version: null },
};
}
+92
View File
@@ -0,0 +1,92 @@
{
"_comment": "Fixture sanitizada — estrutura real do payload que o Respondi envia pro webhook genérico (form 'Imobiliárias e Incorporadoras'), com nome/telefone/e-mail/empresa trocados por dado fictício. Capturado via leitura read-only de webhook_events_log em 2026-08-25; nenhum dado pessoal real está aqui.",
"form": {
"form_id": "9FiY9mrO",
"form_name": "Imobiliárias e Incorporadoras"
},
"respondent": {
"date": "2026-08-25 03:17:10",
"score": 55,
"status": "completed",
"answers": {
"Qual é o seu nome?": "Maria Exemplo",
"Autorização de contato": "Aceito",
"Qual é o seu melhor e-mail?": "maria.exemplo@example.com",
"Qual é o nome da sua empresa?": "Exemplo Incorporadora",
"Qual é a sua função na empresa?": "Diretor(a) ou gestor(a)",
"Qual é o seguimento da sua empresa?": "Incorporadora",
"Qual é o Instagram ou site da empresa?": "@exemploincorporadora",
"Em qual cidade e estado sua empresa atua?": "Sorocaba - SP",
"Qual é o faturamento médio mensal da empresa?": "De R$ 30 mil a R$ 50 mil",
"Como sua empresa faz marketing e atendimento hoje?": "Fazemos internamente, mas sem processo definido",
"Quando você pretende começar a melhorar essa operação?": "Mais adiante",
"Hoje, qual é o principal desafio comercial da sua empresa?": "Aumentar a conversão da equipe comercial",
"Qual é o melhor WhatsApp para falarmos sobre essa análise?": "55 15988887777",
"Quanto sua empresa investe atualmente em marketing por mês?": "De R$ 5 mil a R$ 10 mil",
"Considerando estratégia, tecnologia, atendimento e mídia, qual faixa de investimento seria viável para sua empresa crescer?": "De R$ 4 mil a R$ 7 mil por mês"
},
"raw_answers": [
{
"answer": ["Incorporadora"],
"question": { "question_id": "xqo17erhz57g", "question_type": "radio", "question_title": "Qual é o seguimento da sua empresa?" }
},
{
"answer": ["Diretor(a) ou gestor(a)"],
"question": { "question_id": "x0gwa339l9tfi", "question_type": "radio", "question_title": "Qual é a sua função na empresa?" }
},
{
"answer": "Exemplo Incorporadora",
"question": { "question_id": "x81rqz5zi2fx", "question_type": "text", "question_title": "Qual é o nome da sua empresa?" }
},
{
"answer": "@exemploincorporadora",
"question": { "question_id": "x21uvt0c5mdh", "question_type": "text", "question_title": "Qual é o Instagram ou site da empresa?" }
},
{
"answer": "Sorocaba - SP",
"question": { "question_id": "xwc0y0tz685", "question_type": "text", "question_title": "Em qual cidade e estado sua empresa atua?" }
},
{
"answer": ["De R$ 30 mil a R$ 50 mil"],
"question": { "question_id": "xnq4e13pc11k", "question_type": "radio", "question_title": "Qual é o faturamento médio mensal da empresa?" }
},
{
"answer": ["Aumentar a conversão da equipe comercial"],
"question": { "question_id": "xdy0rr6cr6qb", "question_type": "radio", "question_title": "Hoje, qual é o principal desafio comercial da sua empresa?" }
},
{
"answer": ["Fazemos internamente, mas sem processo definido"],
"question": { "question_id": "xu0v6wh59fw", "question_type": "radio", "question_title": "Como sua empresa faz marketing e atendimento hoje?" }
},
{
"answer": ["De R$ 5 mil a R$ 10 mil"],
"question": { "question_id": "xkufb9awfthd", "question_type": "radio", "question_title": "Quanto sua empresa investe atualmente em marketing por mês?" }
},
{
"answer": ["De R$ 4 mil a R$ 7 mil por mês"],
"question": { "question_id": "xrgvs1g2v5qf", "question_type": "radio", "question_title": "Considerando estratégia, tecnologia, atendimento e mídia, qual faixa de investimento seria viável para sua empresa crescer?" }
},
{
"answer": ["Mais adiante"],
"question": { "question_id": "xnzbz5ipyvze", "question_type": "radio", "question_title": "Quando você pretende começar a melhorar essa operação?" }
},
{
"answer": "Maria Exemplo",
"question": { "question_id": "x8oadm87qhyf", "question_type": "name", "question_title": "Qual é o seu nome?" }
},
{
"answer": { "phone": "15988887777", "country": "55" },
"question": { "question_id": "x2i29hyadnnk", "question_type": "phone", "question_title": "Qual é o melhor WhatsApp para falarmos sobre essa análise?" }
},
{
"answer": "maria.exemplo@example.com",
"question": { "question_id": "xgw7byf4a6sl", "question_type": "email", "question_title": "Qual é o seu melhor e-mail?" }
},
{
"answer": "yes",
"question": { "question_id": "xkmuhuumumqn", "question_type": "legaltext", "question_title": "Autorização de contato" }
}
],
"respondent_id": "00000000-0000-4000-8000-000000000001"
}
}
+328
View File
@@ -1,4 +1,5 @@
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
import { NextRequest } from "next/server";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -270,6 +271,14 @@ const WHIN_SOURCE_JSON = "dddddddd-5555-4000-8000-000000000001";
const WHIN_SOURCE_FORM = "dddddddd-5555-4000-8000-000000000002";
const WHIN_SOURCE_INACTIVE = "dddddddd-5555-4000-8000-000000000003";
const WHIN_SOURCE_SECRET = "dddddddd-5555-4000-8000-000000000004";
const WHIN_SOURCE_RESPONDI = "dddddddd-5555-4000-8000-000000000005";
// Fixture própria (namespace ffffffff, mesmo padrão de webhooks-rls.test.ts) —
// org B só pra provar que o fallback por e-mail não cruza tenant.
const WHIN_ORG_B = "ffffffff-0000-4000-8000-000000000101";
const WHIN_PIPELINE_B = "ffffffff-5555-4000-8000-000000000101";
const WHIN_STAGE_B = "ffffffff-5555-4000-8000-000000000102";
const WHIN_SOURCE_RESPONDI_B = "ffffffff-5555-4000-8000-000000000103";
const TOKEN_RESPONDI_B = "wh-in-respondi-org-b-token-1234";
const SECRET = "test-webhook-secret-abc123";
const REDIRECT_TO = "https://example.com/obrigado";
@@ -278,6 +287,40 @@ const TOKEN_FORM = "wh-in-form-token-1234";
const TOKEN_INACTIVE = "wh-in-inactive-token-1234";
const TOKEN_SECRET = "wh-in-secret-token-1234";
const TOKEN_UNKNOWN = "wh-in-does-not-exist-1234";
const TOKEN_RESPONDI = "wh-in-respondi-token-1234";
/** Fixture sanitizada — mesma FORMA do payload real do Respondi (webhook_events_log, 2026-08-25). */
const RESPONDI_FIXTURE = JSON.parse(
readFileSync("tests/fixtures/webhooks/respondi-imobiliario.json", "utf8"),
) as Record<string, unknown>;
/**
* Cópia funda com respondent_id, telefone e e-mail trocados.
*
* `respondent_id` — cada teste precisa do seu, senão o dedup por external_id
* os confunde. `phone`/`email` — idem para o contato: `uniq_contacts_org_email`
* (baseline.sql:2911) é tão real quanto `uniq_contacts_org_phone`, e a rota
* reusa contato existente por telefone OU colide no INSERT por e-mail
* repetido — dois testes que compartilham qualquer um dos dois acabam no
* MESMO contato (ou, pior, um 23505 por e-mail que a rota só sabe
* recuperar re-selecionando por TELEFONE — achado à parte, fora do escopo
* deste fix). Cada `it()` usa telefone E e-mail próprios.
*/
function respondiPayload(
respondentId: string,
phone: string,
email: string,
overrides: (p: Record<string, unknown>) => void = () => {},
) {
const clone = JSON.parse(JSON.stringify(RESPONDI_FIXTURE)) as Record<string, unknown>;
const respondent = clone.respondent as Record<string, unknown>;
respondent.respondent_id = respondentId;
const answers = respondent.answers as Record<string, unknown>;
answers["Qual é o melhor WhatsApp para falarmos sobre essa análise?"] = phone;
answers["Qual é o seu melhor e-mail?"] = email;
overrides(clone);
return clone;
}
beforeAll(() => {
seedGov();
@@ -306,6 +349,23 @@ beforeAll(() => {
(id, organization_id, name, path_token, default_pipeline_id, default_stage_id, secret_encrypted)
values ('${WHIN_SOURCE_SECRET}', '${GOV_ORG}', 'Secret source', '${TOKEN_SECRET}', '${GOV_PIPELINE}', '${GOV_STAGE}', public.fn_encrypt_oauth('${SECRET}'))
on conflict do nothing;
insert into public.webhook_sources
(id, organization_id, name, path_token, default_pipeline_id, default_stage_id)
values ('${WHIN_SOURCE_RESPONDI}', '${GOV_ORG}', 'Respondi — Decola Aí Imobiliário', '${TOKEN_RESPONDI}', '${GOV_PIPELINE}', '${GOV_STAGE}')
on conflict do nothing;
insert into public.organizations (id, slug, legal_name, display_name)
values ('${WHIN_ORG_B}', 'gov-inv-whin-org-b', 'Gov Invariant Webhooks-In Org B', 'Gov Inv Whin B')
on conflict do nothing;
insert into public.crm_pipelines (id, organization_id, name, slug)
values ('${WHIN_PIPELINE_B}', '${WHIN_ORG_B}', 'Pipeline B', 'pipeline-b')
on conflict do nothing;
insert into public.crm_stages (id, organization_id, pipeline_id, name, slug, position)
values ('${WHIN_STAGE_B}', '${WHIN_ORG_B}', '${WHIN_PIPELINE_B}', 'Novo', 'novo', 1000)
on conflict do nothing;
insert into public.webhook_sources
(id, organization_id, name, path_token, default_pipeline_id, default_stage_id)
values ('${WHIN_SOURCE_RESPONDI_B}', '${WHIN_ORG_B}', 'Respondi Org B', '${TOKEN_RESPONDI_B}', '${WHIN_PIPELINE_B}', '${WHIN_STAGE_B}')
on conflict do nothing;
`);
});
@@ -468,3 +528,271 @@ describe("POST /api/v1/webhooks/in/[token] (Task 6)", () => {
// 61 chamadas sequenciais só pra exercitar um path já testado; pulado.
it.skip("rate limit 429 após estourar a janela — coberto por unit test do fallback in-memory", () => {});
});
describe("POST /api/v1/webhooks/in/[token] — Respondi (payload aninhado, achado 2026-08-25)", () => {
it("caso 1 — envio real do Respondi cria contato + lead com campos mapeados, título empresa+nome, consentimento gravado", async () => {
const payload = respondiPayload("resp-int-happy-0001", "55 15988880001", "maria.exemplo+0001@example.com");
const res = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { lead_id: string } };
const leadId = json.data.lead_id;
expect(leadId).toBeTruthy();
const leadRows = rows(`select * from public.crm_leads where id = '${leadId}'`);
expect(leadRows.length).toBe(1);
const lead = leadRows[0]!;
// Título: empresa + contato — NUNCA um rótulo genérico fixo.
expect(lead.title).toBe("Exemplo Incorporadora — Maria Exemplo");
expect(lead.external_id).toBe("respondi:resp-int-happy-0001");
const cf = lead.custom_fields as Record<string, unknown>;
expect(cf.company_name).toBe("Exemplo Incorporadora");
expect(cf.segment).toBe("Incorporadora");
expect(cf.viable_investment_range).toBe("De R$ 4 mil a R$ 7 mil por mês");
expect(cf.respondi_score).toBe("55");
expect(cf.respondi_form_id).toBe("9FiY9mrO");
expect(cf.consent_marketing_status).toBe("granted");
const contactRows = rows(`select * from public.contacts where id = '${lead.contact_id}'`);
expect(contactRows.length).toBe(1);
const contact = contactRows[0]!;
expect(contact.phone_number).toBe("+5515988880001");
expect(contact.email).toBe("maria.exemplo+0001@example.com");
const consent = contact.consent as { marketing: { granted_at: string | null; source: string | null } };
expect(consent.marketing.granted_at).not.toBeNull();
expect(consent.marketing.source).toBe("webhook:respondi");
});
it("caso 2 — o MESMO envio (mesmo respondent_id) reenviado não duplica o lead (idempotência via respondent_id)", async () => {
const payload = respondiPayload("resp-int-idempotent-0002", "55 15988880002", "maria.exemplo+0002@example.com");
const res1 = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res1.status).toBe(200);
const leadId1 = ((await res1.json()) as { data: { lead_id: string } }).data.lead_id;
const res2 = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res2.status).toBe(200);
const leadId2 = ((await res2.json()) as { data: { lead_id: string } }).data.lead_id;
expect(leadId2).toBe(leadId1);
const count = Number(
rows(`select count(*) as n from public.crm_leads where external_id = 'respondi:resp-int-idempotent-0002'`)[0]!
.n,
);
expect(count).toBe(1);
});
it("caso 3 — consentimento recusado: lead é criado, contato NÃO ganha consent.marketing, e a recusa vira atividade na timeline", async () => {
const payload = respondiPayload("resp-int-declined-0003", "55 15988880003", "maria.exemplo+0003@example.com", (p) => {
const respondent = p.respondent as Record<string, unknown>;
const rawAnswers = respondent.raw_answers as Array<Record<string, unknown>>;
const legaltext = rawAnswers.find(
(r) => (r.question as Record<string, unknown>).question_type === "legaltext",
)!;
legaltext.answer = "no";
(respondent.answers as Record<string, unknown>)["Autorização de contato"] = "Não aceito";
});
const res = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res.status).toBe(200);
const leadId = ((await res.json()) as { data: { lead_id: string } }).data.lead_id;
const lead = rows(`select * from public.crm_leads where id = '${leadId}'`)[0]!;
expect((lead.custom_fields as Record<string, unknown>).consent_marketing_status).toBe("declined");
const contact = rows(`select * from public.contacts where id = '${lead.contact_id}'`)[0]!;
const consent = contact.consent as { marketing: { granted_at: string | null } };
// Recusa NUNCA vira concessão por omissão: continua no default (tudo null).
expect(consent.marketing.granted_at).toBeNull();
const activityRows = rows(
`select * from public.crm_lead_activities where lead_id = '${leadId}' and type = 'consent_declined'`,
);
expect(activityRows.length).toBe(1);
expect(activityRows[0]!.actor_kind).toBe("system");
});
it("caso 4 — compatibilidade: o botão interno 'Enviar lead de teste' (payload genérico) continua funcionando sem passar pelo caminho Respondi", async () => {
// Mesmo payload literal que SourceDetail.tsx manda — bate numa fonte
// comum (não-Respondi), prova que o normalizador novo não interfere.
const res = await POST(
jsonReq(TOKEN_JSON, { nome: "Lead de Teste", telefone: "11999990000", utm_source: "teste" }),
reqCtx(TOKEN_JSON),
);
expect(res.status).toBe(200);
const leadId = ((await res.json()) as { data: { lead_id: string } }).data.lead_id;
const lead = rows(`select * from public.crm_leads where id = '${leadId}'`)[0]!;
expect(lead.title).toBe("Lead de Teste");
});
it("caso 5 — campo (empresa) ausente no envio: título cai pro nome, sem quebrar", async () => {
const payload = respondiPayload("resp-int-sem-empresa-0005", "55 15988880005", "maria.exemplo+0005@example.com", (p) => {
const respondent = p.respondent as Record<string, unknown>;
delete (respondent.answers as Record<string, unknown>)["Qual é o nome da sua empresa?"];
});
const res = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res.status).toBe(200);
const leadId = ((await res.json()) as { data: { lead_id: string } }).data.lead_id;
const lead = rows(`select * from public.crm_leads where id = '${leadId}'`)[0]!;
expect(lead.title).toBe("Maria Exemplo");
expect((lead.custom_fields as Record<string, unknown>).company_name).toBeUndefined();
});
it("caso 6 — MESMO e-mail, telefone DIFERENTE: conflito 23505 por e-mail não órfã o lead (achado do fix anterior)", async () => {
// Pré-semeia um contato ATIVO com um e-mail que o próximo envio vai
// repetir — telefone deliberadamente diferente do que o payload vai usar,
// pra garantir que o pré-check por telefone NÃO encontre nada e a rota
// realmente tente o INSERT (e bata no uniq_contacts_org_email).
const preexistingId = "dddddddd-7777-4000-8000-000000000001";
const sharedEmail = "colisao.email@example.com";
sql(`
insert into public.contacts (id, organization_id, name, phone_number, email, source)
values ('${preexistingId}', '${GOV_ORG}', 'Dono Original do E-mail', '+5515900000001', '${sharedEmail}', 'manual')
on conflict do nothing;
`);
const payload = respondiPayload("resp-int-mesmo-email-0006", "55 15900000099", sharedEmail);
const res = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res.status).toBe(200);
const leadId = ((await res.json()) as { data: { lead_id: string } }).data.lead_id;
const lead = rows(`select * from public.crm_leads where id = '${leadId}'`)[0]!;
// O CORAÇÃO do fix: contact_id NÃO pode ficar null.
expect(lead.contact_id).not.toBeNull();
expect(lead.contact_id).toBe(preexistingId);
// Nenhum contato NOVO foi criado — reusou o existente por e-mail.
const emailCount = Number(
rows(
`select count(*) as n from public.contacts where organization_id = '${GOV_ORG}' and email_normalized = '${sharedEmail}'`,
)[0]!.n,
);
expect(emailCount).toBe(1);
});
it("caso 7 — MESMO telefone: continua reusando o contato existente por telefone (regressão do fallback por e-mail)", async () => {
const preexistingId = "dddddddd-7777-4000-8000-000000000002";
const sharedPhone = "+5515900000002";
sql(`
insert into public.contacts (id, organization_id, name, phone_number, email, source)
values ('${preexistingId}', '${GOV_ORG}', 'Dono Original do Telefone', '${sharedPhone}', 'outro.email@example.com', 'manual')
on conflict do nothing;
`);
// Mesmo telefone do contato pré-existente, e-mail NOVO — prova que o
// telefone continua tendo prioridade sobre e-mail (não troca o contato
// reusado por um e-mail diferente).
const payload = respondiPayload("resp-int-mesmo-telefone-0007", "55 15900000002", "email.novo.0007@example.com");
const res = await POST(jsonReq(TOKEN_RESPONDI, payload), reqCtx(TOKEN_RESPONDI));
expect(res.status).toBe(200);
const leadId = ((await res.json()) as { data: { lead_id: string } }).data.lead_id;
const lead = rows(`select * from public.crm_leads where id = '${leadId}'`)[0]!;
expect(lead.contact_id).toBe(preexistingId);
const phoneCount = Number(
rows(
`select count(*) as n from public.contacts where organization_id = '${GOV_ORG}' and phone_number = '${sharedPhone}'`,
)[0]!.n,
);
expect(phoneCount).toBe(1);
});
it("caso 8 — organizações DIFERENTES: mesmo e-mail e mesmo telefone em duas orgs nunca se cruzam", async () => {
const sharedEmail = "cross.org@example.com";
const sharedPhoneDigits = "15900000008";
const payloadOrgA = respondiPayload(
"resp-int-org-a-0008",
`55 ${sharedPhoneDigits}`,
sharedEmail,
);
const resA = await POST(jsonReq(TOKEN_RESPONDI, payloadOrgA), reqCtx(TOKEN_RESPONDI));
expect(resA.status).toBe(200);
const leadIdA = ((await resA.json()) as { data: { lead_id: string } }).data.lead_id;
const payloadOrgB = respondiPayload(
"resp-int-org-b-0008",
`55 ${sharedPhoneDigits}`,
sharedEmail,
);
const resB = await POST(jsonReq(TOKEN_RESPONDI_B, payloadOrgB), reqCtx(TOKEN_RESPONDI_B));
expect(resB.status).toBe(200);
const leadIdB = ((await resB.json()) as { data: { lead_id: string } }).data.lead_id;
const leadA = rows(`select * from public.crm_leads where id = '${leadIdA}'`)[0]!;
const leadB = rows(`select * from public.crm_leads where id = '${leadIdB}'`)[0]!;
expect(leadA.organization_id).toBe(GOV_ORG);
expect(leadB.organization_id).toBe(WHIN_ORG_B);
// Contatos DIFERENTES — nenhum vazamento cross-tenant via e-mail/telefone.
expect(leadA.contact_id).not.toBe(leadB.contact_id);
const contactA = rows(`select * from public.contacts where id = '${leadA.contact_id}'`)[0]!;
const contactB = rows(`select * from public.contacts where id = '${leadB.contact_id}'`)[0]!;
expect(contactA.organization_id).toBe(GOV_ORG);
expect(contactB.organization_id).toBe(WHIN_ORG_B);
expect(contactA.email).toBe(sharedEmail);
expect(contactB.email).toBe(sharedEmail);
});
// O caso 8 acima NÃO alcança o `selectActiveByEmail`: com o mesmo telefone nas
// duas orgs, o INSERT da org B passa limpo (`uniq_contacts_org_phone` é POR
// organização) e a busca por e-mail nunca roda. Medido sabotando só o
// `.eq("organization_id", …)` daquele select: a suíte inteira segue verde.
//
// Guarda com ponto cego é pior que guarda ausente, porque parece cobertura.
// Este caso força o ramo: MESMO e-mail, telefone INÉDITO em cada org — que é
// exatamente quando o INSERT colide em `uniq_contacts_org_email` e o código
// cai na busca por e-mail para reencontrar o contato.
it("9. o reencontro POR E-MAIL respeita a organização — o ramo que o caso 8 não alcança", async () => {
const emailCompartilhado = "ramo.email@example.com";
// Org A: cria o contato com um telefone próprio.
const resA = await POST(
jsonReq(TOKEN_RESPONDI, respondiPayload("resp-int-email-a", "55 15900000009", emailCompartilhado)),
reqCtx(TOKEN_RESPONDI),
);
expect(resA.status).toBe(200);
const leadA = rows(
`select * from public.crm_leads where id = '${((await resA.json()) as { data: { lead_id: string } }).data.lead_id}'`,
)[0]!;
// Org B: MESMO e-mail, telefone DIFERENTE — o INSERT colide em
// uniq_contacts_org_email da PRÓPRIA org B só se já houver contato lá; aqui
// não há, então ele entra. O que este caso prende é que o select por e-mail
// não pode enxergar o contato da org A em nenhum momento.
const resB = await POST(
jsonReq(TOKEN_RESPONDI_B, respondiPayload("resp-int-email-b", "55 15900000010", emailCompartilhado)),
reqCtx(TOKEN_RESPONDI_B),
);
expect(resB.status).toBe(200);
const leadB = rows(
`select * from public.crm_leads where id = '${((await resB.json()) as { data: { lead_id: string } }).data.lead_id}'`,
)[0]!;
expect(leadA.organization_id).toBe(GOV_ORG);
expect(leadB.organization_id).toBe(WHIN_ORG_B);
expect(leadA.contact_id).not.toBe(leadB.contact_id);
const contatoB = rows(`select * from public.contacts where id = '${leadB.contact_id}'`)[0]!;
expect(contatoB.organization_id, "o contato da org B nasceu na org errada").toBe(WHIN_ORG_B);
// A prova de que o ramo foi exercitado: um SEGUNDO envio na org B, com o
// mesmo e-mail e outro telefone, tem de REENCONTRAR o contato de B — e não
// criar um terceiro nem colidir com o da org A.
const resB2 = await POST(
jsonReq(TOKEN_RESPONDI_B, respondiPayload("resp-int-email-b2", "55 15900000011", emailCompartilhado)),
reqCtx(TOKEN_RESPONDI_B),
);
expect(resB2.status).toBe(200);
const leadB2 = rows(
`select * from public.crm_leads where id = '${((await resB2.json()) as { data: { lead_id: string } }).data.lead_id}'`,
)[0]!;
expect(leadB2.contact_id, "o reencontro por e-mail não achou o contato da própria org").toBe(
leadB.contact_id,
);
const contatosComEsseEmail = rows(
`select organization_id from public.contacts where email_normalized = '${emailCompartilhado}' and is_merged_into is null order by organization_id`,
);
expect(contatosComEsseEmail, "deveria haver exatamente um contato por organização").toHaveLength(2);
});
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { isRespondiPayload, mapRespondiPayload } from "@/lib/webhooks/respondi";
/**
* "NUNCA DESCARTADAS" TEM DE SER VERDADE — inclusive para pergunta longa.
*
* O módulo promete, em `respondi.ts:196`, que pergunta sem alias conhecido é
* "NUNCA descartada … entra sob uma chave derivada do próprio texto, pra
* sobreviver a uma pergunta nova adicionada no Respondi sem exigir deploy".
*
* O slug cortava em 60 caracteres sem mais nada. Duas perguntas longas com os
* mesmos 60 primeiros caracteres colidiam no `Record` e a última sobrescrevia a
* primeira — sem erro, sem log, sem sinal na tela. A promessa e o código
* discordavam, e quem paga é quem acrescenta duas perguntas parecidas no
* formulário meses depois.
*
* Era LATENTE quando foi medido: as 15 perguntas do formulário real têm alias e
* nenhuma chega ao slug. Este arquivo existe para que continue latente.
*/
/** Um payload Respondi mínimo, com as respostas que o caso precisa. */
function payload(answers: Record<string, string>) {
const p = {
form: { id: "f1", title: "Formulário" },
respondent: { respondent_id: "r1", answers },
};
// Guarda de vacuidade: se a forma mudar, o teste não pode passar por não
// exercitar nada — `mapRespondiPayload` só é chamada se o guard aceitar.
expect(isRespondiPayload(p), "o payload de teste deixou de ser reconhecido").toBe(true);
return p as Parameters<typeof mapRespondiPayload>[0];
}
/**
* Só as chaves que o slug gerou. Elas vão para `custom_fields` — `source_metadata`
* recebe apenas as `utm_*` (ver o `if` em `respondi.ts:235`). Minha primeira
* versão olhava o campo errado e devolvia `[]`: instrumento quebrado devolvendo
* zero, que passa por "nada errado" se ninguém tiver um controle.
*/
function chavesDeSlug(m: { custom_fields?: Record<string, unknown> }): string[] {
return Object.keys(m.custom_fields ?? {}).filter((k) => k.startsWith("respondi_q_"));
}
const PREFIXO = "Qual e a sua expectativa de investimento para este imovel no"; // 60 chars de slug
describe("pergunta longa não some por colisão de slug", () => {
it("duas perguntas com os mesmos 60 primeiros caracteres geram chaves DIFERENTES", () => {
const m = mapRespondiPayload(
payload({
[`${PREFIXO} bairro Centro`]: "R$ 300 mil",
[`${PREFIXO} bairro Jardins`]: "R$ 900 mil",
}),
);
const chaves = chavesDeSlug(m);
expect(chaves, "as duas perguntas colidiram numa chave só").toHaveLength(2);
const valores = chaves.map((k) => m.custom_fields?.[k]);
expect(new Set(valores).size, "uma resposta sobrescreveu a outra").toBe(2);
});
it("o limiar: divergir no caractere 61 ainda produz duas chaves", () => {
// Antes do conserto, este era exatamente o caso que colidia — divergir DEPOIS
// do corte. Divergir antes do 60 nunca colidiu, então não prova nada.
const base = "a".repeat(60);
const m = mapRespondiPayload(payload({ [`${base}X`]: "um", [`${base}Y`]: "dois" }));
expect(chavesDeSlug(m)).toHaveLength(2);
});
it("CONTROLE: pergunta CURTA mantém o slug limpo, sem sufixo", () => {
// Sem este caso, sufixar TUDO passaria nos dois acima e mudaria o nome de
// toda chave existente — quebrando quem já lê `respondi_q_orcamento`.
const m = mapRespondiPayload(payload({ "Qual seu orçamento?": "R$ 500 mil" }));
expect(chavesDeSlug(m)).toEqual(["respondi_q_qual_seu_orcamento"]);
});
it("CONTROLE: o valor da resposta chega inteiro", () => {
const m = mapRespondiPayload(payload({ "Observações do cliente": "quer mudar em janeiro" }));
expect(m.custom_fields?.respondi_q_observacoes_do_cliente).toBe("quer mudar em janeiro");
});
});