feat(anuncios): o ref da landing page casa com o contato na primeira mensagem

Fecha a outra metade do mecanismo: o ref criado no clique passa a ser
consumido quando a mensagem chega, e as UTMs guardadas no servidor viram a
origem do contato.

O consumo entra em `guardarOrigemDaPagina` (`lib/channels/pos-entrada.ts`),
junto do `[dk1:]`, porque os dois transportam a MESMA coisa: a origem da
página. Tudo o que vem depois — vale só na primeira mensagem, não sobrescreve
o primeiro toque, grava as `utm_*` achatadas em `source_metadata` com
`ad_platform = site` — é idêntico, e um segundo caminho divergiria do primeiro
na próxima regra que mudasse.

Duas decisões que valem a leitura:

- **O `[dk1:]` é tentado primeiro.** Ele se resolve no próprio texto, sem
  consulta nenhuma; o ref só vai ao banco quando o texto não trouxe UTM.
- **O ref não é lido antes da guarda de primeira mensagem.** Ler é CONSUMIR
  (`update ... where matched_at is null`), e consumir fora da primeira mensagem
  queimaria o clique sem estampar ninguém — o dono do anúncio nunca saberia por
  quê.

A regra de desempate do `[ref:XXXXXX]`, que a issue #1400 levantou: cada lado
procura na PRÓPRIA tabela. O padrão do marcador sobe para
`lib/plataformas-de-anuncio/captura-de-clique.ts` e passa a ser um só para os
dois eixos — um padrão por eixo divergiria no dia em que só um deles mudasse
de alfabeto. A chance de o mesmo ref nascer nas duas tabelas da mesma
organização é a da colisão interna que o arquivo já aceita desde a 0306
(32^6 por organização), e o pior desfecho é escolher entre duas origens que
são ambas de anúncio. Conferir a tabela irmã a cada clique custaria uma
consulta no caminho de quem clicou num anúncio pago — caminho que não pode
ficar mais lento nem mais frágil.

