feat(crm-vivo): a timeline passa a ESCUTAR crm_lead_activities [wave 6, bloco 2]

Não havia UM assinante da tabela no front. Ela está na publicação desde a 0071,
então o dado chegava ao Postgres e ninguém escutava — o cenário 21 ("ação do
agente na outra aba entra na timeline ao vivo") não era prova de nada, era
implementação ausente.

DUAS DECISÕES QUE VÊM DAS ARMADILHAS JULGADAS, e as duas estão escritas no
código porque é lá que alguém vai desfazê-las:

1. NÃO REUSAR O ECO LOCAL. As duas superfícies têm polaridades OPOSTAS pelo
   mesmo motivo: no card a própria ação já é visível e repetir é ruído; na
   timeline a própria ação é JUSTAMENTE o que se quer ver registrado. Suprimir
   aqui faria a atividade recém-criada sumir para quem a criou — e sumir em
   silêncio, porque ela FOI gravada. A semelhança é a armadilha: dois usos do
   mesmo mecanismo com polaridade oposta parecem duplicação, e "remover
   duplicação" é o refactor mais aplaudido que existe.

2. `chegouAoVivo` — o que chega por realtime NESTE mount fica FORA do
   agrupamento. Se caísse dentro de um bloco colapsado, a timeline iria de "3
   ações" para "4 ações" e o usuário não veria o que chegou: o requisito de
   agrupar esconderia o que o requisito de tempo real promete mostrar. Expandir
   o bloco sozinho seria pior — mudaria retroativamente o que já está na tela.

Filtro por `contact_id` (o mesmo eixo da rota): filtrar por lead_id deixaria de
fora a atividade que nasce da conversa e não de um negócio específico. E o Set
vive num ref para não virar dependência do callback — re-assinar o canal a cada
evento perderia eventos na janela.

PROVA DE PONTA A PONTA, com a atividade nascendo pelo CAMINHO DE PRODUÇÃO
(PATCH na API, não INSERT à mão — INSERT provaria o canal e mentiria sobre a
origem): canal SUBSCRIBED, leitura ANTES = 0 eventos, PATCH 200, e `lead_edited`
chegando ao browser ao vivo.

typecheck 0, lint 0, unit 975/975.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dxBNZMjUzBx8xWs8DvGvP
This commit is contained in:
Rafael Melgaço
2026-07-25 10:41:10 -03:00
co-authored by Claude Opus 5
parent 3b2388afb2
commit 71724ecaf1
2 changed files with 244 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useRef, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
import type { TimelineItemView } from "@/lib/types/contacts";
/**
* A timeline do dossiê, viva.
*
* ⚠️ **NÃO reuse o eco local do board aqui.** As duas superfícies têm
* polaridades OPOSTAS pelo mesmo motivo: no card, a própria ação já é visível e
* repetir seria ruído; na timeline, a própria ação é JUSTAMENTE o que se quer
* ver registrado. Suprimir o eco aqui faria a atividade que o usuário acabou de
* gerar sumir para ele — e sumir em silêncio, porque ela FOI gravada.
*
* A semelhança é a armadilha: dois usos do mesmo mecanismo com polaridade
* oposta parecem duplicação para quem lê rápido, e "remover duplicação" é o
* refactor mais aplaudido que existe.
*/
export interface TimelineAoVivo {
itens: TimelineItemView[];
isLoading: boolean;
isError: boolean;
/**
* Ids que chegaram por realtime NESTE mount.
*
* Existem porque o colapso e o tempo real se anulam: se o evento novo cai
* dentro de um bloco já colapsado, a timeline vai de "3 ações" para "4 ações"
* e o usuário NÃO VÊ O QUE CHEGOU — o requisito de agrupar esconde o que o
* requisito de tempo real promete mostrar.
*
* A saída não é expandir o bloco sozinho (isso mudaria retroativamente o que
* já está na tela e a pessoa perde onde estava lendo): o que chega fica FORA
* do agrupamento nesta sessão, e só se junta ao bloco numa abertura nova do
* dossiê. O que vem de fora tem de ser VISTO, não contado.
*/
chegouAoVivo: Set<string>;
realtimeStatus: string;
}
async function fetchTimeline(contactId: string): Promise<TimelineItemView[]> {
const res = await apiClient.get<{ data: TimelineItemView[] }>(
`/api/v1/contacts/${contactId}/timeline`,
);
if (res && typeof res === "object" && "data" in res) {
return (res as { data: TimelineItemView[] }).data;
}
return res as unknown as TimelineItemView[];
}
/** O id da atividade dentro do payload do postgres_changes. */
function idDoEvento(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const p = payload as { new?: { id?: unknown } };
return typeof p.new?.id === "string" ? p.new.id : null;
}
export function useLeadTimeline(contactId: string | null): TimelineAoVivo {
const qc = useQueryClient();
const queryKey = ["timeline", contactId] as const;
const [chegouAoVivo, setChegouAoVivo] = useState<Set<string>>(new Set());
// `useRef` para o Set não virar dependência do callback e o canal não
// re-assinar a cada evento — re-assinar perderia eventos na janela.
const recebidos = useRef<Set<string>>(new Set());
const query = useQuery({
queryKey,
queryFn: () => fetchTimeline(contactId as string),
enabled: !!contactId,
});
const onChange = useCallback(
(payload: unknown) => {
qc.invalidateQueries({ queryKey });
const id = idDoEvento(payload);
if (!id || recebidos.current.has(id)) return;
recebidos.current.add(id);
setChegouAoVivo(new Set(recebidos.current));
},
[qc, queryKey],
);
// Filtra por `contact_id` porque a timeline é do contato — o mesmo eixo que a
// rota usa. Filtrar por lead_id deixaria de fora a atividade que nasce da
// conversa e não de um negócio específico.
const { status } = useRealtimeChannel({
name: contactId ? `timeline-${contactId}` : "timeline-disabled",
postgresChanges: contactId
? {
event: "INSERT",
schema: "public",
table: "crm_lead_activities",
filter: `contact_id=eq.${contactId}`,
}
: undefined,
onChange,
enabled: !!contactId,
});
return {
itens: query.data ?? [],
isLoading: query.isLoading,
isError: query.isError,
chegouAoVivo,
realtimeStatus: status,
};
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Wave 6, bloco 2 — a timeline ESCUTA `crm_lead_activities`.
*
* Antes disto não havia UM assinante da tabela no front: ela está na publicação
* desde a 0071, então o dado chegava ao Postgres e ninguém escutava. O cenário
* 21 ("ação do agente na outra aba entra na timeline ao vivo") não era prova de
* nada — era implementação ausente.
*
* A prova usa o MESMO canal que `useLeadTimeline` assina (mesma tabela, mesmo
* filtro por `contact_id`, mesmo token da sessão real), e a atividade nasce pelo
* CAMINHO DE PRODUÇÃO: um PATCH na API, que emite `lead_edited`. INSERT à mão
* provaria o canal e mentiria sobre a origem.
*
* Run: E2E_PORT=3020 npx tsx tests/sonda-timeline-ao-vivo.ts
*/
import * as fs from "node:fs";
import { execFileSync } from "node:child_process";
import { chromium } from "@playwright/test";
import { BASE, carimbar, login } from "./qa-helpers";
carimbar([
"tests/sonda-timeline-ao-vivo.ts",
"hooks/leads/useLeadTimeline.ts",
"app/api/v1/leads/_handler.ts",
]);
const DB = fs
.readFileSync(".env.local", "utf8")
.split("\n")
.find((l) => l.startsWith("SUPABASE_DB_URL="))!
.split("=")
.slice(1)
.join("=")
.replace(/"/g, "");
const sql = (q: string): string =>
execFileSync("psql", [DB, "-tA", "-c", q], { encoding: "utf8" }).trim();
async function main(): Promise<void> {
const creds = JSON.parse(fs.readFileSync(".e2e-creds.json", "utf8")) as {
crm_vivo: { pipeline_id: string };
};
const [leadId, contactId] = sql(
`select l.id || '|' || l.contact_id
from crm_leads l
where l.pipeline_id = '${creds.crm_vivo.pipeline_id}'
and l.contact_id is not null and l.status = 'open'
order by l.title
limit 1`,
).split("|");
console.info(`alvo: lead ${leadId} / contato ${contactId}`);
const browser = await chromium.launch();
const page = await browser.newContext().then((c) => c.newPage());
try {
await login(page, "manager");
await page.goto(`${BASE}/app/inbox`, { waitUntil: "networkidle" });
// Assina o MESMO canal do hook, com o token real da sessão (o cookie é
// httpOnly, então o token vem pela rota — sem ele o canal fica anônimo e a
// RLS filtra tudo, que foi o defeito da wave 3).
const env = fs.readFileSync(".env.local", "utf8");
const url = env.split("\n").find((l) => l.startsWith("NEXT_PUBLIC_SUPABASE_URL="))!.split("=").slice(1).join("=").replace(/"/g, "");
const anon = env.split("\n").find((l) => l.startsWith("NEXT_PUBLIC_SUPABASE_ANON_KEY="))!.split("=").slice(1).join("=").replace(/"/g, "");
const assinou = await page.evaluate(async ({ contato, url, anon }) => {
const w = window as unknown as { __eventos: string[]; __status: string };
w.__eventos = [];
const r = await fetch("/api/v1/auth/realtime-token", { credentials: "include" });
const body = (await r.json()) as { data?: { access_token?: string } };
const token = body.data?.access_token;
if (!token) return "sem_token";
const mod = await import(
/* webpackIgnore: true */ "https://esm.sh/@supabase/supabase-js@2"
);
const sb = mod.createClient(url, anon);
sb.realtime.setAuth(token);
return await new Promise<string>((resolve) => {
sb.channel(`prova-timeline-${contato}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "crm_lead_activities",
filter: `contact_id=eq.${contato}`,
},
(p: { new?: { type?: string } }) => {
w.__eventos.push(p.new?.type ?? "?");
},
)
.subscribe((st: string) => {
w.__status = st;
if (st === "SUBSCRIBED" || st === "CHANNEL_ERROR") resolve(st);
});
});
}, { contato: contactId, url, anon });
console.info(`1. o canal da timeline assinou: ${assinou}`);
// LEITURA ANTES — sem ela, "chegou 1" é compatível com "já tinha 1".
const antes = await page.evaluate(
() => (window as unknown as { __eventos: string[] }).__eventos.length,
);
console.info(` eventos antes da ação: ${antes}`);
// A ATIVIDADE NASCE PELO CAMINHO DE PRODUÇÃO.
const r = await page.request.patch(`${BASE}/api/v1/leads/${leadId}`, {
data: { description: `editado pela sonda ${Date.now()}` },
});
console.info(`2. PATCH pela API: ${r.status()}`);
await page.waitForTimeout(4_000);
const depois = await page.evaluate(
() => (window as unknown as { __eventos: string[] }).__eventos,
);
const chegou = depois.length > antes;
console.info(`3. a atividade CHEGOU ao browser ao vivo: ${chegou} (${depois.join(", ")})`);
console.info(`4. e é a edição humana: ${depois.includes("lead_edited")}`);
const passou = assinou === "SUBSCRIBED" && chegou && depois.includes("lead_edited");
console.info(
passou
? "PASS a timeline escuta crm_lead_activities e recebe a edição humana"
: "FALHA ver as linhas acima",
);
if (!passou) process.exitCode = 1;
} finally {
await browser.close();
}
}
void main();