mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
3b2388afb2
commit
71724ecaf1
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user