mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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:
co-authored by
Claude Opus 5
parent
4a49386f2e
commit
2db62ad8a3
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user