mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user