Verificado: `pnpm cercas` (139 arquivos, 1354 testes), `pnpm lint:channels`,
`eslint` nos arquivos do diff e `pnpm vitest run` nos módulos tocados (92
testes, incluindo os cinco casos novos de ingestão: ref que casa, filtros do
consumo, ref fora da primeira mensagem, ref que não casa e `[dk1:]` ganhando
do ref). `pnpm typecheck` não roda nesta máquina (exit 137, OOM); o job
`verify` do CI cobre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvE6x5GV6rjgmXhhBPLhYK
This commit is contained in:
rafaelbatistazz
2026-09-20 22:47:06 +00:00
co-authored by Claude Opus 5
parent 4a49386f2e
commit 2db62ad8a3
6 changed files with 269 additions and 8 deletions
+36 -4
View File
@@ -46,6 +46,8 @@ import {
extrairOrigemDaPagina,
} from "@/lib/leads/origem-do-site";
import { logger } from "@/lib/logger";
import { PADRAO_DO_REF } from "@/lib/plataformas-de-anuncio/captura-de-clique";
import { casarClickRef } from "@/lib/plataformas-de-anuncio/meta/captura-de-clique";
import type { createAdminClient } from "@/lib/supabase/admin";
import { ehPedidoDeOptOut } from "@/lib/opt-out/deteccao";
import { ehContatoDoNumeroInterno } from "@/lib/escalacao/numero-interno-de-aviso";
@@ -358,13 +360,25 @@ async function pedirDespachoDoAgente(admin: Admin, entrada: EntradaDeMensagem):
* Falha aqui é LOG, nunca exceção: a mensagem do cliente JÁ está gravada, e
* devolver erro ao provider faria ele reenviar a mensagem. Trocar um rótulo de
* origem faltando por uma tempestade de reentregas é um péssimo negócio.
*
* ─── Dois transportes para a MESMA origem ──────────────────────────────────
*
* `[dk1:<base64url>]` carrega as UTMs dentro do próprio texto, e `[ref:XXXXXX]`
* carrega só um ref de seis caracteres, cujas UTMs ficaram no servidor quando a
* rota de captura recebeu o clique. O resto — primeira mensagem, primeiro
* toque, formato do que é gravado — é idêntico nos dois, e é por isso que eles
* compartilham este bloco em vez de ganharem um caminho cada.
*
* O `[dk1:]` é tentado PRIMEIRO porque é o que não custa consulta nenhuma: ele
* se resolve no texto. O ref só vai ao banco quando o texto não trouxe UTM.
*/
async function guardarOrigemDaPagina(admin: Admin, entrada: EntradaDeMensagem): Promise<void> {
const achada = extrairOrigemDaPagina(entrada.texto);
// O caso comum: quase nenhuma mensagem traz código de página.
if (!achada) return;
const origem = { ...achada, capturadaEm: new Date().toISOString() };
// O ref não é lido no banco aqui: a leitura CONSOME o clique, e consumir um
// clique fora da primeira mensagem o queimaria sem estampar ninguém.
const ref = achada ? null : (PADRAO_DO_REF.exec(entrada.texto ?? "")?.[1] ?? null);
// O caso comum: quase nenhuma mensagem traz código de página nem ref.
if (!achada && !ref) return;
try {
// A consulta vem ANTES de qualquer escrita, e DENTRO do try: falha de
@@ -385,6 +399,24 @@ async function guardarOrigemDaPagina(admin: Admin, entrada: EntradaDeMensagem):
return;
}
const utm = achada
? achada.utm
: ref
? (await casarClickRef(admin, entrada.organizationId, ref, entrada.contactId))?.utm
: undefined;
if (!utm) {
// Ref que não casa é sinal NOSSO que não fechou: já consumido, de outra
// organização, ou de um clique que nunca foi gravado. Não é tráfego
// orgânico, então vale um aviso — ao contrário da mensagem sem marcador
// nenhum, que nem chega aqui.
logger.warn("pos-entrada: ref da página não casou (a mensagem entra assim mesmo)", {
contactId: entrada.contactId,
});
return;
}
const origem = { utm, capturadaEm: new Date().toISOString() };
const gravou = await estamparOrigemDaPagina(admin, entrada.organizationId, entrada.contactId, origem);
if (!gravou) {
logger.warn("pos-entrada: origem da página NÃO gravada (a mensagem entra assim mesmo)", {
@@ -30,6 +30,31 @@ const TENTATIVAS_MAXIMAS = 5;
*/
export type TabelaDeClickRefs = "google_ads_click_refs" | "meta_ads_click_refs";
/**
* `[ref:XXXXXX]` — colchetes e prefixo de propósito, para não casar por
* acidente com seis caracteres que apareçam à toa no meio de uma mensagem
* comum. O alfabeto (sem 0/O/1/I/L) espelha o gerador logo abaixo.
*
* O padrão é UM SÓ para os dois eixos de captura, e é por isso que ele mora
* aqui e não em `google/atribuicao.ts`: quem lê o texto da mensagem não sabe
* (nem precisa saber) se aquele ref nasceu de um clique do Google Ads ou do
* botão de uma landing page. Quem sabe é a tabela que TEM o ref.
*/
export const PADRAO_DO_REF = /\[ref:([2-9A-HJ-NP-Z]{6})\]/;
/**
* ─── O desempate entre os dois eixos, declarado ────────────────────────────
*
* O mesmo `[ref:XXXXXX]` pode, em tese, existir nas duas tabelas da mesma
* organização. Cada lado procura na PRÓPRIA tabela, e é isso: a chance de o
* mesmo ref nascer nos dois é a mesma de uma colisão interna (32^6 por
* organização), que este arquivo já aceita desde a 0306, e o pior desfecho é
* escolher entre duas origens que são ambas de anúncio — não um dado de
* terceiro. Conferir a tabela irmã a cada clique custaria uma consulta no
* caminho de quem clicou num anúncio pago, que é o caminho que não pode
* ficar mais lento nem mais frágil.
*/
function gerarToken(): string {
let token = "";
for (let i = 0; i < TAMANHO_DO_TOKEN; i++) {
@@ -19,14 +19,15 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { logger } from "@/lib/logger";
import { estamparAtribuicaoDoContato } from "@/lib/leads/atribuicao-de-anuncio";
import { PADRAO_DO_REF } from "../captura-de-clique";
import { casarClickRef } from "./captura-de-clique";
/**
* `[ref:XXXXXX]` — colchetes e prefixo de propósito, para não casar por
* acidente com seis caracteres que apareçam à toa no meio de uma mensagem
* comum. O alfabeto (sem 0/O/1/I/L) espelha `captura-de-clique.ts`.
* O padrão do ref subiu para `../captura-de-clique.ts` quando a captura de UTM
* da landing page passou a usar o MESMO marcador: um padrão por eixo
* divergiria no dia em que só um dos dois mudasse de alfabeto.
*/
const PADRAO_DO_TOKEN = /\[ref:([2-9A-HJ-NP-Z]{6})\]/;
const PADRAO_DO_TOKEN = PADRAO_DO_REF;
/**
* Melhor esforço: nunca lança, nunca derruba o inbound. Ausência de match é o
@@ -0,0 +1,63 @@
/**
* O par ref↔UTM: criado quando alguém clica no botão da landing page (a rota
* `app/api/v1/anuncios/meta/[org]/route.ts`), consumido quando a mensagem do
* WhatsApp chega com o ref no texto.
*
* Mora aqui, e não em `lib/leads/`, pela mesma razão do irmão do Google: o
* DADO é o da captura de anúncio, e esta pasta é a segunda fronteira que
* `lib/plataformas-de-anuncio/types.ts` declara. O MECANISMO do ref (alfabeto,
* tamanho, retentativa, padrão no texto) é compartilhado em
* `../captura-de-clique.ts`.
*
* Só a metade de CONSUMO vive aqui — a de criação é a genérica, chamada
* direto pela rota com a tabela como parâmetro.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { logger } from "@/lib/logger";
export interface ClickRefDaPaginaCasado {
/** Só chaves de `CHAVES_DE_UTM`, como a rota as normalizou antes de gravar. */
utm: Record<string, string>;
}
/**
* Casa um ref com um contato — a UPDATE condicional que garante que um clique
* só é consumido UMA vez. `matched_at is null` no WHERE é a trava: o mesmo
* texto encaminhado adiante não vira atribuição de quem o recebeu, porque a
* segunda tentativa não encontra o que atualizar.
*
* `organization_id` no WHERE é a lição da #236: ref de uma organização nunca
* pode casar na outra.
*/
export async function casarClickRef(
admin: SupabaseClient,
organizationId: string,
token: string,
contactId: string,
): Promise<ClickRefDaPaginaCasado | null> {
const { data, error } = await admin
.from("meta_ads_click_refs")
.update({ matched_at: new Date().toISOString(), contact_id: contactId })
.eq("organization_id", organizationId)
.eq("token", token)
.is("matched_at", null)
.select("utm")
.maybeSingle();
if (error) {
logger.error("[meta.captura-de-clique] update de match falhou", {
organizationId,
codigo: error.code,
detalhe: error.message,
});
return null;
}
if (!data) return null;
const utm = (data as { utm: Record<string, string> | null }).utm;
// A coluna é `not null` com `check (utm <> '{}')`, então isto é defesa de
// borda, não caso esperado: linha sem UTM não teria o que estampar.
if (!utm || Object.keys(utm).length === 0) return null;
return { utm };
}
@@ -49,6 +49,7 @@ vi.mock("@/lib/supabase/admin", () => ({
}));
import { GET } from "@/app/api/v1/anuncios/meta/[org]/route";
import { casarClickRef } from "@/lib/plataformas-de-anuncio/meta/captura-de-clique";
import { textoSemRef } from "@/lib/plataformas-de-anuncio/pagina-de-captura";
const chamar = (query: string) =>
@@ -167,3 +168,50 @@ describe("textoSemRef", () => {
expect(textoSemRef("Vim pelo site {token}")).toBe("Vim pelo site");
});
});
const ORG = "11111111-1111-1111-1111-111111111111";
const CONTATO = "33333333-3333-3333-3333-333333333333";
describe("casarClickRef (Meta)", () => {
/** O construtor do PostgREST, com os filtros anotados para o caso conferir. */
function adminQueDevolve(data: unknown, filtros: Record<string, unknown> = {}) {
const construtor = {
update: () => construtor,
eq: (campo: string, valor: unknown) => {
filtros[campo] = valor;
return construtor;
},
is: (campo: string, valor: unknown) => {
filtros[campo] = valor;
return construtor;
},
select: () => construtor,
maybeSingle: async () => ({ data, error: null }),
};
return { from: () => construtor };
}
it("consome o ref e devolve as UTMs guardadas no clique", async () => {
const filtros: Record<string, unknown> = {};
const admin = adminQueDevolve({ utm: { utm_campaign: "black-friday" } }, filtros);
const casado = await casarClickRef(admin as never, ORG, "K7M2P9", CONTATO);
expect(casado).toEqual({ utm: { utm_campaign: "black-friday" } });
// Organização no filtro é a lição da #236; `matched_at is null` é a trava
// de consumo único.
expect(filtros.organization_id).toBe(ORG);
expect(filtros.token).toBe("K7M2P9");
expect(filtros.matched_at).toBeNull();
});
it("ref já consumido não casa (o UPDATE não acha linha)", async () => {
const casado = await casarClickRef(adminQueDevolve(null) as never, ORG, "K7M2P9", CONTATO);
expect(casado).toBeNull();
});
it("linha sem UTM não vira atribuição", async () => {
const casado = await casarClickRef(adminQueDevolve({ utm: {} }) as never, ORG, "K7M2P9", CONTATO);
expect(casado).toBeNull();
});
});
@@ -65,6 +65,13 @@ let rpcChamadas: Array<{ nome: string; args: Record<string, unknown> }> = [];
* tipo não distingue) passava verde: o fake respondia igual a qualquer filtro.
*/
let filtrosDeMessages: Array<[string, unknown]> = [];
/**
* O que a tabela do ref devolve quando o UPDATE de consumo roda. `null` é o
* ref que NÃO casa: já consumido, de outra organização, ou nunca gravado.
*/
let refCasado: { utm: Record<string, string> } | null = null;
/** Os `.eq()`/`.is()` do consumo do ref — provam a organização e a trava de uso único. */
let filtrosDoRef: Record<string, unknown> = {};
/** Imita o builder do PostgREST: encadeável, o efeito acontece no `await`. */
function cadeia(rotulo: string): Record<string, unknown> {
@@ -88,6 +95,26 @@ const admin = {
from(tabela: string) {
return {
update(payload: Record<string, unknown>) {
if (tabela === "meta_ads_click_refs") {
const consumo = {
eq(coluna: string, valor: unknown) {
filtrosDoRef[coluna] = valor;
return consumo;
},
is(coluna: string, valor: unknown) {
filtrosDoRef[coluna] = valor;
return consumo;
},
select(_colunas: string) {
return consumo;
},
async maybeSingle() {
sequencia.push("update:meta_ads_click_refs");
return { data: refCasado, error: null };
},
};
return consumo;
}
ultimoUpdate = payload;
return cadeia(`update:${tabela}`);
},
@@ -157,6 +184,8 @@ beforeEach(() => {
ultimaRpc = null;
rpcChamadas = [];
filtrosDeMessages = [];
refCasado = { utm: { utm_campaign: "black-friday", utm_ad: "video-depoimento-v3" } };
filtrosDoRef = {};
audit.mockClear();
garantirLeadDaConversa.mockClear();
garantirLeadDaConversa.mockResolvedValue({ criado: true, leadId: "lead-1" } as never);
@@ -548,3 +577,66 @@ describe("a origem da página que veio no texto", () => {
expect(vi.mocked(acelerarPipelineDeEventos)).toHaveBeenCalled();
});
});
/**
* O MESMO bloco de origem, pelo outro transporte: `[ref:XXXXXX]`, com as UTMs
* guardadas no servidor quando a rota de captura recebeu o clique
* (`app/api/v1/anuncios/meta/[org]/route.ts`).
*
* O que estes casos vigiam não é o casamento em si — é que o ref passa pelas
* MESMAS guardas do `[dk1:]`, e que o clique só é CONSUMIDO quando vai virar
* atribuição de verdade. Consumir fora da primeira mensagem queimaria o ref
* sem estampar ninguém, e o dono do clique nunca saberia por quê.
*/
describe("o ref curto da página que veio no texto", () => {
const REF = "[ref:K7M2P9]";
/** O mesmo `[dk1:]` do bloco acima, escrito aqui de forma independente. */
const DK1 = `[dk1:${Buffer.from(JSON.stringify({ utm_campaign: "dia-das-maes" }), "utf8").toString("base64url")}]`;
const nomesDeRpc = () => rpcChamadas.map((c) => c.nome);
it("casa o ref e estampa as UTMs guardadas no servidor", async () => {
await rodar({ texto: `Olá! Vim pelo site. ${REF}` });
const estampa = rpcChamadas.find((c) => c.nome === "fn_estampar_atribuicao_de_anuncio");
expect(estampa, "a origem do ref não foi estampada").toBeDefined();
expect(estampa?.args.p_platform).toBe("site");
const metadata = estampa?.args.p_metadata as Record<string, unknown>;
expect(metadata.utm_campaign).toBe("black-friday");
expect(metadata.utm_ad).toBe("video-depoimento-v3");
});
it("o consumo filtra por organização e por ref ainda não usado", async () => {
await rodar({ texto: `oi ${REF}` });
expect(filtrosDoRef.organization_id).toBe("org-1");
expect(filtrosDoRef.token).toBe("K7M2P9");
expect(filtrosDoRef.matched_at).toBeNull();
});
it("fora da primeira mensagem o ref NÃO é consumido", async () => {
historicoDoContato = { id: "outra-msg", count: 4 };
await rodar({ texto: `oi ${REF}` });
expect(sequencia).not.toContain("update:meta_ads_click_refs");
expect(nomesDeRpc()).not.toContain("fn_estampar_atribuicao_de_anuncio");
});
it("ref que não casa não estampa nada e a ingestão segue", async () => {
refCasado = null;
await expect(rodar({ texto: `oi ${REF}` })).resolves.toBeUndefined();
expect(nomesDeRpc()).not.toContain("fn_estampar_atribuicao_de_anuncio");
expect(garantirLeadDaConversa).toHaveBeenCalled();
});
it("com `[dk1:]` no texto, o ref nem vai ao banco", async () => {
// O `[dk1:]` se resolve sem consulta nenhuma. Ir ao banco assim mesmo
// consumiria um clique que ninguém pediu.
await rodar({ texto: `oi ${DK1} ${REF}` });
expect(sequencia).not.toContain("update:meta_ads_click_refs");
expect(nomesDeRpc()).toContain("fn_estampar_atribuicao_de_anuncio");
});
});