Merge remote-tracking branch 'origin/main' into fix-1892

# Conflicts:
#	app/api/v1/contacts/_handler.ts
This commit is contained in:
webtecnica
2026-09-28 20:07:08 -03:00
committed by melgarafael
56 changed files with 3795 additions and 157 deletions
@@ -0,0 +1,13 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Perguntar ao acervo direto da conversa, e a busca do atendente ganha gráfico próprio em Evolução
---
Quem atende ganha, no painel ao lado da conversa, uma caixa "Acervo" para perguntar sobre o material da própria empresa (as fontes que a IA usa nas respostas). A resposta traz os trechos que passaram no limiar, com a semelhança de cada um, e separa três situações que antes chegavam iguais: o acervo está vazio, a base não tem essa informação, ou há algo parecido abaixo do limiar. É a mesma busca e o mesmo limiar que a IA usa; não existe uma segunda régua para a tela.
Cada pergunta gasta uma chamada de embedding na chave da organização, então a caixa tem limite de 12 perguntas por pessoa e 60 por organização a cada minuto, e a pergunta vai até 1000 caracteres. Sem chave de embedding cadastrada, a caixa diz isso e aponta Credenciais.
A pergunta do atendente passa a ser registrada, e a tela de Evolução a mostra num gráfico próprio, "Consultas da equipe ao acervo". Ela não entra nos números do agente nem nas "perguntas de clientes sem resposta". Não há ação para quem opera a VPS: a migração é aditiva e roda sozinha na atualização.
Contribuição de @webtecnica (#1877).
@@ -0,0 +1,7 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O caminho de um arquivo enviado não sai mais da pasta da conversa por `..`
---
A conferência de que um arquivo pertence à conversa (`isMediaPathOwnedBy`) olhava só o começo do caminho, `{organização}/{conversa}/`. Um caminho como `{organização}/{conversa}/../../{outra}/arquivo` passava nessa conferência. Agora, depois do prefixo, só são aceitos nomes comuns: nada de `..`, `.`, segmento vazio ou barra invertida. A regra vale para o envio de mídia ao cliente e para o anexo da nota interna. Não foi medido se o Storage chegava a resolver o `..`; o conserto fecha a porta sem depender disso. Nada muda para quem opera a instalação.
@@ -0,0 +1,16 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Filtre a lista por várias etiquetas de uma vez — todas (E) ou qualquer uma (OU)
---
O filtro de etiqueta passa a aceitar mais de uma escolha nas três listas: Inbox, Funil e Contatos. Você marca quantas quiser no menu (o menu não fecha mais a cada clique) e escolhe o sentido:
- **Todas (E)** — a lista mostra só quem tem todas as etiquetas escolhidas, juntas na mesma caixa (na conversa ou no contato), que era o sentido de filtrar por duas e comparar na cabeça.
- **Qualquer uma (OU)** — a lista mostra quem tem pelo menos uma delas, em qualquer caixa.
O modo aparece no menu só quando há duas ou mais etiquetas, porque com uma ele não muda nada. O gatilho do filtro resume a escolha ("vip +1") e "Limpar filtros" continua limpando tudo. Os filtros continuam nos endereços: `?tag=vip&tag=orçamento` com `&modo=ou`, e qualquer link salvo ou chamada de API com uma etiqueta só segue funcionando igual, sem mudança.
Uma combinação ainda não é possível: "vip na conversa **e** orçamento no contato", misturando as caixas. Ela fica registrada como decisão de produto pendente — as duas caixas de hoje não a expressam, e o filtro não finge que expressa.
Contribuição de @webtecnica (#1274).
+7
View File
@@ -0,0 +1,7 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Relatório por etiqueta — volume, espera e desfecho de cada assunto no período
---
Quem opera agora pode perguntar à API **qual assunto ocupou a operação em um período e quanto tempo o cliente esperou**. `GET /api/v1/reports/tags` devolve, para cada etiqueta em uso, quantos atendimentos começaram no período, quantos ainda estão abertos e quantos foram encerrados, a espera média pela nossa resposta e a fatia de cada etiqueta sobre o total. A lista de etiquetas vem das que existem de fato nas conversas: uma etiqueta sem atendimento no período aparece com zero em vez de sumir, e um período sem dado nenhum diz isso na resposta em vez de devolver uma tabela de zeros. O pedido aceita `de`, `ate` (datas válidas, até 90 dias) e `tz`, porque a janela é contada no fuso de quem lê. Quando a janela tem mais conversas do que a leitura alcança, a resposta avisa que está cortada. É só leitura, sem migration e ainda sem tela: a tela vem depois. Contribuição de @webtecnica (#1888).
+2 -2
View File
@@ -176,9 +176,9 @@ export async function GET(req: NextRequest): Promise<Response> {
"created_at, outcome, intent_name",
"created_at",
),
ler<{ created_at: string; hits: number; top_score: number | null; threshold: number }>(
ler<{ created_at: string; hits: number; top_score: number | null; threshold: number; author_kind: string }>(
"knowledge_searches",
"created_at, hits, top_score, threshold",
"created_at, hits, top_score, threshold, author_kind",
"created_at",
),
ler<{ created_at: string; to_stage: string }>("lead_state_transitions", "created_at, to_stage", "created_at"),
+297
View File
@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { requireRole } from "@/lib/auth/require-role";
import type * as Embed from "@/lib/ai/embed";
/**
* POST /api/v1/ai/knowledge/busca — "perguntar ao acervo" pelo operador.
*
* O que este arquivo guarda não é o retrieval (isso é `busca.ts` e a RPC), e sim
* as três mentiras que a tela poderia contar:
*
* 1. **"acervo vazio" virar "nada encontrado".** Sem material publicado, a busca
* nem roda; dizer "não encontramos" faria o operador concluir que a base não
* sabe — quando ela está só vazia. São diagnósticos opostos.
* 2. **"quase achou" chegar igual a "não tem".** `melhorSimilaridade` existe
* exatamente para separar as duas: uma manda reformular, a outra manda perguntar
* para humano. Sem o número, a tela promete "sem resultado" para uma busca que
* passou raspando.
* 3. **`organization_id` vindo do corpo.** O contrato de `buscarConhecimento`
* exige fonte confiável; o corpo é fonte adulterável.
*
* `buscarConhecimento` roda DE VERDADE aqui — só o embedding e o RPC são trocados.
* Mockar a busca seria medir o mock.
*/
vi.mock("@/lib/ai/embed", async (original) => ({
// A classe de erro é a REAL: a rota decide o 409 por `instanceof`.
...(await original<typeof Embed>()),
embedText: vi.fn(async () => ({ embedding: new Array(1536).fill(0.1) })),
}));
vi.mock("@/lib/ai/dispatcher/rate-limit", () => ({
checkRateLimit: vi.fn(async (_chave: string, limit: number) => ({ allowed: true, count: 1, limit })),
}));
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/auth/server", () => ({
mfaEmDivida: vi.fn(async () => false),
loadAuthUser: vi.fn(),
resolveActiveOrg: vi.fn(),
}));
const ORG_DA_SESSAO = "22222222-2222-4222-8222-222222222222";
const ORG_MENTIROSAS_NO_CORPO = "99999999-9999-4999-9999-999999999999";
const FONTE = "66666666-6666-4666-8666-666666666666";
type Linha = { chunk_id: string; knowledge_source_id: string | null; source_name: string | null; content: string; similarity: number };
/** Supabase falso: `.from()` encadeável, `.rpc()` devolvendo o que o caso pedir
* e `.insert()` REGISTRANDO a linha — a F2 da #1869 grava telemetria, e sem
* registrar aqui não há como provar que a busca humana virou métrica. */
function supabaseFalso(opts: { fontes?: unknown[]; linhas?: Linha[]; insertFalha?: boolean } = {}) {
const rpcArgs: unknown[] = [];
const inserts: Record<string, unknown>[] = [];
const fontes = opts.fontes ?? [{ id: FONTE }];
const cadeia: Record<string, unknown> = {
select: () => cadeia,
eq: () => cadeia,
maybeSingle: async () => ({ data: null, error: null }),
insert: (linha: Record<string, unknown>) => {
inserts.push(linha);
// `insertFalha` simula o banco fora do ar: a telemetria é secundária e
// NÃO pode transformar uma busca que aconteceu em 500 (F2 da #1869).
if (opts.insertFalha) return Promise.reject(new Error("banco indisponivel"));
return Promise.resolve({ data: null, error: null });
},
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve({ data: fontes, error: null }).then(resolve),
};
const db = {
from: () => cadeia,
rpc: async (nome: string, args: unknown) => {
rpcArgs.push({ nome, args });
return { data: opts.linhas ?? [], error: null };
},
__rpc: rpcArgs,
__inserts: inserts,
};
return db;
}
function requisicao(corpo: unknown) {
return new NextRequest("http://local/api/v1/ai/knowledge/busca", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(corpo),
});
}
function resultadoDe(corpo: unknown) {
return POST(requisicao(corpo));
}
import { POST } from "./route";
import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { SemChaveDeEmbeddingError } from "@/lib/ai/embed";
import { createClient } from "@/lib/supabase/server";
beforeEach(() => {
// Sem isto os casos contam um ao outro: `embedText` acumulava as chamadas dos
// testes anteriores e "deve chamar 1x" virava "chamou 4x" — o defeito é do
// teste, não da rota. `clearAllMocks` zera as contagens e PRESERVA as
// implementações do `vi.mock` (seria `resetAllMocks` que as apagaria).
vi.clearAllMocks();
vi.mocked(requireRole).mockResolvedValue({
ok: true,
user: { id: "user-1", idioma: "pt" },
org: { orgId: ORG_DA_SESSAO },
response: new Response(),
} as never);
vi.mocked(createClient).mockReset();
});
describe("perguntar ao acervo", () => {
it("distingue acervo VAZIO de acervo sem a resposta", async () => {
// (a) nenhuma fonte ativa — o mais parecido possível de "não encontramos".
vi.mocked(createClient).mockResolvedValue(supabaseFalso({ fontes: [] }) as never);
const vazio = await resultadoDe({ pergunta: "qual o horario" });
const a = (await vazio.json()) as { data: { motivo: string; trechos: unknown[] } };
expect(a.data.trechos).toEqual([]);
expect(a.data.motivo).toContain("material publicado");
// (b) a base TEM material, mas não tem essa informação.
vi.mocked(createClient).mockResolvedValue(supabaseFalso({ linhas: [] }) as never);
const semResposta = await resultadoDe({ pergunta: "qual o horario" });
const b = (await semResposta.json()) as { data: { motivo: string; trechos: unknown[] } };
expect(b.data.trechos).toEqual([]);
expect(b.data.motivo).toContain("não tem essa informação");
expect(b.data.motivo).not.toContain("material publicado");
});
it("mostra que a base tem algo PORTE quando não passa no limiar", async () => {
vi.mocked(createClient).mockResolvedValue(
supabaseFalso({
linhas: [
{ chunk_id: "c1", knowledge_source_id: FONTE, source_name: "FAQ", content: "…", similarity: 0.31 },
],
}) as never,
);
const res = await resultadoDe({ pergunta: "qual o horario" });
const corpo = (await res.json()) as {
data: { trechos: unknown[]; melhorSimilaridade: number | null; motivo: string };
};
// 0,31 < 0,40 (limiar do caminho humano) — fica de fora…
expect(corpo.data.trechos).toEqual([]);
// …mas o número chega, e é ele que separa "reformule" de "peça ajuda".
expect(corpo.data.melhorSimilaridade).toBeCloseTo(0.31, 5);
expect(corpo.data.motivo).toContain("parecido");
});
it("pega a organização da SESSÃO, ignorando a do corpo", async () => {
const db = supabaseFalso({ linhas: [] });
vi.mocked(createClient).mockResolvedValue(db as never);
await resultadoDe({
pergunta: "qual o horario",
organization_id: ORG_MENTIROSAS_NO_CORPO,
organizationId: ORG_MENTIROSAS_NO_CORPO,
});
const { embedText } = await import("@/lib/ai/embed");
expect(vi.mocked(embedText)).toHaveBeenCalledWith(
"qual o horario",
expect.objectContaining({ organizationId: ORG_DA_SESSAO }),
);
const rpc = db.__rpc as Array<{ args: { p_organization_id: string } }>;
expect(rpc).toHaveLength(1);
expect(rpc[0]?.args.p_organization_id).toBe(ORG_DA_SESSAO);
});
it("apara a quantidade entre 1 e 10", async () => {
vi.mocked(createClient).mockResolvedValue(supabaseFalso({ linhas: [] }) as never);
await resultadoDe({ pergunta: "oi tudo bem?", quantidade: 99 });
const { embedText } = await import("@/lib/ai/embed");
expect(vi.mocked(embedText)).toHaveBeenCalledTimes(1);
vi.mocked(createClient).mockResolvedValue(supabaseFalso({ linhas: [] }) as never);
await resultadoDe({ pergunta: "oi tudo bem?", quantidade: -5 });
expect(vi.mocked(embedText)).toHaveBeenCalledTimes(2);
});
it("recusa pergunta de mais de 1000 caracteres antes de gastar embedding", async () => {
vi.mocked(createClient).mockResolvedValue(supabaseFalso() as never);
const res = await resultadoDe({ pergunta: "x".repeat(1001) });
expect(res.status).toBe(422);
const { embedText } = await import("@/lib/ai/embed");
expect(vi.mocked(embedText)).not.toHaveBeenCalled();
});
it("recusa agentId que não é uuid com 422, e não com 500 sem envelope", async () => {
vi.mocked(createClient).mockResolvedValue(supabaseFalso() as never);
const res = await resultadoDe({ pergunta: "qual o horario", agentId: "abc" });
expect(res.status).toBe(422);
});
it("devolve 429 quando o limite por minuto estoura, sem gastar embedding", async () => {
// Cada pergunta gasta um embedding pago na chave do self-hoster.
vi.mocked(checkRateLimit).mockResolvedValueOnce({ allowed: false, count: 13, limit: 12 } as never);
vi.mocked(createClient).mockResolvedValue(supabaseFalso() as never);
const res = await resultadoDe({ pergunta: "qual o horario" });
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const { embedText } = await import("@/lib/ai/embed");
expect(vi.mocked(embedText)).not.toHaveBeenCalled();
});
it("organização sem chave de embedding recebe 409 com o que fazer, não 500", async () => {
const { embedText } = await import("@/lib/ai/embed");
vi.mocked(embedText).mockRejectedValueOnce(new SemChaveDeEmbeddingError(ORG_DA_SESSAO));
vi.mocked(createClient).mockResolvedValue(supabaseFalso() as never);
const res = await resultadoDe({ pergunta: "qual o horario" });
expect(res.status).toBe(409);
const corpo = (await res.json()) as { error: { message: string } };
expect(corpo.error.message).toContain("Credenciais");
});
it("recusa pergunta de 1 caractere antes de gastar embedding", async () => {
vi.mocked(createClient).mockResolvedValue(supabaseFalso() as never);
const res = await resultadoDe({ pergunta: "a" });
expect(res.status).toBe(422);
const { embedText } = await import("@/lib/ai/embed");
expect(vi.mocked(embedText)).not.toHaveBeenCalled();
});
});
// ── F2 da #1869: a busca do OPERADOR vira métrica ─────────────────────────────
//
// B3 da issue: `knowledge_searches` só recebia o caminho do agente
// (`search-knowledge.ts:122`). O gráfico de /app/ai/evolution conta linhas SEM
// filtrar (aggregate.ts:201), então a linha humana aparece sozinha — mas só se
// alguém gravar. O que os casos abaixo medem é exatamente isso.
describe("telemetria da busca humana (F2 da #1869)", () => {
/** O insert é fire-and-forget: precisamos deixar a promise assentar. */
const assentar = () => new Promise((r) => setTimeout(r, 0));
it("grava author_kind=human com a ORG e o USUARIO da sessao", async () => {
const db = supabaseFalso();
vi.mocked(createClient).mockResolvedValue(db as never);
await resultadoDe({ pergunta: "qual o prazo de entrega" });
await assentar();
const inserts = db.__inserts as Array<Record<string, unknown>>;
expect(inserts).toHaveLength(1);
const linha = inserts[0]!;
expect(linha.author_kind).toBe("human");
// A organização vem da SESSÃO, jamais do corpo — mesma regra da própria
// rota (caso "pega a organização da SESSÃO"); gravar a do corpo deixaria a
// métrica de uma organização dentro da tabela de outra.
expect(linha.organization_id).toBe(ORG_DA_SESSAO);
expect(linha.author_user_id).toBe("user-1");
expect(linha.threshold).toBeTypeOf("number");
expect(linha.hits).toBeTypeOf("number");
});
it("grava mesmo quando a base NADA encontra (hits=0 é a métrica)", async () => {
// É o caso que a 0086 existe para medir: "a base não tem isso" precisa
// aparecer, senão o painel só vê as buscas que acertaram.
const db = supabaseFalso({ linhas: [] });
vi.mocked(createClient).mockResolvedValue(db as never);
await resultadoDe({ pergunta: "quem e o dono da conta" });
await assentar();
const inserts = db.__inserts as Array<Record<string, unknown>>;
expect(inserts).toHaveLength(1);
expect(inserts[0]!.hits).toBe(0);
});
it("a telemetria NUNCA derruba a busca (banco fora => resposta intacta)", async () => {
const db = supabaseFalso({ insertFalha: true });
vi.mocked(createClient).mockResolvedValue(db as never);
const res = await resultadoDe({ pergunta: "qual o horario de atendimento" });
await assentar();
// Sem o try/catch próprio da registrarBuscaHumana, a rejeição cairia no
// catch do handler e a tela diria "não foi possível consultar o acervo"
// para uma busca que CONTEÚM aconteceu — prometer e desmentir.
expect(res.status).toBe(200);
const corpo = (await res.json()) as { data?: { motivo?: string | null } };
expect(corpo.data?.motivo === undefined || corpo.data.motivo === null || typeof corpo.data.motivo === "string").toBe(true);
});
it("acervo sem material NÃO grava — não houve busca para medir", async () => {
// Decisão documentada na rota: o retorno antecipado de "acervo vazio"
// acontece ANTES de qualquer RPC. Gravaria uma linha de CONFIGURAÇÃO como
// se fosse uma pergunta, e o gráfico contaria como busca.
const db = supabaseFalso({ fontes: [] });
vi.mocked(createClient).mockResolvedValue(db as never);
await resultadoDe({ pergunta: "qual o prazo" });
await assentar();
const inserts = db.__inserts as Array<Record<string, unknown>>;
expect(inserts).toHaveLength(0);
});
});
+283
View File
@@ -0,0 +1,283 @@
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { z } from "zod";
import { ok, fail } from "@/lib/api/wrappers";
import { checkRateLimit } from "@/lib/ai/dispatcher/rate-limit";
import { SemChaveDeEmbeddingError } from "@/lib/ai/embed";
import { requireRole } from "@/lib/auth/require-role";
import { requireSupportWrite } from "@/lib/impersonate/support";
import {
KNOWLEDGE_SEARCH_AUTHOR_KINDS,
LIMIAR_PADRAO_BUSCA,
buscarConhecimento,
resolverAcervoDoAgente,
} from "@/lib/ai/knowledge/busca";
import { traduzir } from "@/lib/i18n/dicionario";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
/**
* "Perguntar ao acervo" — a superfície do OPERADOR sobre a MESMA busca que a IA faz.
*
* Não reimplementa retrieval: chama `buscarConhecimento`, que o próprio docblock
* define como "operação única, compartilhada". Duas implementações divergiriam em
* limiar e em top-K, e o sistema passaria a responder diferente para a IA e para o
* humano sobre o MESMO acervo — que é exatamente o defeito que a casa já corrigiu
* uma vez (limiares 0,40 / 0,72 / 0,72 unificados na migração 0097).
*
* `organization_id` sai da sessão autenticada (`requireRole`), NUNCA do corpo —
* mesma regra documentada em `lib/ai/knowledge/busca.ts`.
*
* Devolve `motivo` junto de um `trechos` possivelmente vazio, porque "a base não
* tem essa informação" e "a base tem algo perto, mas não o bastante" são situações
* que pedem ações opostas (reformular × perguntar para humano) e não podem chegar
* iguais a quem pergunta. Sem isso a tela promete "sem resultado" para uma busca
* que quase acertou.
*/
const QUANTIDADE_PADRAO = 6;
const QUANTIDADE_MAXIMA = 10;
/**
* Cada pergunta gasta um embedding pago na chave do self-hoster. Mesmo padrão
* da conversa do caso (`ai/cases/[id]/chat`): teto por PESSOA e por
* ORGANIZAÇÃO — "12 por pessoa" com 20 pessoas seria 240 chamadas por minuto.
*/
const TETO_POR_USUARIO = 12;
const TETO_POR_ORGANIZACAO = 60;
const JANELA_SEGUNDOS = 60;
const corpoDaBusca = z.object({
// `max` antes de gastar embedding: sem ele, um texto de megabytes vira
// tokens pagos numa única chamada.
pergunta: z.string().trim().min(2).max(1000),
agentId: z.string().uuid().nullish(),
// Tolerante de propósito: fora da faixa é aparado, não recusado (ver `numero`).
quantidade: z.unknown().optional(),
});
/** Converte o corpo sem confiar em tipo algum — qualquer coisa fora vira o default. */
function numero(v: unknown, padrao: number, min: number, max: number): number {
const n = typeof v === "number" ? v : Number(v);
if (!Number.isFinite(n)) return padrao;
return Math.min(max, Math.max(min, Math.floor(n)));
}
export async function POST(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
// A rota MUTA: a F2 da #1869 grava a pergunta em `knowledge_searches`.
// Sem esta guarda, o modo support_readonly seria ignorado por um handler que
// escreve — quem está acompanhando em leitura veria linhas novas nascendo
// métrica da própria instalação. `requireSupportWrite` (retorno não-nulo é a
// recusa) cuida do MODO DE ACOMPANHAMENTO; o `requireRole` abaixo cuida do
// PAPEL. Um não substitui o outro.
const support = await requireSupportWrite();
if (support) return support;
// Papel mínimo do inbox: um atendente lê conversa e lê acervo.
const authz = await requireRole("agent", { requestId, resource: "ai_knowledge" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
let bruto: unknown;
try {
bruto = await req.json();
} catch {
return fail("unprocessable", t("Corpo inválido."), 422, { requestId });
}
const parsed = corpoDaBusca.safeParse(bruto);
if (!parsed.success) {
return fail("unprocessable", t("Corpo inválido."), 422, {
requestId,
details: parsed.error.flatten(),
});
}
const { pergunta } = parsed.data;
const organizationId = authz.org.orgId;
const porUsuario = await checkRateLimit(
`acervo-busca:${authz.user.id}`,
TETO_POR_USUARIO,
JANELA_SEGUNDOS,
);
const porOrganizacao = await checkRateLimit(
`acervo-busca-org:${organizationId}`,
TETO_POR_ORGANIZACAO,
JANELA_SEGUNDOS,
);
if (!porUsuario.allowed || !porOrganizacao.allowed) {
return fail("rate_limited", t("Muitas perguntas seguidas. Tente em um minuto."), 429, {
requestId,
headers: {
"Retry-After": String(JANELA_SEGUNDOS),
"X-RateLimit-Limit": String(porUsuario.limit),
"X-RateLimit-Remaining": String(Math.max(0, porUsuario.limit - porUsuario.count)),
},
});
}
const supabase = await createClient();
const agentId = parsed.data.agentId ?? null;
const quantidade = numero(parsed.data.quantidade, QUANTIDADE_PADRAO, 1, QUANTIDADE_MAXIMA);
try {
let knowledgeSourceIds: string[];
let limiar = LIMIAR_PADRAO_BUSCA;
if (agentId) {
// Escopo do agente: mesmo acervo e MESMO limiar que a IA usaria para responder.
knowledgeSourceIds = await resolverAcervoDoAgente(supabase, organizationId, agentId);
const { data: agente } = await supabase
.from("ai_agents")
.select("config")
.eq("id", agentId)
.eq("organization_id", organizationId)
.maybeSingle();
const cfg = agente?.config as { rag_similarity_threshold?: unknown } | null;
// Mesma faixa que `agent-config.ts` aceita; fora dela o turno usa o padrão.
const lido = cfg?.rag_similarity_threshold;
if (typeof lido === "number" && lido >= 0 && lido <= 1) {
limiar = lido;
}
} else {
// Acervo da organização inteira — a biblioteca é da org; a escolha por
// assistente é do AGENTE, não do operador que está apenas perguntando.
const { data: fontes, error } = await supabase
.from("ai_knowledge_sources")
.select("id")
.eq("organization_id", organizationId)
.eq("is_active", true);
if (error) {
return fail("internal_error", t("Não foi possível ler o acervo."), 500, { requestId });
}
knowledgeSourceIds = (fontes ?? []).map((f) => f.id as string);
}
if (knowledgeSourceIds.length === 0) {
// Acervo vazio NÃO é "sem resultado": é acervo errado ou recém-criado.
// Dizer "nada encontrado" aqui seria mentir e soaria como defeito.
return ok(
{
trechos: [],
melhorSimilaridade: null,
motivo: t("Este acervo ainda não tem material publicado."),
acervo: { fontes: 0, limiar },
},
{ requestId },
);
}
const resultado = await buscarConhecimento(supabase, {
organizationId,
knowledgeSourceIds,
pergunta,
topK: quantidade,
limiar,
});
const vazio = resultado.trechos.length === 0;
const melhor = resultado.melhorSimilaridade;
// Três coisas diferentes chegam como "vazio" e pedem respostas opostas.
const motivo = vazio
? melhor === null
? t("A base não tem essa informação.")
: t("Há algo parecido no acervo, mas ainda abaixo do limiar — tente outras palavras.")
: null;
// (função logo abaixo, fora do handler — ela nunca pode derrubar a busca)
void registrarBuscaHumana(supabase, {
organizationId,
hits: resultado.trechos.length,
topScore: melhor,
threshold: limiar,
fontes: knowledgeSourceIds,
agentId: agentId ?? null,
userId: authz.user.id,
});
return ok(
{
trechos: resultado.trechos,
melhorSimilaridade: melhor,
motivo,
acervo: { fontes: knowledgeSourceIds.length, limiar },
},
{ requestId },
);
} catch (e) {
if (e instanceof SemChaveDeEmbeddingError) {
// Estado da organização, não acidente: a tela diz o que fazer.
return fail(
e.code,
t(
"Esta organização ainda não tem chave de embedding. Cadastre uma chave OpenAI ou OpenRouter em Credenciais para consultar o acervo.",
),
409,
{ requestId },
);
}
const msg = e instanceof Error ? e.message : String(e);
console.error("[ai-knowledge-busca] falhou:", msg);
return fail("internal_error", t("Não foi possível consultar o acervo."), 500, { requestId });
}
}
/**
* Grava a pergunta do OPERADOR em `knowledge_searches` — F2 da #1869.
*
* ## Por que existe
*
* Só o caminho do agente gravava (`search-knowledge.ts:122`). O gráfico de
* `/app/ai/evolution` conta linhas SEM filtrar (`aggregate.ts:201`), então uma
* linha humana aparece sozinha — mas só aparece se alguém gravar. `author_kind`
* (`'human'`) é o que a torna distinguível depois; `agent_id is null` não
* serviria, porque é `on delete set null` desde a 0181.
*
* ## Por que nunca pode derrubar a busca
*
* O `catch` do handler devolveria 500 "não foi possível consultar o acervo" para
* uma busca que CONTEÚM aconteceu e cujos trechos já estão na mão. É o defeito
* de "prometer primeiro e desmentir depois" que o docblock desta página avisa —
* só que no sentido inverso: aqui a tela mentiria sobre a própria falha.
*
* Mesma decisão do insert do agente (que é engolido de propósito lá, com o
* `warn` encurtado para não vazar a pergunta no log — esta tabela nunca guarda
* o texto, decisão da 0086).
*/
async function registrarBuscaHumana(
supabase: Awaited<ReturnType<typeof createClient>>,
p: {
organizationId: string;
hits: number;
topScore: number | null;
threshold: number;
fontes: string[];
agentId: string | null;
userId: string;
},
): Promise<void> {
try {
const { error } = await supabase.from("knowledge_searches").insert({
organization_id: p.organizationId,
hits: p.hits,
top_score: p.topScore,
threshold: p.threshold,
knowledge_source_ids: p.fontes,
// Guarda o ACERVO consultado, não "quem perguntou": com author_kind='human'
// a dupla lê "operador perguntou sobre o acervo do assistente X".
agent_id: p.agentId,
author_kind: KNOWLEDGE_SEARCH_AUTHOR_KINDS[0],
author_user_id: p.userId,
});
if (error) console.warn("[ai-knowledge-busca] telemetria não gravada:", error.message);
} catch (err) {
console.warn(
"[ai-knowledge-busca] telemetria não gravada:",
err instanceof Error ? err.message.slice(0, 120) : String(err).slice(0, 120),
);
}
}
+17 -1
View File
@@ -28,6 +28,7 @@ import type {
ContactListQueryParams,
} from "@/lib/schemas";
import { contactListQuerySchema } from "@/lib/schemas";
import { arrayDeUmValorParaOr } from "@/lib/inbox/marcador-da-conversa";
import { buscaValeConsulta, normalizarTermoDeBusca } from "@/lib/inbox/termo-de-busca";
type SB = SupabaseClient;
@@ -207,7 +208,22 @@ export async function listContactsHandler(
}
query = query.or(orParts.join(","));
}
if (q.tag) query = query.contains("tags", [q.tag]);
// ⚠️ E/OU (#1274). E e OU viraram DOIS textos, e o que os separa e o
// operador — a mesma régua do Inbox (`lib/inbox/marcador-da-conversa.ts`), com
// a diferença de que aqui existe UMA caixa só (`contacts.tags`).
//
// - E: `tags=cs.{a,b}` — `contains` com a LISTA, que o builder já sabe escrever.
// Uma etiqueta só continua `tags=cs.{a}`, byte a byte o que era antes.
// - OU: um `or=` com um `ov` por etiqueta. ⚠️ NÃO é `overlaps` repetido: o
// builder escreve `tags=ov.…` no MESMO parametro cada vez, e parâmetro
// repetido no PostgREST é E — que é o modo oposto com o nome de OU.
if (q.tag && q.tag.length > 1 && q.modo === "ou") {
query = query.or(
q.tag.map((marcador) => `tags.ov.${arrayDeUmValorParaOr(marcador)}`).join(","),
);
} else if (q.tag) {
query = query.contains("tags", q.tag);
}
if (q.source) query = query.eq("source", q.source);
if (q.cursor) {
+4 -1
View File
@@ -120,7 +120,10 @@ export async function GET(req: NextRequest): Promise<Response> {
const url = new URL(req.url);
const qsParsed = contactListQuerySchema.safeParse({
search: url.searchParams.get("search") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined,
// `getAll` (#1274): a repetição na URL soe viva pelo `getAll`. Um `get` leria
// so a primeira e a tela mostraria uma escolha que a lista ignora.
tag: url.searchParams.getAll("tag"),
modo: url.searchParams.get("modo") ?? undefined,
source: url.searchParams.get("source") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
+9 -2
View File
@@ -18,7 +18,7 @@ import type {
import type { Conversation } from "@/lib/types/messaging";
import { normalizarTermoDeBusca } from "@/lib/inbox/termo-de-busca";
import { ORDEM_DA_ESPERA, ehAFila } from "@/lib/inbox/comando-da-conversa";
import { aplicarMarcador } from "@/lib/inbox/marcador-da-conversa";
import { aplicarMarcadores } from "@/lib/inbox/marcador-da-conversa";
/**
* Prepara o termo digitado para viajar dentro de um `or=` do PostgREST.
@@ -219,7 +219,14 @@ export async function listConversationsHandler(
// A régua do marcador mora num lugar só (`lib/inbox/marcador-da-conversa.ts`),
// porque a segunda régua sempre diverge: foi assim que a contagem das abas
// passou a pedir uma coluna que não existe (#1223). Aqui ela é só aplicada.
if (q.tag) query = aplicarMarcador(query, q.tag);
//
// ⚠️ `aplicarMarcadores`, e o `modo` vai junto (#1274). O filtro passou a
// aceitar VÁRIAS etiquetas com E/OU, e `aplicarMarcadores` é quem sabe as
// duas coisas: que uma etiqueta só tem de sair byte a byte como antes, e que
// E (`cs`) e OU (`ov`) são operadores diferentes. Chamar `aplicarMarcador`
// aqui com a lista inteira faria o TypeScript aceitar e o filtro casar o
// ARRAY como se fosse um marcador só — lista vazia, sem erro.
if (q.tag) query = aplicarMarcadores(query, q.tag, q.modo);
// No BANCO, e não em memória: filtrar depois de paginar devolveria páginas curtas —
// e, quando a página inteira estivesse lida, uma lista vazia que a tela apresentava
+9 -3
View File
@@ -16,7 +16,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
import { CONVERSATION_TERMINAL_STATUSES } from "@/lib/schemas";
import { orgTemAutomatico } from "@/lib/ai/agents/org-tem-automatico";
import { comandosDaFila } from "@/lib/inbox/comando-da-conversa";
import { aplicarMarcador } from "@/lib/inbox/marcador-da-conversa";
import { aplicarMarcadores, modoDeEtiqueta } from "@/lib/inbox/marcador-da-conversa";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
@@ -94,7 +94,13 @@ export async function GET(req: NextRequest): Promise<Response> {
const sp = req.nextUrl.searchParams;
const auxiliares = filtrosAuxiliaresDaContagem(sp);
const soNaoLidas = contagemSoNaoLidas(sp);
const marcador = sp.get("tag");
// ⚠️ O badge conta o MESMO que a lista mostra, então o marcador é lido com
// `getAll` e o `modo` viaja junto (#1274). Ler com `get` aqui deixaria o badge
// de um filtro de duas etiquetas contando uma só — e a aba diria "Fila 3"
// listando duas: exatamente a divergência que este arquivo existe para
// impedir, agora pelo caminho do marcador em vez do de canal.
const marcadores = sp.getAll("tag");
const modo = modoDeEtiqueta(sp.get("modo")) ?? "e";
// ⚠️ TODA contagem nasce daqui, e daqui já sai com `organization_id` E com os
// filtros auxiliares. Herdar tira a opção de esquecer: não existe o caminho
@@ -106,7 +112,7 @@ export async function GET(req: NextRequest): Promise<Response> {
.eq("organization_id", org);
for (const [coluna, valor] of auxiliares) q = q.eq(coluna, valor);
// O marcador entra pela régua da LISTA — a mesma função, não uma segunda.
q = aplicarMarcador(q, marcador);
q = aplicarMarcadores(q, marcadores, modo);
if (soNaoLidas) q = q.gt("unread_count_for_assignee", 0);
return q;
};
+9 -1
View File
@@ -54,7 +54,15 @@ export async function GET(req: NextRequest): Promise<Response> {
// meio: `InboxFilters` mostra o select "Filtrar por tag" sempre que a org tem
// vocabulário, o browser manda `?tag=vip`, e a lista voltava inteira, sem erro.
// Achado por @jmpo, no cabeçalho do teste que ele escreveu no PR #199.
tag: url.searchParams.get("tag") ?? undefined,
//
// ⚠️ `getAll`, e não `get` (#1274): o filtro passou a aceitar VÁRIAS
// etiquetas, e a repetição na URL (`?tag=vip&tag=orçamento`) só existe para o
// `getAll`. Um `get` aqui leria só a PRIMEIRA e a tela mostraria uma escolha
// que a lista ignora — que é a MESMA classe de rotura silenciosa que a linha
// de cima documenta, e por isso a cerca `rota-le-todo-filtro-do-schema` cobre
// este filtro com a mesma regra.
tag: url.searchParams.getAll("tag"),
modo: url.searchParams.get("modo") ?? undefined,
unread: url.searchParams.get("unread") ?? undefined,
channel_session_id: url.searchParams.get("channel_session_id") ?? undefined,
// A aba "Grupos" (Task 10) — mesma rotura que `tag`/`comando` já tiveram
+485
View File
@@ -0,0 +1,485 @@
/**
* GET /api/v1/reports/tags — o RELATÓRIO POR ETIQUETA (fatia F1 da #1833):
* qual assunto ocupou a operação neste período, e quanto tempo ele esperou.
*
* ## Uma pergunta, três números — e nada além (invariante 5)
*
* `lib/reports/atividades.ts` já escreveu a régua na cabeça do próprio relatório:
* *"um relatório que mostra tudo não responde nada… número que não muda uma
* decisão é ruído"*. Por etiqueta saem só VOLUME (`conversas`), DESFECHO
* (`abertas`/`resolvidas`) e ESPERA (`espera_media_segundos`), mais `fatia` —
* volume já com denominador declarado, para a barra não inventar o seu.
* Canal, atendente, dia e valor ficam DE FORA: são outras perguntas, com outras
* rotas (`/metrics/attendants`, `/reports/activities`, `/metrics/lost`).
*
* ## Por que a fonte é `conversations.tags`, e não `crm_lead_activities`
*
* O relatório de atividades responde *"o que aconteceu, e quem fez — gente ou
* máquina"* a partir de `crm_lead_activities`, que **não tem coluna de conversa**
* (a issue mediu: só `actor_kind`, `actor_agent_id`, `evidence` e `reason` foram
* acrescentados). Grudar as duas perguntas numa tabela só inventaria um join que
* não existe. O relatório por etiqueta lê `conversations` — `tags text[]` com
* índice GIN (migration 0033), a mesma coluna que o operador preenche o dia
* inteiro e que até aqui não virava número nenhum.
*
* ## A dimensão vem do que EXISTE, nunca do que foi sugerido
*
* A lista de linhas sai de `fn_tags_de_conversa_em_uso(p_org)` (migration 0244,
* SECURITY INVOKER): é o que `git grep` do filtro já usa. A lista canônica
* (`organizations.settings.canonical_conversation_tags`) é semente de seletor, e
* a armadilha 1 da proposta é literalmente o relatório que devolve zero para
* etiqueta que ninguém usa enquanto a etiqueta visível na tela não aparece.
* Etiqueta em uso SEM conversa no período continua na lista, com `0` — sumir da
* lista seria o mesmo defeito com outra roupa.
*
* ## A espera: a coluna da Fila, e o que ela mede de fato
*
* `espera_media_segundos` parte de `awaiting_since`, a coluna que ordena a Fila
* (#990, migration 0267). Ela tem DOIS sentidos, e a média herda os dois:
* - com a bola na equipe, é a mensagem do cliente mais antiga sem resposta, e a
* espera corre até `agora` — só aqui a régua coincide com o `avg_wait_seconds`
* do painel, que olha quem está na fila;
* - depois de uma resposta, a coluna vira `last_inbound_at`
* (`fn_mark_conversation_message` e `messages/_handler.ts`), e a espera mede da
* ÚLTIMA mensagem do cliente até a nossa última resposta — não da primeira.
* Conversa ENCERRADA com mensagem do cliente sem resposta termina a espera em
* `service_closed_at`, nunca em `agora`: encerrar não mexe em `awaiting_since`, e
* contar até `agora` faria o relatório de um mês passado crescer a cada recarga.
* Sem `awaiting_since` (ou encerrada sem carimbo de encerramento) a conversa não
* entra na média: `null` é "não medido", e não `0` (a doutrina do
* `/metrics/atrito` proíbe zero onde o certo é —).
*
* **Isto NÃO é `first_human_out − first_in`**, a "1ª resposta" de
* `fn_attendant_metrics`. Essa exige ler `messages` e paginar a tabela inteira do
* período — outra fatia, com outro custo, e declarada aqui para não virar número
* que a tela lê com um nome que não é o seu.
*
* ## Escopo: a própria RLS, e por isso o client é o da SESSÃO
*
* Client de sessão + `.eq("organization_id", …)` explícito em toda leitura: a
* policy de `conversations` faz o recorte (agente em modo `own` vê só o próprio),
* e a doutrina manda o inquilino dito em voz alta em vez de terceirizado.
* Trocar pelo admin "porque é só leitura" derrubaria o recorte sem erro nenhum na
* tela. Read-only ⇒ sem audit: a doutrina cobre POST/PATCH/DELETE.
*
* ## Sem migração ⇒ a conta é aqui, e a paginação também
*
* F1 não cria função no banco, então a agregação roda na aplicação — e é por
* isso que a leitura paginar: o `max_rows = 1000` (`supabase/config.toml`) corta
* a resposta SEM avisar, e um total somado sobre uma página viria com cara de
* certo (a mesma medição que o `/reports/financeiro` registrou: R$ 141.436 em
* vez de R$ 641.103,60). Aqui o `count` exato diz o tamanho, as páginas andam
* com `ORDER BY` (sem ele o lote é arbitrário e muda com o plano) e o que não
* coube chega à tela como `truncado: true`, nunca como número exato.
*
* O corte: `sem_dados` com `motivo`, e não uma tabela de zeros — etiqueta com
* zero AINDA aparece quando o período tem dado, mas período sem nenhuma conversa
* com etiqueta não devolve linhas vazias fingindo que é relatório.
*/
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { fail, ok } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { STATUS_ENCERRADOS } from "@/lib/inbox/comando-da-conversa";
import { fusoValido } from "@/lib/reports/atividades";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
/**
* Quantos dias a janela pode cobrir. Mesma ordem de grandeza de
* `/reports/activities` (90): sem migração a leitura varre linhas, e um "desde
* sempre" chegaria pela query string de qualquer um.
*/
const DIAS_MAXIMOS = 90;
/**
* `max_rows = 1000` é o teto do SERVIDOR — um `.limit(5000)` devolve 1000 e não
* diz nada. Só `range` com `count` exato sabe o tamanho de verdade.
*/
const TAMANHO_DA_PAGINA = 1000;
const PAGINAS_MAXIMAS = 10;
const MAXIMO_DE_TAGS_PEDIDAS = 100;
const querySchema = z.object({
// `iso.date` confere o CALENDÁRIO, não só o formato: `2026-99-99` passava no
// regex, dava `NaN` dias, furava o teto de 90 e virava janela até 2034.
de: z.iso.date({ message: "Data inicial inválida." }).optional(),
ate: z.iso.date({ message: "Data final inválida." }).optional(),
/**
* Fuso de quem lê. A janela é DIÁRIA no fuso do leitor, e sem o fuso ela é
* diária em UTC: `?de=2026-09-01&tz=America/Sao_Paulo` começa à meia-noite de
* Brasília (03:00Z), e sem `tz` à meia-noite UTC — três horas de conversa
* nascem ou desaparecem conforme o país de quem olha.
*/
tz: z.string().min(1).max(64).default("UTC").refine(fusoValido, {
message: "Fuso horário desconhecido.",
}),
tags: z.string().max(4000).optional(),
});
interface ConversaBruta {
id: string;
organization_id: string;
tags: string[] | null;
status: string;
created_at: string;
service_started_at: string | null;
service_closed_at: string | null;
awaiting_since: string | null;
last_outbound_at: string | null;
}
/** O que a rota entrega por etiqueta — invariante 5: volume, espera, desfecho. */
interface LinhaDeEtiqueta {
etiqueta: string;
conversas: number;
abertas: number;
resolvidas: number;
/** `null` = nenhuma conversa da etiqueta tinha espera mensurável. */
espera_media_segundos: number | null;
/** 0–100, já arredondado — a barra não recalcula nem inventa denominador. */
fatia: number;
}
export async function GET(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
// Piso `viewer`: é leitura, e quem restringe por atendente é a RLS, não o
// papel — mesmo contrato de `/reports/activities`.
const authz = await requireRole("viewer", { requestId, resource: "reports" });
if (!authz.ok) return authz.response;
const { org: activeOrg } = authz;
const url = new URL(req.url);
const parsed = querySchema.safeParse({
de: url.searchParams.get("de") ?? undefined,
ate: url.searchParams.get("ate") ?? undefined,
tz: url.searchParams.get("tz") ?? undefined,
// `getAll` + junção: a lista pode chegar repetida (`?tags=a&tags=b`) ou
// separada por vírgula (`?tags=a,b`) — os dois formatos valem um só pedido.
tags: url.searchParams.getAll("tags").join(","),
});
if (!parsed.success) {
return fail("validation_failed", "Query inválida.", 422, {
details: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
requestId,
});
}
const { tz } = parsed.data;
const hoje = dataNoFuso(new Date(), tz);
const deTexto = parsed.data.de ?? `${hoje.slice(0, 7)}-01`;
const ateTexto = parsed.data.ate ?? hoje;
if (deTexto > ateTexto) {
// Invertido devolveria lista vazia, e lista vazia lê como "não houve
// atendimento" — a resposta errada mais convincente que este relatório dá.
return fail("validation_failed", "A data inicial é depois da final.", 422, { requestId });
}
const dias = diasDeJanela(deTexto, ateTexto);
if (dias > DIAS_MAXIMOS) {
return fail(
"validation_failed",
`Janela de ${dias} dias: o relatório por etiqueta cobre no máximo ${DIAS_MAXIMOS}.`,
422,
{ requestId },
);
}
const pedidas = etiquetasPedidas(parsed.data.tags);
if (pedidas.length > MAXIMO_DE_TAGS_PEDIDAS) {
return fail(
"validation_failed",
`Muitas etiquetas pedidas (${pedidas.length}): o teto é ${MAXIMO_DE_TAGS_PEDIDAS}.`,
422,
{ requestId },
);
}
// Semiaberta [de, até): o fim é o começo do dia SEGUINTE, para "ate=hoje"
// incluir as conversas de hoje inteiras.
const janela = {
de: inicioDoDia(deTexto, tz).toISOString(),
ate: inicioDoDia(proximoDia(ateTexto), tz).toISOString(),
};
const supabase = await createClient();
// ─── A DIMENSÃO ────────────────────────────────────────────────────────────
//
// `security invoker` + `p_org` da SESSÃO: quem passa o uuid de outra
// organização recebe zero linhas pelo banco, sem depender de a rota se
// comportar. O erro SOBE — engolir devolveria "sem dados" para um problema de
// leitura, que é a mentira mais cara deste relatório.
const { data: emUso, error: erroDimensao } = await supabase.rpc(
"fn_tags_de_conversa_em_uso",
{ p_org: activeOrg.orgId },
);
if (erroDimensao) return fail("internal_error", erroDimensao.message, 500, { requestId });
const dimensaoDoBanco = ((emUso ?? []) as Array<{ tag: string }>)
.map((linha) => linha.tag)
.filter((tag) => typeof tag === "string" && tag.length > 0);
// ─── AS CONVERSAS DO PERÍODO ───────────────────────────────────────────────
//
// A régua é "o ATENDIMENTO começou no período" (`service_started_at`), não
// `created_at`: a conversa é um fio único por contato e sessão de canal
// (`uniq_conversations_1to1_per_contact_session`), e quem volta reabre o MESMO
// fio com `service_started_at` novo e o `created_at` de quando falou pela
// primeira vez — por `created_at`, a reclamação de setembro de quem conversa
// desde junho sumiria de setembro. Fio sem atendimento carimbado (grupo, ou
// conversa que ainda não recebeu mensagem do cliente) cai em `created_at`.
// Limites declarados: o fio guarda só o ÚLTIMO começo, então um atendimento
// anterior de um fio reaberto depois do período conta no período da
// reabertura; e as etiquetas acumulam no fio, não por atendimento.
// Ordenado DESC: se a leitura for cortada, sobra o período mais RECENTE — um
// relatório que não completa o corte prefere mentir sobre o passado distante
// do que sobre a semana que o gestor está olhando.
const COLUNAS =
"id, organization_id, tags, status, created_at, service_started_at, service_closed_at, awaiting_since, last_outbound_at";
const naJanela =
`and(service_started_at.gte.${janela.de},service_started_at.lt.${janela.ate}),` +
`and(service_started_at.is.null,created_at.gte.${janela.de},created_at.lt.${janela.ate})`;
const conversas: ConversaBruta[] = [];
let totalNoBanco: number | null = null;
let paginaCheia = false;
for (let pagina = 0; pagina < PAGINAS_MAXIMAS; pagina++) {
const inicio = pagina * TAMANHO_DA_PAGINA;
const { data, error, count } = await supabase
.from("conversations")
.select(COLUNAS, { count: "exact" })
.eq("organization_id", activeOrg.orgId)
.or(naJanela)
.order("service_started_at", { ascending: false })
.order("created_at", { ascending: false })
.order("id", { ascending: false })
.range(inicio, inicio + TAMANHO_DA_PAGINA - 1);
if (error) return fail("internal_error", error.message, 500, { requestId });
if (totalNoBanco === null) totalNoBanco = count;
const lote = (data ?? []) as unknown as ConversaBruta[];
conversas.push(...lote);
paginaCheia = lote.length >= TAMANHO_DA_PAGINA;
if (!paginaCheia) break;
if (totalNoBanco !== null && conversas.length >= totalNoBanco) break;
}
const truncado =
totalNoBanco === null ? paginaCheia : conversas.length < totalNoBanco;
// ─── A CONTA ───────────────────────────────────────────────────────────────
const agora = Date.now();
const porEtiqueta = agregar(conversas, agora);
const chaves = [...new Set([...dimensaoDoBanco, ...porEtiqueta.keys(), ...pedidas])]
// Pedido filtra: quem pediu duas etiquetas não quer a lista inteira — mas a
// pedida que não tem conversa NENHUMA no período continua aqui (zerada).
.filter((tag) => pedidas.length === 0 || pedidas.includes(tag))
.sort((a, b) => a.localeCompare(b, "pt-BR"));
// O corte: sem linha nenhuma, a resposta DIZ que não há dados. Duas portas
// para a mesma ausência, e as duas são pergunta, não tabela.
if (chaves.length === 0) {
return ok(montarResposta(janela, tz, [], "nenhuma_etiqueta_em_uso", truncado), { requestId });
}
const linhas: LinhaDeEtiqueta[] = chaves.map((etiqueta) => {
const conta = porEtiqueta.get(etiqueta) ?? zerada();
return {
etiqueta,
conversas: conta.conversas,
abertas: conta.abertas,
resolvidas: conta.resolvidas,
espera_media_segundos:
conta.medidas > 0 ? Math.round(conta.somaEsperaMs / conta.medidas / 1000) : null,
fatia: 0,
};
});
const totalEtiquetagens = linhas.reduce((soma, l) => soma + l.conversas, 0);
if (totalEtiquetagens === 0) {
return ok(
montarResposta(janela, tz, [], "nenhuma_conversa_com_etiqueta_no_periodo", truncado),
{ requestId },
);
}
for (const linha of linhas) linha.fatia = fatiaDe(linha.conversas, totalEtiquetagens);
// O denominador é a SOMA das etiquetagens (uma conversa com duas etiquetas
// conta em duas linhas), então as fatias somam 100 exatos antes de arredondar
// — e o arredondamento não pode criar porcentagem que não existe: 101% num
// relatório é o mesmo defeito de barra que não fecha.
const excedente = linhas.reduce((soma, l) => soma + l.fatia, 0) - 100;
if (excedente > 0) {
const maior = linhas.reduce((a, b) => (a.fatia >= b.fatia ? a : b));
maior.fatia -= excedente;
}
// Volume primeiro: a pergunta é "qual assunto ocupou mais", e a leitura que
// começa em cima não pode obrigar a varrer a lista para achar o maior.
linhas.sort((a, b) => b.conversas - a.conversas || a.etiqueta.localeCompare(b.etiqueta, "pt-BR"));
return ok(montarResposta(janela, tz, linhas, null, truncado), { requestId });
}
interface Conta {
conversas: number;
abertas: number;
resolvidas: number;
somaEsperaMs: number;
medidas: number;
}
function zerada(): Conta {
return { conversas: 0, abertas: 0, resolvidas: 0, somaEsperaMs: 0, medidas: 0 };
}
/**
* Uma conversa de duas etiquetas conta em DUAS linhas: a pergunta é "deste
* assunto, quantas", e suprimir a segunda mentiria o volume dela para beneficiar
* o total. É por isso que o denominador de `fatia` é a soma das linhas.
*/
function agregar(conversas: ConversaBruta[], agora: number): Map<string, Conta> {
const mapa = new Map<string, Conta>();
for (const conversa of conversas) {
const espera = esperaMs(conversa, agora);
const encerrada = STATUS_ENCERRADOS.has(conversa.status);
for (const etiqueta of conversa.tags ?? []) {
if (!etiqueta) continue;
const conta = mapa.get(etiqueta) ?? zerada();
conta.conversas += 1;
if (encerrada) conta.resolvidas += 1;
else conta.abertas += 1;
if (espera !== null) {
conta.somaEsperaMs += espera;
conta.medidas += 1;
}
mapa.set(etiqueta, conta);
}
}
return mapa;
}
/**
* De `awaiting_since` até `last_outbound_at` quando respondemos depois dela.
* Sem resposta depois: aberta conta até `agora`; ENCERRADA termina em
* `service_closed_at` — ou `null` sem ele, nunca `agora`, que faria um período
* passado mudar a cada recarga. Sem `awaiting_since` não há régua — `null`, não
* zero. O que `awaiting_since` significa em cada caso está no cabeçalho.
*/
function esperaMs(conversa: ConversaBruta, agora: number): number | null {
const inicio = instante(conversa.awaiting_since);
if (inicio === null) return null;
const fim = instante(conversa.last_outbound_at);
if (fim !== null && fim >= inicio) return fim - inicio;
if (!STATUS_ENCERRADOS.has(conversa.status)) return Math.max(0, agora - inicio);
const encerrada = instante(conversa.service_closed_at);
return encerrada !== null && encerrada >= inicio ? encerrada - inicio : null;
}
function instante(texto: string | null): number | null {
if (!texto) return null;
const ms = Date.parse(texto);
return Number.isNaN(ms) ? null : ms;
}
function fatiaDe(quantidade: number, total: number): number {
if (total <= 0) return 0;
return Math.round((quantidade / total) * 100);
}
function montarResposta(
janela: { de: string; ate: string },
tz: string,
linhas: LinhaDeEtiqueta[],
motivo: string | null,
truncado: boolean,
) {
return {
// A régua junto do número: período e fuso saem junto da conta, porque
// número sem denominador declarado não muda decisão (03-medida-do-proposito).
janela: { ...janela, tz },
linhas,
total_etiquetagens: linhas.reduce((soma, l) => soma + l.conversas, 0),
sem_dados: linhas.length === 0,
motivo,
truncado,
};
}
function etiquetasPedidas(brutas: string | undefined): string[] {
const vistas = new Set<string>();
for (const pedida of (brutas ?? "").split(",")) {
const etiqueta = pedida.trim();
if (etiqueta) vistas.add(etiqueta);
}
return [...vistas];
}
/** Dias de calendário cobertos pela janela, contando os dois extremos. */
function diasDeJanela(de: string, ate: string): number {
const ms = Date.parse(`${ate}T00:00:00Z`) - Date.parse(`${de}T00:00:00Z`);
return Math.round(ms / 86_400_000) + 1;
}
function proximoDia(data: string): string {
const [ano, mes, dia] = data.split("-").map(Number) as [number, number, number];
return new Date(Date.UTC(ano, mes - 1, dia + 1)).toISOString().slice(0, 10);
}
function partesLocais(
instante: Date,
tz: string,
): { ano: number; mes: number; dia: number; hora: number; minuto: number; segundo: number } {
const formato = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const partes: Record<string, string> = {};
for (const parte of formato.formatToParts(instante)) partes[parte.type] = parte.value;
return {
ano: Number(partes.year),
mes: Number(partes.month),
dia: Number(partes.day),
hora: Number(partes.hour),
minuto: Number(partes.minute),
segundo: Number(partes.second),
};
}
function dataNoFuso(instante: Date, tz: string): string {
const p = partesLocais(instante, tz);
const dois = (n: number) => String(n).padStart(2, "0");
return `${p.ano}-${dois(p.mes)}-${dois(p.dia)}`;
}
/**
* `2026-09-01` em `tz` → o instante UTC do começo daquele dia LOCAL.
*
* Duas passadas porque uma só erra na fronteira de horário de verão: o
* deslocamento medido em `T00:00Z` pode não ser o do instante que sobra depois
* de subtraído. Recusar fuso inválido já aconteceu antes (`fusoValido`): a
* entrada é do navegador e vai para `Intl`, que levanta exceção com nome torto.
*/
function inicioDoDia(data: string, tz: string): Date {
const pretendido = Date.parse(`${data}T00:00:00Z`);
const primeira = deslocamentoDe(pretendido, tz);
const tentativa = pretendido - primeira;
const segunda = deslocamentoDe(tentativa, tz);
return new Date(segunda === primeira ? tentativa : pretendido - segunda);
}
function deslocamentoDe(instanteUtcMs: number, tz: string): number {
const p = partesLocais(new Date(instanteUtcMs), tz);
const localComoUtc = Date.UTC(p.ano, p.mes - 1, p.dia, p.hora, p.minuto, p.segundo);
return localComoUtc - instanteUtcMs;
}
+17
View File
@@ -525,6 +525,23 @@ function Conteudo({ payload }: { payload: NonNullable<ReturnType<typeof useEvolu
}
/>
</div>
{/* Série SEPARADA de propósito (#1877): a busca de quem atende não é
trabalho do agente nem pergunta de cliente, e somá-la ao gráfico
acima faria o painel mentir sobre os dois. */}
<GraficoDiario
titulo={t("Consultas da equipe ao acervo")}
significa={t(
"Quantas vezes alguém da equipe perguntou ao acervo pela caixa ao lado da conversa. Não entra nos números do agente.",
)}
dados={activity.series.knowledge_searches_equipe}
cor="hsl(32 95% 44%)"
vazio={
<Vazio
texto={t("Ninguém da equipe consultou o acervo pela conversa neste período.")}
acoes={[{ href: "/app/inbox", label: t("Ver conversas") }]}
/>
}
/>
<div className="grid gap-3 md:grid-cols-2">
<Ranking
titulo={t("Habilidades mais usadas")}
+75 -10
View File
@@ -8,9 +8,12 @@ import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -20,6 +23,7 @@ import { PontoDaEtiqueta } from "@/components/tags/PontoDaEtiqueta";
import { NewContactDialog } from "@/components/contacts/NewContactDialog";
import { ImportContactsDialog } from "@/components/contacts/ImportContactsDialog";
import { TAG_DE_CLIENTE } from "@/lib/contacts/cliente";
import { type ModoDeEtiqueta } from "@/lib/inbox/marcador-da-conversa";
import { useActiveOrg } from "@/hooks/auth/AuthProvider";
import { MergeDialog } from "@/components/contacts/MergeDialog";
import { EmptyContacts } from "@/components/empty";
@@ -49,7 +53,11 @@ export function ContactsListClient() {
const clientesLigado = useActiveOrg()?.cliente_pela_agenda === true;
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [tag, setTag] = useState<string | undefined>(undefined);
// VÁRIAS etiquetas com E/OU (#1274). O estado é a LISTA, e uma etiqueta só é
// a lista de um — o que faz "nenhuma escolha" e "vip escolhida" passarem pelo
// mesmo caminho, e impede a segunda forma de nascer daqui.
const [tags, setTags] = useState<string[]>([]);
const [tagMode, setTagMode] = useState<ModoDeEtiqueta | undefined>(undefined);
const [source, setSource] = useState<string | undefined>(undefined);
const [orderBy, setOrderBy] = useState<ContactOrderBy>("last_activity_at");
const [orderDir, setOrderDir] = useState<"asc" | "desc">("desc");
@@ -64,8 +72,16 @@ export function ContactsListClient() {
}, [searchInput]);
const filters = useMemo(
() => ({ search, tag, source, order_by: orderBy, order_dir: orderDir, limit }),
[search, tag, source, orderBy, orderDir, limit],
() => ({
search,
tag: tags.length > 0 ? tags : undefined,
tagMode,
source,
order_by: orderBy,
order_dir: orderDir,
limit,
}),
[search, tags, tagMode, source, orderBy, orderDir, limit],
);
const q = useContactList(filters);
@@ -155,19 +171,67 @@ export function ContactsListClient() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={tagOptions.length === 0}>
{tag ? <PontoDaEtiqueta tag={tag} className="mr-2" /> : null}
{tag ? `${t("Tag")}: ${tag}` : `${t("Tag")}: ${t("todas")}`}
{tags[0] ? <PontoDaEtiqueta tag={tags[0]} className="mr-2" /> : null}
{/* Resumo, e não a lista inteira: o gatilho tem a largura do filtro de
origem ao lado. Uma etiqueta mostra o nome; duas mostram a
primeira e o resto em contagem. */}
{tags.length === 0
? `${t("Tag")}: ${t("todas")}`
: tags.length === 1
? `${t("Tag")}: ${tags[0]}`
: `${t("Tag")}: ${tags[0]} +${tags.length - 1}`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>{t("Tag")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setTag(undefined)}>{t("Todas")}</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setTags([]);
setTagMode(undefined);
}}
>
{t("Todas")}
</DropdownMenuItem>
{/* O E/OU so aparece com DUAS etiquetas: com uma so o parametro nao
muda o resultado, e um controle que nao muda nada e pior do que
nenhum. */}
{tags.length > 1 && (
<>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={tagMode === "ou" ? "ou" : "e"}
onValueChange={(modo) => setTagMode(modo === "ou" ? "ou" : undefined)}
>
<DropdownMenuRadioItem value="e">{t("Todas (E)")}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="ou">{t("Qualquer uma (OU)")}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
)}
<DropdownMenuSeparator />
{/* `DropdownMenuCheckboxItem` marca e NAO fecha o menu — e o
`onSelect` com `preventDefault` trava esse comportamento, porque
o item de checkbox fecha por padrao. Sem isso, escolher a segunda
etiqueta exigiria reabrir o menu. */}
{tagOptions.map((tagOption) => (
<DropdownMenuItem key={tagOption} onClick={() => setTag(tagOption)}>
<DropdownMenuCheckboxItem
key={tagOption}
checked={tags.includes(tagOption)}
onCheckedChange={() => {
const escolhida = tags.includes(tagOption);
const proximas = escolhida
? tags.filter((et) => et !== tagOption)
: [...tags, tagOption];
setTags(proximas);
// O modo so faz sentido com DUAS: com uma so ele nao muda o
// resultado, e o `&modo=ou` na URL seria ruido.
setTagMode(proximas.length > 1 ? tagMode : undefined);
}}
onSelect={(e) => e.preventDefault()}
>
<PontoDaEtiqueta tag={tagOption} className="mr-2" />
{tagOption}
</DropdownMenuItem>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -204,14 +268,15 @@ export function ContactsListClient() {
</DropdownMenuContent>
</DropdownMenu>
{(search || tag || source) && (
{(search || tags.length > 0 || source) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchInput("");
setSearch("");
setTag(undefined);
setTags([]);
setTagMode(undefined);
setSource(undefined);
}}
>
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useT } from "@/hooks/i18n/useT";
import { apiClient } from "@/lib/api/client";
import { ApiError } from "@/lib/api/types";
/**
* "Perguntar ao acervo" — o operador faz na tela a MESMA pergunta que a IA faria.
*
* Não há busca própria aqui: o componente só pergunta e mostra. Quem decide limiar,
* top-K e o que contar quando não acha é a rota, que chama `buscarConhecimento` —
* a operação única que a IA também usa. Duas buscas aqui passariam a responder
* diferente sobre o mesmo acervo, que é o defeito que a casa já corrigiu uma vez.
*
* O `motivo` é obrigatório na tela. Quando não há trecho, o operador precisa
* distinguir três coisas que, sem o número, chegam iguais:
* - acervo vazio (está errado ou recém-criado → não é "a base não sabe");
* - algo parecido abaixo do limiar (reformule a pergunta);
* - a base realmente não tem (peça para humano).
* Mostrar "nenhum resultado" para as três seria prometer e desmentir depois.
*/
type Trecho = {
chunk_id: string;
source_name?: string | null;
content: string;
similarity: number;
};
type Resultado = {
trechos: Trecho[];
melhorSimilaridade: number | null;
motivo: string | null;
acervo: { fontes: number; limiar: number };
};
/** Similaridade de cosseno em [0,1] → percentual legível de verdade. */
function percentual(n: number): string {
return `${Math.round(Math.max(0, Math.min(1, n)) * 100)}%`;
}
export function AcervoSearch() {
const t = useT();
const [pergunta, setPergunta] = useState("");
const [carregando, setCarregando] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const [resultado, setResultado] = useState<Resultado | null>(null);
const pronta = pergunta.trim().length >= 2;
async function perguntar() {
if (!pronta || carregando) return;
setCarregando(true);
setErro(null);
try {
const res = await apiClient.post<{ data: Resultado }>("/api/v1/ai/knowledge/busca", {
pergunta: pergunta.trim(),
});
setResultado(res.data);
} catch (e) {
// 409 (sem chave de embedding) e 429 (limite por minuto) trazem uma frase
// que diz o que fazer, já traduzida pela rota. O resto fica genérico:
// mostrar stack para o atendente só polui — mas NÃO ficamos em silêncio.
setErro(
e instanceof ApiError && (e.status === 409 || e.status === 429) && e.message
? e.message
: t("Não consegui consultar o acervo."),
);
setResultado(null);
} finally {
setCarregando(false);
}
}
return (
<div className="space-y-3" data-testid="inbox-acervo">
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void perguntar();
}}
>
<Input
value={pergunta}
onChange={(e) => setPergunta(e.target.value)}
placeholder={t("Pergunte ao acervo…")}
aria-label={t("Perguntar ao acervo")}
data-testid="acervo-pergunta"
className="h-8 text-xs"
/>
<Button
type="submit"
size="sm"
variant="secondary"
disabled={!pronta || carregando}
data-testid="acervo-buscar"
>
{carregando ? t("Buscando…") : t("Buscar")}
</Button>
</form>
{erro && (
<p className="text-xs text-destructive" data-testid="acervo-erro">
{erro}
</p>
)}
{carregando && (
<div className="space-y-2" data-testid="acervo-carregando">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-4/5" />
</div>
)}
{!carregando && resultado && (
<div className="space-y-3">
{resultado.trechos.length > 0 ? (
<ul className="space-y-2" data-testid="acervo-trechos">
{resultado.trechos.map((tr) => (
<li
key={tr.chunk_id}
className="rounded-md border border-border bg-surface p-2 text-xs"
data-testid="acervo-trecho"
>
<div className="mb-1 flex items-center justify-between gap-2">
{tr.source_name && (
<Badge variant="outline" className="truncate text-[10px]">
{tr.source_name}
</Badge>
)}
<span
className="shrink-0 text-muted-foreground"
title={t("Semelhança com a pergunta")}
>
{percentual(tr.similarity)}
</span>
</div>
<p className="whitespace-pre-wrap break-words text-foreground">{tr.content}</p>
</li>
))}
</ul>
) : (
// O motivo é obrigatório: ver o item do docblock no topo.
<p className="text-xs text-muted-foreground" data-testid="acervo-motivo">
{resultado.motivo ?? t("A base não tem essa informação.")}
</p>
)}
<p className="text-[10px] text-muted-foreground" data-testid="acervo-resumo">
{t("Materiais consultados")}: {resultado.acervo.fontes} ·{" "}
{t("limiar")}: {percentual(resultado.acervo.limiar)}
</p>
</div>
)}
{!carregando && !resultado && !erro && (
<p className="text-xs text-muted-foreground" data-testid="acervo-dica">
{t("A mesma busca que a IA faz — com a mesma origem de cada trecho.")}
</p>
)}
</div>
);
}
+16
View File
@@ -1,6 +1,7 @@
"use client";
import { RoteirosDoContato } from "@/components/contacts/RoteirosDoContato";
import { AcervoSearch } from "./AcervoSearch";
import { LeadEnrichment } from "./LeadEnrichment";
import type { ProspectEnrichment } from "@/lib/prospecting/schema";
import { useAuth } from "@/hooks/auth/AuthProvider";
@@ -911,6 +912,21 @@ export function CRMSidePanel({ conversation }: Props) {
<SemLista vazio="Sem atividade." erro={erro} onTentarDeNovo={() => setTentativa((n) => n + 1)} />
)}
</section>
<Separator />
{/* Perguntar ao acervo — a MESMA busca que a IA faz, com a origem de cada
trecho. Não é busca própria: o componente só pergunta e mostra, e quem
decide limiar/top-K é a rota, que chama `buscarConhecimento`. Colocada
DEPOIS das seções de trabalho: é consulta, não é o que o atendente abre
a conversa para fazer. */}
<section>
<h3 className="text-xs font-semibold">{t("Acervo")}</h3>
<p className="mt-1 mb-2 text-xs text-muted-foreground">
{t("Pergunte como a IA perguntaria — a resposta vem com a origem de cada trecho.")}
</p>
<AcervoSearch />
</section>
</aside>
);
}
+155 -41
View File
@@ -12,9 +12,24 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChipDeEtiqueta } from "@/components/tags/ChipDeEtiqueta";
import { PontoDaEtiqueta } from "@/components/tags/PontoDaEtiqueta";
import { channelLabel, useChannelSessions } from "@/hooks/channels/useChannelSessions";
import {
type ModoDeEtiqueta,
marcadoresEscolhidos,
} from "@/lib/inbox/marcador-da-conversa";
import { useAuth } from "@/hooks/auth/AuthProvider";
import { useContactTagVocabulary } from "@/hooks/contacts/useContactTagVocabulary";
import { useConversationTagVocabulary } from "@/hooks/inbox/useConversationTags";
@@ -56,7 +71,17 @@ export interface InboxFiltersValue {
search: string;
onlyUnread: boolean;
channel_session_id?: string;
tag?: string;
/**
* A etiqueta escolhida, ou VÁRIAS (#1274).
*
* `string` continua aceito e continua significando a MESMA coisa: é o que o
* `InboxLayout`, o deep-link e qualquer chamada antiga produzem. Uma etiqueta
* só nunca tem dois sentidos, porque o caminho singular da régua
* (`aplicarMarcador`) é o mesmo de antes — byte a byte.
*/
tag?: string | readonly string[];
/** E ou OU entre as etiquetas escolhidas (#1274). `e` é o padrão. */
tagMode?: ModoDeEtiqueta;
/** A aba "Grupos" (Task 10): manda `is_group=true` na listagem. */
onlyGroups?: boolean;
}
@@ -152,12 +177,21 @@ export function InboxFilters({ value, onChange }: Props) {
),
[tagsDeConversa, tagsDeContato],
);
// A lista de etiquetas escolhida, normalizada pelo MESMO caminho do servidor
// (`marcadoresEscolhidos`): sem vazio, sem repetido, com a ordem da primeira
// aparição. Duas fontes de verdade para "quantas etiquetas estão escolhidas"
// fariam a tela mostrar um filtro e a lista aplicar outro.
const etiquetas = useMemo(
() => marcadoresEscolhidos(typeof value.tag === "string" ? [value.tag] : (value.tag ?? [])),
[value.tag],
);
// Os MESMOS filtros que a lista aplicou. Badge que conta o que a aba não mostra
// manda o atendente procurar trabalho que não existe — a regra já estava escrita
// na rota; faltava alcançar os filtros ao lado da aba.
const { data: counts } = useConversationCounts(activeOrg?.orgId ?? null, {
unread: value.onlyUnread,
tag: value.tag,
tag: etiquetas,
tagMode: value.tagMode,
channel_session_id: value.channel_session_id,
});
@@ -234,11 +268,40 @@ export function InboxFilters({ value, onChange }: Props) {
// vocabulário vazio, e é justamente ela que precisa do seletor de volta para
// desfazer o filtro que continua valendo.
const vocabularioConhecido = tagVocabulary != null || ultimoVocabulario.length > 0;
const tagForaDoVocabulario =
value.tag != null &&
vocabularioConhecido &&
!vocabularioDoSeletor.includes(value.tag);
const mostrarSeletorDeTag = vocabularioDoSeletor.length > 0 || tagForaDoVocabulario;
// ⚠️ A VALIDAÇÃO DO FILTRO ÓRFÃO PASSOU A SER SOBRE A LISTA (#1274). Com uma
// etiqueta só, "está no vocabulário" é uma pergunta; com VÁRIAS, é outra: basta
// uma das escolhidas ter sumido do vocabulário para o operador precisar da
// válvula. O sintoma sem isto seria o pior dos dois: um filtro de duas
// etiquetas, uma delas apagada, e a tela sem dizer que há filtro nenhum.
// ⚠️ `&&` AQUI DEVOLVERIA `false | string[]`, e `false.length` não existe. A
// forma é um ternário que devolve SEMPRE lista: o resto do componente só
// precisa do comprimento, e um `false` no meio obrigaria cada uso a checar.
const etiquetasForaDoVocabulario =
etiquetas.length > 0 && vocabularioConhecido
? etiquetas.filter((tag) => !vocabularioDoSeletor.includes(tag))
: [];
const mostrarSeletorDeTag =
vocabularioDoSeletor.length > 0 || etiquetasForaDoVocabulario.length > 0;
// O menu não fecha a cada clique: quem escolhe duas etiquetas não pode ter de
// reabrir o menu entre a primeira e a segunda, e o `DropdownMenuCheckboxItem`
// é o item que NÃO fecha (o `Select` de hoje fecha). A regra é do componente,
// e por isso o gatilho é um botão com `aria-expanded` em vez de um `Select`.
const opcoesDoSeletor = [
...vocabularioDoSeletor,
...etiquetasForaDoVocabulario,
];
const alternaEtiqueta = (tag: string) => {
const escolhida = etiquetas.includes(tag);
const proximas = escolhida ? etiquetas.filter((t) => t !== tag) : [...etiquetas, tag];
onChange({
...value,
tag: proximas.length === 0 ? undefined : proximas,
// O `modo` só faz sentido com DUAS: ao voltar para uma etiqueta só, ele
// sai, porque `?tag=vip&modo=ou` é um link que não significa nada e
// polui a URL (e a chave de cache do react-query) à toa.
tagMode: proximas.length > 1 ? value.tagMode : undefined,
});
};
// O timer lê o valor MAIS RECENTE, não o do render em que foi agendado.
//
@@ -370,40 +433,91 @@ export function InboxFilters({ value, onChange }: Props) {
)}
{mostrarSeletorDeTag && (
<Select
value={value.tag ?? "all"}
onValueChange={(v) => onChange({ ...value, tag: v === "all" ? undefined : v })}
>
<SelectTrigger
className={cn(
"h-8 min-w-0 flex-1 rounded-full border-transparent bg-surface-elevated px-3 text-xs shadow-none",
value.tag != null && "border-accent bg-accent-soft text-accent",
)}
aria-label={t("Filtrar por tag")}
>
{/* O gatilho mostra o CHIP da etiqueta filtrada, e não o texto
cru: é a mesma cor que a lista mostra ao lado, e é o que
faz o filtro ativo se reconhecer de relance — mesma razão
do `border-accent` acima. Sem filtro, o texto continua
sendo o de sempre (`Todas as tags`). */}
<SelectValue placeholder={t("Todas as tags")}>
{value.tag ? (
<ChipDeEtiqueta tag={value.tag} className="h-5 px-1.5 text-[11px]" />
<DropdownMenu>
{/*
⚠️ POR QUE ISTO DEIXOU DE SER UM `Select` (#1274).
O `Select` do Radix é de escolha ÚNICA e — o que mata a
multi-seleção — FECHA o menu a cada item escolhido. Para uma
etiqueta só isso era certo; para duas, o operador teria de
reabrir o menu entre a primeira e a segunda, e o custo do
segundo clique é o que faz a feature parecer idiota. O
`DropdownMenuCheckboxItem` marca e NÃO fecha, que é a
diferença entre um filtro de duas etiquetas e um formulário.
O gatilho continua com `aria-label="Filtrar por tag"` e a MESMA
aparência de cápsula, porque quem procura este controle no
Inbox (e o teste `inbox-filtro-de-tag-nao-desmonta`, que
vigia a desmontagem) não pode ver o filtro mudar de figura.
*/}
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"h-8 min-w-0 flex-1 truncate rounded-full border border-transparent bg-surface-elevated px-3 text-left text-xs shadow-none",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
etiquetas.length > 0 && "border-accent bg-accent-soft text-accent",
)}
aria-label={t("Filtrar por tag")}
>
{etiquetas.length > 0 ? (
<span className="inline-flex items-center gap-1">
{/* O CHIP da primeira etiqueta + o resto resumido: a coluna
é de 280 px e três chips não cabem. A cor continua sendo
a mesma que a lista mostra ao lado — mesma razão do
`border-accent`, que é o que faz o filtro ativo se
reconhecer de relance. */}
<ChipDeEtiqueta tag={etiquetas[0]!} className="h-5 px-1.5 text-[11px]" />
{etiquetas.length > 1 && (
<span className="tabular-nums text-[11px]">+{etiquetas.length - 1}</span>
)}
</span>
) : (
t("Todas as tags")
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("Todas as tags")}</SelectItem>
{/* A órfã entra na lista: sem ela o Select mostraria o
placeholder no lugar do valor JÁ selecionado, e o operador
veria "Todas as tags" com um filtro ativo. */}
{[
...vocabularioDoSeletor,
...(tagForaDoVocabulario && value.tag ? [value.tag] : []),
].map((tag) => (
<SelectItem key={tag} value={tag}>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>{t("Todas as tags")}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onChange({ ...value, tag: undefined, tagMode: undefined })}
>
{t("Todas as tags")}
</DropdownMenuItem>
{/*
O E/OU só aparece havendo DUAS etiquetas. Com uma só o parâmetro
não muda o resultado, e um botão que não muda nada é um
controle morto — a mesma razão pela qual o seletor some quando
a organização não tem vocabulário.
*/}
{etiquetas.length > 1 && (
<>
<DropdownMenuSeparator />
{/* Rádio, e não item comum: marca o modo ATIVO (e só ele) e
expõe `aria-checked` a quem usa leitor de tela. */}
<DropdownMenuRadioGroup
value={value.tagMode === "ou" ? "ou" : "e"}
onValueChange={(modo) =>
onChange({ ...value, tagMode: modo === "ou" ? "ou" : undefined })
}
>
<DropdownMenuRadioItem value="e">{t("Todas (E)")}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="ou">
{t("Qualquer uma (OU)")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
)}
<DropdownMenuSeparator />
{/* As órfãs entram na lista: sem elas o gatilho mostraria o
resumo de um filtro cujas opções não estão mais lá, e o
operador não teria como tirá-las. */}
{opcoesDoSeletor.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={etiquetas.includes(tag)}
onCheckedChange={() => alternaEtiqueta(tag)}
onSelect={(e) => e.preventDefault()}
>
{/* Ponto, não chip: a opção é uma linha de 280 px que já
divide espaço com o filtro de número. O nome continua
sendo o que se lê; a cor só acelera o reconhecimento
@@ -412,10 +526,10 @@ export function InboxFilters({ value, onChange }: Props) {
<PontoDaEtiqueta tag={tag} />
{tag}
</span>
</SelectItem>
</DropdownMenuCheckboxItem>
))}
</SelectContent>
</Select>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
+2
View File
@@ -234,6 +234,7 @@ export function InboxLayout({ initialSelectedId = null, rascunho = null }: Inbox
: undefined,
channel_session_id: filterValue.channel_session_id,
tag: filterValue.tag,
tagMode: filterValue.tagMode,
unread: filterValue.onlyUnread || undefined,
is_group: filterValue.onlyGroups || undefined,
}),
@@ -243,6 +244,7 @@ export function InboxLayout({ initialSelectedId = null, rascunho = null }: Inbox
filterValue.search,
filterValue.channel_session_id,
filterValue.tag,
filterValue.tagMode,
filterValue.onlyUnread,
filterValue.onlyGroups,
],
+63 -6
View File
@@ -5,9 +5,12 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -20,6 +23,7 @@ import { marcadoresDoCard } from "@/lib/kanban/marcadores-do-card";
import { OwnerBadge } from "./OwnerBadge";
import {
agentOwnerFilter,
marcadoresDoFiltro,
parseAgentOwnerFilter,
type LeadFilters,
} from "@/lib/kanban/filters";
@@ -129,7 +133,30 @@ export function FilterBar({ filters, onChange, leads, settings }: FilterBarProps
STATUS_OPTIONS.find((o) => o.value === (filters.status ?? "all"))?.label ?? "Todos",
);
const tagLabel = filters.tag ?? t("Tag: todas");
// ⚠️ O RÓTULO DO GATILHO RESUME, E NÃO CORTA O FILTRO (#1274). O nome da
// primeira etiqueta e o resto viram contagem: o que importa na tela é que há
// filtro com DUAS etiquetas, e não qual é a segunda — ela está no menu, com a
// caixa marcada. Uma etiqueta só mostra o nome dela, como sempre.
const marcadoresEscolhidos = marcadoresDoFiltro(filters.tag);
const tagLabel =
marcadoresEscolhidos.length === 0
? t("Tag: todas")
: marcadoresEscolhidos.length === 1
? `${t("Tag")}: ${marcadoresEscolhidos[0]}`
: `${t("Tag")}: ${marcadoresEscolhidos[0]} +${marcadoresEscolhidos.length - 1}`;
const alternaEtiqueta = (tag: string) => {
const escolhida = marcadoresEscolhidos.includes(tag);
const proximas = escolhida
? marcadoresEscolhidos.filter((m) => m !== tag)
: [...marcadoresEscolhidos, tag];
onChange({
...filters,
tag: proximas.length === 0 ? undefined : proximas,
// O modo só faz sentido com DUAS: `?tag=vip&modo=ou` é um link que não
// significa nada, e a chave de cache/url mudaria à toa.
tagMode: proximas.length > 1 ? filters.tagMode : undefined,
});
};
/**
* Motivo e categoria da perda (issue #1537). As opções vêm do que ESTÁ no
@@ -289,20 +316,50 @@ export function FilterBar({ filters, onChange, leads, settings }: FilterBarProps
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={tagOptions.length === 0}>
{filters.tag ? <PontoDaEtiqueta tag={filters.tag} className="mr-2" /> : null}
{marcadoresEscolhidos[0] ? (
<PontoDaEtiqueta tag={marcadoresEscolhidos[0]} className="mr-2" />
) : null}
{tagLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onChange({ ...filters, tag: undefined })}>
<DropdownMenuItem
onClick={() => onChange({ ...filters, tag: undefined, tagMode: undefined })}
>
{t("Todas")}
</DropdownMenuItem>
{/* O E/OU só aparece com DUAS etiquetas. Com uma só o parâmetro não
muda o resultado — e um controle que não muda nada é pior do que
nenhum. */}
{marcadoresEscolhidos.length > 1 && (
<>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={filters.tagMode === "ou" ? "ou" : "e"}
onValueChange={(modo) =>
onChange({ ...filters, tagMode: modo === "ou" ? "ou" : undefined })
}
>
<DropdownMenuRadioItem value="e">{t("Todas (E)")}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="ou">{t("Qualquer uma (OU)")}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
)}
<DropdownMenuSeparator />
{/* Checkbox, e não item comum: `DropdownMenuCheckboxItem` marca e NÃO
fecha o menu, que é o que permite escolher a segunda etiqueta sem
reabrir o menu. O `onSelect` com `preventDefault` trava esse
comportamento, porque o item de checkbox fecha por padrão. */}
{tagOptions.map((tag) => (
<DropdownMenuItem key={tag} onClick={() => onChange({ ...filters, tag })}>
<DropdownMenuCheckboxItem
key={tag}
checked={marcadoresEscolhidos.includes(tag)}
onCheckedChange={() => alternaEtiqueta(tag)}
onSelect={(e) => e.preventDefault()}
>
<PontoDaEtiqueta tag={tag} className="mr-2" />
{tag}
</DropdownMenuItem>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
@@ -323,7 +380,7 @@ export function FilterBar({ filters, onChange, leads, settings }: FilterBarProps
{(filters.search ||
filters.owner ||
filters.tag ||
marcadoresEscolhidos.length > 0 ||
filters.overdueOnly ||
filters.lostReason ||
filters.lostCategory ||
+16
View File
@@ -1269,6 +1269,22 @@ os recursos que dependem do servidor em tela nenhuma.
(regra no `page.tsx`, sem spec); telefonia por SIP é "não dá para ver daqui" —
ela vive nos contêineres, fora do alcance do app.
## J36 — Perguntar ao acervo sem sair da conversa `[P1]` (2026-09-28)
**Origem:** #1869 (F1+F2), contribuição de @webtecnica no #1877. O atendente
consulta o material da empresa pela caixa "Acervo" no painel da conversa, com a
mesma busca que a IA usa; a pergunta vira linha em `knowledge_searches` com
`author_kind='human'`, e a Evolução a mostra num gráfico próprio.
| Caso | Spec | Estado |
|---|---|---|
| Atendente abre a conversa, pergunta na caixa "Acervo" e recebe o diagnóstico (acervo vazio, ou 409 de chave ausente), nunca o erro genérico | `tests/e2e/busca-na-conversa.spec.ts` | CI (PARTE_3) |
| 429 por pessoa/organização, pergunta > 1000 caracteres, agentId não-uuid, 409 sem chave | `app/api/v1/ai/knowledge/busca/route.test.ts` | unit |
| Busca da equipe fora das lacunas do agente e numa série própria | `lib/ai/evolution/aggregate.test.ts` | unit |
**Não coberto:** a busca com material indexado e chave de embedding real (nenhum
e2e do CI tem chave); o gráfico "Consultas da equipe ao acervo" em tela.
## Jornadas exercitadas (instalação final, virgem)
| Jornada | Resultado |
+94
View File
@@ -0,0 +1,94 @@
# 1274 — SABOTAGEM PREVISTA, escrita ANTES do fix
Escopo: filtro por VÁRIAS etiquetas (E/OU) nas três listas (Inbox, Funil, Contatos),
com `?tag=` singular preservado.
Data da escrita: **antes** de qualquer linha do fix. Regra do time: uma previsão escrita
depois do verde é racionalização, não prova.
## As seis sabotagens, e o que CADA uma tem de reprovar
| # | Sabotagem | Teste que tem de ficar VERMELHO | Por que este teste é o certo |
|---|---|---|---|
| S1 | Apagar o ramo `modo === "ou"` e deixar o E cair no caminho de OU (bug de copy-paste) | `tests/unit/filtro-multi-etiqueta.test.ts` → caso "OU com duas etiquetas" | E e OU com DUAS etiquetas produzem o MESMO `or=(tags.cs.{a},tags_do_contato.cs.{a},…)` byte a byte quando o ramo se perde. A diferença só aparece no OPERADOR (`ov` vs `cs`) e no TERMO ÚNICO da disjunção. Só um teste que compara as DUAS formas byte a byte pega. |
| S2 | Trocar `tags.cs.{a,b},tags_do_contato.cs.{a,b}` por `tags.cs.{a},tags.cs.{b},tags_do_contato…` no modo E | mesmo arquivo → caso "E com duas etiquetas" | O `cs` de um valor só cada é OU; "vip E orçamento" viraria "vip OU orçamento" e a lista cresceria. O teste afirma o LITERAL do array com as duas etiquetas em cada caixa. |
| S3 | Deletar a linha `modo` do `_handler.ts` (`aplicarMarcadores(query, q.tag, q.modo)`) | `tests/unit/filtro-multi-etiqueta.test.ts` → caso "o handler aplica o modo que veio da URL" | `modo` tem Default (`"e"`), então TypeScript NÃO reclama de omitir: a assinatura continua válida e a UI escolher "OU" simplesmente filtraria por E, sem erro. Só um teste que passa `modo: "ou"` ao handler e compara o `or=` emitido pega. |
| S4 | Voltar o schema a `tag: conversationTagSchema.optional()` (sem `string[]`) | mesmo arquivo → caso "o schema aceita `?tag=vip&tag=orçamento`" | Com o schema singular, `getAll` devolve array e o `safeParse` RECUSA com 422: a tela de multi-seletor ficaria vermelha na cara do operador. O teste passa o array ao schema e afirma que passa. |
| S5 | Trocar a compatibilidade do `?tag=` singular: fazer o caminho de TAMANHO 1 passar pelo plural e produzir `cs.{vip}` por outro caminho | mesmo arquivo → caso "`?tag=vip` continua byte a byte o `or=` de antes" + `tests/unit/inbox-filtro-de-tag-le-as-duas-caixas.test.ts` (já existente) | O singularity é o CONTRATO de hoje (link salvo, aba aberta, chamada de API). A regressão é silenciosa: a lista volta quase toda, porque `cs.{vip}` e `ov.{vip}` casam a mesma conversa — o sintoma é "o filtro parou de filtrar", e nenhum teste de contagem nota. O teste afirma IGUALDADE BYTE A BYTE com a forma singular de sempre. |
| S6 | No Funil, deixar `applyFilters` casando `cardTemMarcadores` com a lista INTEIRA em vez do modo (ou seja, ignorar `tagMode`) | `tests/unit/filtro-multi-etiqueta.test.ts` → caso "funil: E e OU dão listas diferentes" + `tests/unit/funil-filtro-de-tag-le-as-duas-caixas.test.ts` (existente) | O funil filtra no CLIENTE, sem erro e sem 422: a diferença entre E e OU é só o TAMANHO da lista, e ninguém lê o sintoma. Comparar as duas listas com o MESMO conjunto de leads é o que torna isso vermelho. |
## O que NÃO é sabotagem (e por quê não entra na lista)
- **Migration nova.** A #1274 não pede coluna: o filtro é sobre `conversations.tags` (0033) e
o campo calculado `tags_do_contato` (0323), que já aceitam "contém todos" e "sobrepõe".
Uma migration aqui seria mudança de schema sem pedido — proibido pelo escopo desta fatia.
- **Mixing de caixas** ("vip na conversa E orçamento no contato"). A issue registra isso
como decisão de produto pendente, e é a razão de o E ser "mesma caixa". Sabotar isso
seria implementar o que a issue NÃO pediu.
- **Teto de 20 etiquetas.** Acima de 20 nenhum marcador escrito hoje seria filtrável
(é o mesmo teto de `conversationTagsSchema`), então o limite não tira nada de quem filtra.
## Como cada uma é executada
Cada sabotagem é uma troca EXATA de texto num arquivo de produção, aplicada por um
script que (1) falha em voz alta se o texto velho não existir mais, (2) copia o
arquivo antes, (3) roda a suíte-alvo por `dk-heavy.sh`, (4) restaura o arquivo da
cópia. O script roda UMA vez só, com as seis sabotagens em sequência, e no fim
roda a suíte de novo: o `rc=0` final é a prova de que nenhum arquivo ficou
sabotado. Sem o medido, a tabela acima é apenas uma intenção — e uma intenção não
é prova de cobertura.
Comando (sempre fora do terminal do gateway):
```
export PATH=/root/.hermes/node/bin:$PATH
/root/workspace/bin/dk-heavy.sh /root/workspace/wt-dk1274 onda4-1274-sab2 \
'python3 /root/.hermes/profiles/webtecnica/cache/scratch/sab-1274.py'
/root/workspace/bin/dk-heavy.sh --wait onda4-1274-sab2 280
```
Suíte-alvo de cada sabotagem: `tests/unit/filtro-multi-etiqueta.test.ts` +
`tests/unit/funil-filtro-de-tag-le-as-duas-caixas.test.ts`.
## RESULTADO MEDIDO (2026-09-28, rc lido do `/tmp/dk-onda4-1274-sab2.log`)
**BASE sem sabotagem: rc=0. APÓS restaurar as seis: rc=0.** As seis sabotagens:
rc=1 VERMELHO, e em todas o teste MIRADO foi o que quebrou.
| # | rc | O teste mirado quebrou | Outros testes que também ficaram vermelhos |
|---|---|---|---|
| S1 | 1 (VERMELHO) | SIM — `E com DUAS etiquetas: \`cs\` com a LISTA nas DUAS caixas` | `E e OU com as MESMAS etiquetas só diferem no OPERADOR`; `conversas, modo E: um \`or=\` só, com \`cs\` e as DUAS etiquetas num literal` (6 no total) |
| S2 | 1 (VERMELHO) | SIM — `E com DUAS etiquetas` | 12 testes, incluindo `conversas, modo E` e `conversas, modo OU` |
| S3 | 1 (VERMELHO) | SIM — `conversas, modo OU: o MESMO literal com o operador \`ov\`` | só ele (2 contagens do mesmo caso) |
| S4 | 1 (VERMELHO) | SIM — `a repetição na URL vira LISTA no schema` | `\`?tag=vip\` (um só) continua sendo ACEITO`; `o marcador é normalizado item a item`; `\`modo\` fora dos dois é RECUSADO (422)`; `CONTROLE: mais etiquetas que o teto é recusado` (10 no total) |
| S5 | 1 (VERMELHO) | SIM — `UMA etiqueta pelo caminho plural é IDÊNTICA ao caminho singular` | `conversas, uma etiqueta só: continua o \`or=\` singular de sempre` (4 no total) |
| S6 | 1 (VERMELHO) | SIM — `OU devolve quem tem QUALQUER uma — e a lista CRESCE` | `CONTROLE: a diferença entre E e OU é o TAMANHO`; **`E do funil NÃO aceita mistura de caixas`** (6 no total) |
### Duas correções sobre o que estava previsto acima (a previsão é de antes do fix)
1. **S3 — o caso previsto não existe.** A tabela previa o caso "o handler aplica o
modo que veio da URL"; ele nunca foi escrito com esse nome. O caso que cumpre a
mesma função é `conversas, modo OU: o MESMO literal com o operador \`ov\``, do
bloco novo do handler, e foi ele que quebrou.
2. **S5 — a sabotagem como estava escrita é um APAGÃO, não uma troca.** Fazer o
caminho de tamanho 1 passar pelo plural NÃO muda o byte: `listaDeValoresParaOr(["vip"])`
produz exatamente `arrayDeUmValorParaOr("vip")`, porque a lista de um é o
literal de um item. Trocar só o caminho deixaria o teste verde — e um teste
verde sob sabotagem não é prova. A sabotagem EXECUTADA muda o OPERADOR da forma
singular (`cs` → `ov` no predicado do marcador), que é a mesma classe de
regressão (o `?tag=vip` deixa de ser o de sempre) e sim é detectável: o caso
byte a byte e o caso do handler ficaram vermelhos.
**Corolário para quem vier depois:** a delegação de tamanho 1 para a função
singular é redundante por construção — quem a remover não é pegue por este
arquivo, e sim pelo operador. O que segura o contrato é a comparação byte a
byte com a função singular, não o desvio de caminho.
### Um achado que a sabotagem S6 revelou (e que o fix já tinha corrigido)
Quando o `passaMarcador` do funil foi sabotado para `every` sobre a união das três
caixas, ficaram vermelhos NÃO só os casos de E/OU, mas também
`E do funil NÃO aceita mistura de caixas`. É esse o caso: a primeira versão desta
fatia (escrita antes desta medição) fazia exatamente aquele `every` — aceitava
"vip na conversa E orçamento no negócio", que o servidor recusa. O teste que
cobriu a mistura de caixas é o que transformou um defeito silencioso (duas telas
com respostas diferentes para o mesmo filtro) em vermelho.
+21 -2
View File
@@ -4,6 +4,10 @@ import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import type { ContactOrderBy } from "@/lib/schemas/contacts";
import type { Contact } from "@/lib/types/contacts";
import {
type ModoDeEtiqueta,
marcadoresEscolhidos,
} from "@/lib/inbox/marcador-da-conversa";
interface ListResponse {
data: Contact[];
@@ -12,7 +16,16 @@ interface ListResponse {
export interface ContactListFilters {
search?: string;
tag?: string;
/**
* A etiqueta, ou VÁRIAS (#1274).
*
* `string` continua aceito porque é o que a tela e qualquer chamada antiga
* produzem; a lista sai na URL por `append` porque é a repetição que a rota lê
* com `getAll`.
*/
tag?: string | readonly string[];
/** E ou OU entre as etiquetas escolhidas (#1274). `e` e o padrao. */
tagMode?: ModoDeEtiqueta;
source?: string;
order_by?: ContactOrderBy;
order_dir?: "asc" | "desc";
@@ -26,7 +39,13 @@ export function useContactList(filters: ContactListFilters) {
queryFn: async ({ pageParam }) => {
const qs = new URLSearchParams();
if (filters.search) qs.set("search", filters.search);
if (filters.tag) qs.set("tag", filters.tag);
for (const marcador of marcadoresEscolhidos(
typeof filters.tag === "string" ? [filters.tag] : (filters.tag ?? []),
))
qs.append("tag", marcador);
// So `ou` viaja: `e` e o padrao, e um `&modo=e` colado num link de hoje
// mudaria a URL sem mudar o sentido do filtro.
if (filters.tagMode === "ou") qs.set("modo", "ou");
if (filters.source) qs.set("source", filters.source);
if (filters.order_by) qs.set("order_by", filters.order_by);
if (filters.order_dir) qs.set("order_dir", filters.order_dir);
+22 -2
View File
@@ -1,6 +1,10 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import {
type ModoDeEtiqueta,
marcadoresEscolhidos,
} from "@/lib/inbox/marcador-da-conversa";
export interface ConversationCounts {
/**
@@ -24,7 +28,17 @@ export interface ConversationCounts {
/** Os filtros auxiliares ligados na barra, que a contagem tem de aplicar junto. */
export interface FiltrosDaContagem {
unread?: boolean;
tag?: string;
/**
* A etiqueta, ou VÁRIAS (#1274).
*
* O badge conta o MESMO que a lista mostra, então ele recebe a MESMA lista. E
* `append`, nunca `set`: com `set` a segunda etiqueta substituiria a primeira e
* o badge contaria um filtro diferente do que a lista aplicou — a divergência
* que o módulo inteiro existe para impedir.
*/
tag?: string | readonly string[];
/** E ou OU entre as etiquetas escolhidas (#1274). `e` é o padrão. */
tagMode?: ModoDeEtiqueta;
channel_session_id?: string;
}
@@ -38,7 +52,13 @@ export function useConversationCounts(
) {
const qs = new URLSearchParams();
if (filtros.unread) qs.set("unread", "true");
if (filtros.tag) qs.set("tag", filtros.tag);
for (const marcador of marcadoresEscolhidos(
typeof filtros.tag === "string" ? [filtros.tag] : (filtros.tag ?? []),
))
qs.append("tag", marcador);
// Só `ou` viaja: `e` é o padrão, e mandar `&modo=e` num link de hoje mudaria a
// URL sem mudar o sentido do filtro.
if (filtros.tagMode === "ou") qs.set("modo", "ou");
if (filtros.channel_session_id) qs.set("channel_session_id", filtros.channel_session_id);
const sufixo = qs.toString();
+24 -2
View File
@@ -5,6 +5,10 @@ import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
import { useRefetchDeSeguranca } from "@/hooks/realtime/useRefetchDeSeguranca";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import {
type ModoDeEtiqueta,
marcadoresEscolhidos,
} from "@/lib/inbox/marcador-da-conversa";
import type { Conversation } from "@/lib/types/messaging";
import type { ComandoDoBanco } from "@/lib/inbox/comando-da-conversa";
@@ -93,7 +97,17 @@ export interface ConversationsFilters {
*/
unread?: boolean;
channel_session_id?: string;
tag?: string;
/**
* A etiqueta, ou VÁRIAS (#1274).
*
* `string` continua aceito porque é o que o resto da tela (e qualquer chamada
* antiga) produz. A lista sai na URL por `append`, nunca por `set` — com
* `set`, a segunda etiqueta substituiria a primeira e a tela mostraria duas
* escolhas filtrando por uma.
*/
tag?: string | readonly string[];
/** E ou OU entre as etiquetas escolhidas (#1274). `e` é o padrão. */
tagMode?: ModoDeEtiqueta;
/** A aba "Grupos" do inbox (Task 10). Ausente = sem filtro, mostra tudo. */
is_group?: boolean;
}
@@ -132,7 +146,15 @@ export function useConversationsRealtime(
if (filters.search) qs.set("search", filters.search);
if (filters.unread) qs.set("unread", "true");
if (filters.channel_session_id) qs.set("channel_session_id", filters.channel_session_id);
if (filters.tag) qs.set("tag", filters.tag);
// `append`, e não `set` (#1274): o filtro aceita várias etiquetas e cada
// uma viaja como um `tag` repetido — que é o que a rota lê com `getAll`.
for (const marcador of marcadoresEscolhidos(
typeof filters.tag === "string" ? [filters.tag] : (filters.tag ?? []),
))
qs.append("tag", marcador);
// O `modo` só sai quando é `ou`: `e` é o padrão e não precisa viajar, e um
// `&modo=e` colado num link de hoje mudaria a URL sem mudar o sentido.
if (filters.tagMode === "ou") qs.set("modo", "ou");
if (filters.is_group !== undefined) qs.set("is_group", filters.is_group ? "true" : "false");
if (pageParam) qs.set("cursor", pageParam);
qs.set("limit", "50");
+19
View File
@@ -68,6 +68,25 @@ describe('aggregateEvolution', () => {
expect(p.gaps.knowledge_empty).toBe(3);
});
it('a busca do ATENDENTE não vira pergunta de cliente nem consulta do agente (#1877)', () => {
// Só a 0484 grava linha humana. Sem o filtro, a pergunta exploratória de
// quem opera, com hits=0, apareceria como "pergunta de cliente que o agente
// não soube responder" e inflaria "Consultas aos seus materiais".
const p = aggregateEvolution({
...base(),
knowledgeSearches: [
{ created_at: '2026-07-01T10:00:00Z', hits: 0, top_score: 0.68, threshold: 0.72, author_kind: 'ai' },
{ created_at: '2026-07-01T11:00:00Z', hits: 0, top_score: 0.69, threshold: 0.72, author_kind: 'human' },
{ created_at: '2026-07-02T11:00:00Z', hits: 0, top_score: null, threshold: 0.4 }, // linha antiga, sem a coluna
],
});
expect(p.gaps.knowledge_empty).toBe(2);
expect(p.gaps.knowledge_near_misses).toBe(1);
expect(p.activity.series.knowledge_searches.map((d) => d.value)).toEqual([1, 1, 0]);
expect(p.activity.series.knowledge_searches_equipe.map((d) => d.value)).toEqual([1, 0, 0]);
});
it('conta certo mesmo quando o driver entrega numeric como STRING', () => {
// Este teste não é paranoia: a prova real da Task 2 mediu `top_score` voltando
// como '0.910667' — `numeric` não tem parser default no node-postgres. Sem
+17 -2
View File
@@ -29,6 +29,12 @@ export interface EvolutionInput {
hits: number;
top_score: number | null;
threshold: number;
/**
* `'human'` = o atendente perguntou pela caixa "Acervo" da conversa (0484).
* Ausente conta como `'ai'`: é o default da coluna e o que toda linha
* anterior à 0484 foi.
*/
author_kind?: string | null;
}>;
stageTransitions: Array<{ created_at: string; to_stage: string }>;
costCents: number;
@@ -55,7 +61,10 @@ export interface EvolutionPayload {
series: {
skill_activations: Array<{ day: string; value: number }>;
router_decisions: Array<{ day: string; value: number }>;
/** Só as buscas do AGENTE — é delas que os `gaps` de acervo falam. */
knowledge_searches: Array<{ day: string; value: number }>;
/** Buscas da EQUIPE pela caixa "Acervo" da conversa: série própria, nunca somada à do agente. */
knowledge_searches_equipe: Array<{ day: string; value: number }>;
};
by_skill: Record<string, number>;
by_intent: Record<string, number>;
@@ -147,9 +156,14 @@ export function aggregateEvolution(input: EvolutionInput): EvolutionPayload {
// logo abaixo do limiar. É o sinal que separa "a base não tem isso" de "a base
// tem e o corte está apertado demais" — dois problemas com consertos opostos.
const PERTO = 0.1;
// A busca do ATENDENTE não entra aqui: a tela lê `knowledge_empty` como
// "pergunta de cliente que o agente não soube responder", e a pergunta
// exploratória de quem opera não é nenhuma das duas coisas.
const buscasDoAgente = input.knowledgeSearches.filter((k) => k.author_kind !== 'human');
const buscasDaEquipe = input.knowledgeSearches.filter((k) => k.author_kind === 'human');
let nearMisses = 0;
let empty = 0;
for (const k of input.knowledgeSearches) {
for (const k of buscasDoAgente) {
if (k.hits > 0) continue;
empty += 1;
// ⚠️ COERÇÃO OBRIGATÓRIA. `top_score` e `threshold` são `numeric` no Postgres,
@@ -198,7 +212,8 @@ export function aggregateEvolution(input: EvolutionInput): EvolutionPayload {
series: {
skill_activations: serie(days, input.skillActivations),
router_decisions: serie(days, input.routerDecisions),
knowledge_searches: serie(days, input.knowledgeSearches),
knowledge_searches: serie(days, buscasDoAgente),
knowledge_searches_equipe: serie(days, buscasDaEquipe),
},
by_skill: contaPor(input.skillActivations, (r) => r.skill_name),
by_intent: contaPor(
+24
View File
@@ -17,6 +17,30 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { embedText } from "@/lib/ai/embed";
/**
* Limiar do CAMINHO DO HUMANO — o mesmo que a ferramenta MCP `crm_search_knowledge`
* usa. Mora aqui, e não em `lib/mcp/tools/evolucao.ts`, por um motivo prático: a
* operação é única, e uma constante duplicada é o primeiro passo para a busca do
* operador e a da IA divergirem sobre o mesmo acervo.
*
* É o MESMO default do banco desde a migration 0097. Era 0.72 na ferramenta MCP,
* e o produto tinha TRÊS limiares para o mesmo acervo: 0.40 na RPC, 0.72 no turno
* do agente e 0.72 nesta capacidade. Duas pessoas perguntando a mesma coisa pelo
* mesmo material recebiam respostas diferentes conforme a porta por onde entraram.
*
* O turno do agente lê `ai_agents.config.rag_similarity_threshold`, cujo padrão
* também é 0,40 (`agent-config.ts`, `guardrails-schema.ts`). Quem pergunta na tela
* pode escopar por agente e herdar o limiar dele — é o caminho que devolve a MESMA
* resposta que a IA daria.
*/
export const LIMIAR_PADRAO_BUSCA = 0.4;
/**
* Vocabulário de `knowledge_searches.author_kind` (CHECK da 0484, par vigiado em
* `tests/invariants/vocabulario-banco-x-typescript.test.ts`).
*/
export const KNOWLEDGE_SEARCH_AUTHOR_KINDS = ["human", "ai"] as const;
export interface TrechoEncontrado {
chunk_id: string;
knowledge_source_id: string | null;
+29
View File
@@ -1246,6 +1246,12 @@ export const DICIONARIO: Traducoes = {
"Buscar mensagens…": { es: "Buscar mensajes…" },
"Todos os números": { es: "Todos los números" },
"Todas as tags": { es: "Todas las etiquetas" },
// #1274 — filtro por VÁRIAS etiquetas (E/OU). As duas entradas novas do menu
// de etiqueta das TRÊS listas (Inbox, funil e contatos); o rótulo do item do
// menu é a pergunta, e o "✓" que marca o modo corrente é um caractere, não uma
// string traduzível.
"Todas (E)": { es: "Todas (Y)" },
"Qualquer uma (OU)": { es: "Cualquiera (O)" },
"Apenas não lidos": { es: "Solo no leídos" },
"Não lidos": { es: "No leídos" },
Grupo: { es: "Grupo" },
@@ -9689,6 +9695,29 @@ export const DICIONARIO: Traducoes = {
"Usando a chave que veio na instalação.": { es: "Usando la clave que vino con la instalación." },
"A chave escolhida no painel de Provedores para este ponto não está utilizável (desativada, apagada ou ainda não validada). Seguindo com a próxima chave disponível.": { es: "La clave que elegiste en el panel de Proveedores para este punto no se puede usar (está desactivada, eliminada o aún sin validar). Se usará la siguiente clave disponible." },
// ─── Acervo: perguntar pelo operador (components/inbox/AcervoSearch.tsx,
// e a rota app/api/v1/ai/knowledge/busca). A chave é o texto em português;
// espanhol é idioma "completo", então faltar `es` reprova i18n-espanhol-cobre-a-tela. ───
"Acervo": { es: "Acervo" },
"Pergunte ao acervo…": { es: "Pregunta al acervo…" },
"Perguntar ao acervo": { es: "Preguntar al acervo" },
"Buscando…": { es: "Buscando…" },
"Semelhança com a pergunta": { es: "Similitud con la pregunta" },
// Evolução (#1877): a busca da equipe tem série própria, fora dos números do agente.
"Consultas da equipe ao acervo": { es: "Consultas del equipo al acervo" },
"Quantas vezes alguém da equipe perguntou ao acervo pela caixa ao lado da conversa. Não entra nos números do agente.": { es: "Cuántas veces alguien del equipo le preguntó al acervo desde el cuadro junto a la conversación. No entra en los números del agente." },
"Ninguém da equipe consultou o acervo pela conversa neste período.": { es: "Nadie del equipo consultó el acervo desde la conversación en este período." },
"Esta organização ainda não tem chave de embedding. Cadastre uma chave OpenAI ou OpenRouter em Credenciais para consultar o acervo.": { es: "Esta organización todavía no tiene clave de embeddings. Registra una clave de OpenAI u OpenRouter en Credenciales para consultar el acervo." },
"Materiais consultados": { es: "Materiales consultados" },
"limiar": { es: "umbral" },
"A mesma busca que a IA faz — com a mesma origem de cada trecho.": { es: "La misma búsqueda que hace la IA, con el origen de cada fragmento." },
"Pergunte como a IA perguntaria — a resposta vem com a origem de cada trecho.": { es: "Pregunta como lo haría la IA: la respuesta trae el origen de cada fragmento." },
"Não consegui consultar o acervo.": { es: "No pude consultar el acervo." },
"Não foi possível ler o acervo.": { es: "No fue posible leer el acervo." },
"Não foi possível consultar o acervo.": { es: "No fue posible consultar el acervo." },
"Este acervo ainda não tem material publicado.": { es: "Este acervo todavía no tiene material publicado." },
"A base não tem essa informação.": { es: "La base no tiene esa información." },
"Há algo parecido no acervo, mas ainda abaixo do limiar — tente outras palavras.": { es: "Hay algo parecido en el acervo, pero todavía bajo el umbral: prueba con otras palabras." },
// ─── Acervo: a listagem (app/app/ai/knowledge/sources/_client.tsx) ───
//
// Este arquivo escapou das DUAS varreduras do merge: não é arquivo NOVO (a
+1 -1
View File
@@ -191,7 +191,7 @@ const MENOS_INFINITO = "-infinity";
* `conversations.status='resolved'` (só há leitores), então isto não muda nada
* hoje — e passa a importar no instante em que o banco calcular o mesmo comando.
*/
const STATUS_ENCERRADOS = new Set(["closed", "archived", "resolved"]);
export const STATUS_ENCERRADOS = new Set(["closed", "archived", "resolved"]);
/**
* O silêncio, lido do jeito que o Postgres o entrega.
+171 -1
View File
@@ -19,10 +19,16 @@
* — a lista (`app/api/v1/conversations/_handler.ts`) e a contagem das abas
* (`app/api/v1/conversations/counts/route.ts`) — apenas o aplica.
* `tests/unit/badge-espelha-o-filtro.test.ts` vigia os dois lados.
*
* ─── E AGORA, VÁRIAS ETIQUETAS (#1274) ─────────────────────────────────────
*
* A segunda metade do arquivo é o filtro por VÁRIAS etiquetas, nos dois modos
* (E/OU). A régua continua UMA só: quem oferece a opção e quem filtra leem as
* mesmas funções, que é a mesma razão de o arquivo existir.
*/
/**
* `{valor}` como operando de `cs` DENTRO de um `or=` do PostgREST.
* `{valor}` como operando de `cs`/`ov` DENTRO de um `or=` do PostgREST.
*
* Duas gramáticas, uma dentro da outra: o literal de array do Postgres
* (`{"vip"}`, com `"` e `\` escapados por barra) e, por fora, o valor entre
@@ -37,6 +43,33 @@ export function arrayDeUmValorParaOr(valor: string): string {
return `"${escapa(`{"${escapa(valor)}"}`)}"`;
}
/**
* `{"a","b"}` como operando de `cs`/`ov` DENTRO de um `or=` — a MESMA gramática
* de duas camadas de `arrayDeUmValorParaOr`, com a lista inteira num literal só.
*
* ⚠️ CADA ELEMENTO VAI ENTRE ASPAS, e é isso que a torna uma lista e não uma
* palavra. O literal de array do Postgres é `{"a","b"}`: sem as aspas internas,
* `{a,b}` é um elemento só, de nome `a,b` — e o filtro casaria conversas com
* uma etiqueta chamada "a,b", que ninguém escreveu. A forma de um item já
* tinha as aspas (`arrayDeUmValorParaOr("vip")` → `{"vip"}`); a lista tem de
* manter, senão as duas formas da mesma coluna divergiriam por construção.
*
* Por isso a lista de um é `{"vip"}` — byte a byte o que o caminho singular já
* produzia, e a razão de `aplicarMarcadores` desviar o caso de TAMANHO 1 para
* `aplicarMarcador`: por este caminho, ele já sairia igual.
*
* O escape é o de sempre, POR ITEM: escapar a string junta escaparia as vírgulas
* que separam os elementos, e o `,` que está DENTRO do nome de um marcador
* viraria separador. Marcador é texto livre e o editor aceita vírgula — é o caso
* que o valor único já resolvia, e resolver aqui só o valor único devolveria a
* mesma classe de defeito pelo caminho novo.
*/
export function listaDeValoresParaOr(valores: readonly string[]): string {
const escapa = (t: string) => t.replace(/[\\"]/g, (c) => `\\${c}`);
const itens = valores.map((v) => `"${escapa(v)}"`).join(",");
return `"${escapa(`{${itens}}`)}"`;
}
/**
* O predicado do marcador: casa a caixa da CONVERSA **ou** a do CONTATO.
*
@@ -57,6 +90,12 @@ export function predicadoDoMarcador(marcador: string): string {
*
* Sem marcador não há filtro, e sem filtro não há `or=`. O builder do supabase-js
* devolve ele mesmo, então quem chama pode encadear sem saber quem aplicou.
*
* ⚠️ ESTA É A FORMA SINGULAR, E ELA CONTINUA SENDO O CONTRATO DE HOJE. O plural
* nasce em `aplicarMarcadores` (logo abaixo), que delega a forma de um item a
* ESTA função — de modo que `?tag=vip` produz byte a byte o mesmo `or=` que
* produzia antes desta mudança. `tests/unit/filtro-multi-etiqueta.test.ts`
* fixa essa igualdade byte a byte.
*/
export function aplicarMarcador<C extends { or: (filtro: string) => C }>(
consulta: C,
@@ -65,3 +104,134 @@ export function aplicarMarcador<C extends { or: (filtro: string) => C }>(
if (!marcador) return consulta;
return consulta.or(predicadoDoMarcador(marcador));
}
/**
* ═══ O FILTRO POR VÁRIAS ETIQUETAS, E COMO AS DUAS SEMÂNTICAS VIRARAM TEXTO ═══
*
* A issue #1274 pede "vip **e** orçamento" e "vip **ou** orçamento" nas três
* listas. A semântica de cada uma foi combinada com quem mantém o produto:
*
* - **E** (`modo=e`, padrão): a conversa tem TODAS as escolhidas *na mesma caixa*
* — na conversa (`tags`) **ou** no contato (`tags_do_contato`). É o "contém
* todos" do PostgREST (`cs`) e mantém a régua das duas caixas que a 0323
* fixou: quem marcou a conversa e quem marcou o cliente continuam casando.
* - **OU** (`modo=ou`): qualquer uma das escolhidas, em qualquer das duas caixas
* (`ov`).
*
* O intervalo entre as duas — "vip na conversa E orçamento no contato", misturando
* caixas — não é expressável pelas duas caixas de hoje. A recomendação de quem
* abriu a issue é ficar nas duas semânticas simples, e o terceiro caso fica
* registrado aqui como decisão de produto pendente — é o que o PR declara.
*/
/** Os dois modos, e só eles. `e` é o padrão: uma etiqueta só nunca muda de sentido. */
export const MODOS_DE_ETIQUETA = ["e", "ou"] as const;
/** Qual dos dois modos vale — `e` quando ninguém disse. */
export type ModoDeEtiqueta = (typeof MODOS_DE_ETIQUETA)[number];
/**
* Quantas etiquetas um filtro aceita. Teto GENEROSO de propósito: ele existe
* porque a lista de valores viaja na querystring do PostgREST, e um teto baixo
* cortaria um filtro pedido. 20 é o mesmo teto que a escrita de marcadores já
* impõe (`conversationTagsSchema`, `normalizarTags` na importação por CSV) —
* acima disso nenhum marcador escrito hoje seria filtrável, então o limite não
* tira nada de quem filtra.
*/
export const MAXIMO_DE_ETIQUETAS_NO_FILTRO = 20;
/**
* Lê `modo` da URL sem explodir: valor FORA dos dois (`?modo=xou`) é `undefined`,
* e quem chamou decide — no schema Zod, virar 422, porque uma resposta de 422
* ensina o integrador a corrigir; na tela, virar `e`, porque uma tela não pode
* quebrar por um param inventado.
*/
export function modoDeEtiqueta(cru: string | null | undefined): ModoDeEtiqueta | undefined {
if (cru == null) return undefined;
return (MODOS_DE_ETIQUETA as readonly string[]).includes(cru)
? (cru as ModoDeEtiqueta)
: undefined;
}
/**
* A lista de marcadores que o filtro escolheu, sem vazio e sem repetido.
*
* A ordem da primeira aparição é preservada: é o que o operador reconhece, e o
* que a URL produzida seja estável entre dois renders do mesmo filtro.
*/
export function marcadoresEscolhidos(crus: readonly (string | null | undefined)[]): string[] {
const vistos = new Set<string>();
const saida: string[] = [];
for (const cru of crus) {
const marcador = cru?.trim();
if (!marcador || vistos.has(marcador)) continue;
vistos.add(marcador);
saida.push(marcador);
}
return saida;
}
/**
* O predicado de UMA etiqueta, no texto do `or=` — a MESMA forma que a função
* singular acima produzia, e é por isso que o filtro de uma etiqueta não mudou
* de byte.
*/
export function predicadoDeUmMarcador(marcador: string): string {
return predicadoDoMarcador(marcador);
}
/**
* O predicado de VÁRIAS etiquetas nas DUAS caixas, no `or=` de sempre.
*
* `modo` é o que decide o OPERADOR: `cs` (a caixa contém TODAS as etiquetas) no
* modo E, `ov`/overlaps (a caixa contém QUALQUER uma) no modo OU. É a ÚNICA coisa
* que muda entre os dois modos — o literal é o mesmo e o `or=` é o mesmo, de
* propósito: o que separa "E" de "OU" fica visível num `git diff` de uma linha.
*/
export function predicadoDeVariasEtiquetas(
marcadores: readonly string[],
modo: ModoDeEtiqueta = "e",
): string {
if (marcadores.length === 0) return "";
if (marcadores.length === 1) return predicadoDeUmMarcador(marcadores[0]!);
const operador = modo === "ou" ? "ov" : "cs";
const valor = listaDeValoresParaOr(marcadores);
return `tags.${operador}.${valor},tags_do_contato.${operador}.${valor}`;
}
/**
* Aplica o filtro de VÁRIAS etiquetas na consulta.
*
* ⚠️ Aceita `string` além de `string[]` DE PROPÓSITO. Quem chama vem de duas
* fontes: a tela, que tem lista, e o `?tag=vip` de um link salvo, que é uma
* string só. Se esta função aceitasse só array, o chamador da string teria de
* embrulhar — e um chamador que esquece o embrulho não avisa: o filtro passa a
* casar a string como se fosse um marcador INTEIRO, que é uma lista vazia na
* prática, e a tela mostra "nenhuma conversa" sem erro. Aceitar as duas formas
* aqui é a mesma política do schema, pela mesma razão.
*
* ⚠️ O CASO DE TAMANHO 1 É O QUE MANTÉM A URL ANTIGA VIVA. Uma etiqueta só
* entra pelo caminho singular (`aplicarMarcador` → `or=(tags.cs.{vip},
* tags_do_contato.cs.{vip})`), byte a byte igual ao que `?tag=vip` produzia
* antes desta mudança. Sem esse desvio, o `?tag=` singular passaria a gerar um
* `cs` de lista de um item — que casaria a mesma conversa, e por isso o defeito
* seria SILENCIOSO: a lista voltaria quase inteira e o sintoma seria "o filtro
* parou de filtrar". Daí o teste comparar as duas formas byte a byte.
*/
export function aplicarMarcadores<C extends { or: (filtro: string) => C }>(
consulta: C,
marcadores: readonly string[] | string | null | undefined,
modo: ModoDeEtiqueta = "e",
): C {
// A string solitaria é embrulhada em LISTA de um — e é aqui que está o perigo
// que o embrulho evita: sem ele, uma string `fidic` viraria o LITERAL
// `{f,i,d,c}` de "contém todos", e o filtro casaria conversas que têm as
// letras f, i, d e c em qualquer ordem. A lista de um devolve exatamente o
// que o caminho singular devolvia, que é o que a comparação byte a byte
// abaixo fixa.
const lista = marcadoresEscolhidos(
typeof marcadores === "string" ? [marcadores] : (marcadores ?? []),
);
if (lista.length === 0) return consulta;
if (lista.length === 1) return aplicarMarcador(consulta, lista[0]);
return consulta.or(predicadoDeVariasEtiquetas(lista, modo));
}
+83 -8
View File
@@ -1,5 +1,10 @@
import type { Lead } from "@/lib/types/leads";
import { cardTemMarcador } from "@/lib/kanban/marcadores-do-card";
import { cardTemMarcador, cardTemTodasNaMesmaCaixa } from "@/lib/kanban/marcadores-do-card";
import {
type ModoDeEtiqueta,
marcadoresEscolhidos,
modoDeEtiqueta,
} from "@/lib/inbox/marcador-da-conversa";
/**
* Prefixo que marca um dono AGENTE no filtro (0070). O param de URL continua
@@ -21,7 +26,17 @@ export interface LeadFilters {
/** userId | `agent:<uuid>` | "any" | "unassigned" */
owner?: string | "any" | "unassigned";
status?: "all" | "open" | "won" | "lost";
tag?: string;
/**
* A etiqueta escolhida, ou VÁRIAS (#1274).
*
* `string` continua aceito porque é o que o `filtersFromParams` entrega num
* link salvo e o que qualquer chamada antiga manda. As DUAS formas passam pelo
* MESMO normalizador (`marcadoresDoFiltro`), então "uma" nunca tem dois
* sentidos — nem entre uma versão antiga do link e a de hoje.
*/
tag?: string | readonly string[];
/** E ou OU entre as etiquetas escolhidas (#1274). `e` é o padrão. */
tagMode?: ModoDeEtiqueta;
search?: string;
valueCentsMin?: number | null;
valueCentsMax?: number | null;
@@ -32,17 +47,47 @@ export interface LeadFilters {
lostCategory?: string;
}
/**
* A lista de marcadores do filtro, sem vazio e sem repetido.
*
* Mora AQUI e não no `FilterBar` por uma razão que já custou defeito duas vezes
* neste arquivo: quem MONTA o filtro e quem o APLICA precisam responder a mesma
* pergunta. Com a regra escrita duas vezes, o seletor oferece uma combinação que
* a lista nunca casa — sem erro, sem sintoma, só um filtro que devolve vazio.
*/
export function marcadoresDoFiltro(tag: LeadFilters["tag"]): string[] {
if (tag == null) return [];
const crus = typeof tag === "string" ? [tag] : tag;
return marcadoresEscolhidos(crus);
}
/** A cor/ponto que o gatilho mostra: a primeira escolhida, ou nada. */
export function primeiroMarcador(tag: LeadFilters["tag"]): string | undefined {
return marcadoresDoFiltro(tag)[0];
}
/**
* Serializa/deserializa os filtros do board em query params (deep-linkável).
* Só os controles expostos na FilterBar: owner, status, tag, busca, atrasados.
*
* ⚠️ `getAll` no `tag` (#1274): a repetição na URL (`?tag=vip&tag=orçamento`) é o
* que faz o link do funil com duas etiquetas sobreviver a um F5 e a um link
* colado no chat. Um `get` leria a primeira e o deep-link mentiria sem aviso.
* O `modo` só sai quando é `ou` — `e` é o padrão, e um `?tag=vip` de hoje não
* ganha um `&modo=e` colado nele.
*/
export function filtersFromParams(
sp: { get(key: string): string | null },
sp: { get(key: string): string | null; getAll?: (key: string) => string[] },
): LeadFilters {
const owner = sp.get("owner");
const status = sp.get("status");
const tag = sp.get("tag");
// `getAll` não existe no tipo mínimo desta assinatura (o chamador real passa um
// `URLSearchParams`), e o fallback de uma lista mantém o deep-link legível
// quando um objeto de busca minimalista é passado em teste.
const tags = typeof sp.getAll === "function" ? sp.getAll("tag") : [];
const tag = tags.length > 0 ? tags : sp.get("tag") ?? undefined;
const search = sp.get("q");
const modo = sp.get("modo") ?? undefined;
return {
owner: owner ?? undefined,
status:
@@ -50,6 +95,11 @@ export function filtersFromParams(
? status
: "all",
tag: tag ?? undefined,
// Um `modo` fora dos dois vira `e`, e não erro: a URL é deep-link e não
// resposta de API. O `z.enum` do servidor recusa (422) porque ali o
// integrador precisa corrigir; aqui a tela não pode quebrar por um parâmetro
// colado à mão.
...(modoDeEtiqueta(modo) ? { tagMode: modoDeEtiqueta(modo) } : {}),
search: search ?? undefined,
overdueOnly: sp.get("overdue") === "1" || undefined,
lostReason: sp.get("motivo") ?? undefined,
@@ -61,7 +111,10 @@ export function filtersToParams(f: LeadFilters): string {
const p = new URLSearchParams();
if (f.owner && f.owner !== "any") p.set("owner", f.owner);
if (f.status && f.status !== "all") p.set("status", f.status);
if (f.tag) p.set("tag", f.tag);
// `append`, e não `set`: o último venceria, e o filtro de duas etiquetas
// viraria uma só — a tela mostraria duas escolhidas filtrando por uma.
for (const marcador of marcadoresDoFiltro(f.tag)) p.append("tag", marcador);
if (f.tagMode === "ou") p.set("modo", "ou");
if (f.search?.trim()) p.set("q", f.search.trim());
if (f.overdueOnly) p.set("overdue", "1");
if (f.lostReason) p.set("motivo", f.lostReason);
@@ -86,6 +139,27 @@ export function applyFilters(
): Lead[] {
const today = new Date().toISOString().slice(0, 10);
const search = f.search?.trim().toLowerCase() ?? "";
// Fora do `filter`: a escolha de E/OU é do filtro, não do card, e calculá-la a
// cada linha pagaria uma normalização por card — a lista do funil é pequena
// hoje e não precisa de um laço que cresce com ela.
const marcadores = marcadoresDoFiltro(f.tag);
// ⚠️ E/OU (#1274). O funil filtra no CLIENTE, então não há `cs`/`ov` para
// delegar: a semântica é reimplementada aqui, e ela tem de ser a MESMA que a do
// servidor (`lib/inbox/marcador-da-conversa.ts`).
//
// E o detalhe que é fácil errar: no servidor o E é `tags.cs.{a,b}` OU
// `tags_do_contato.cs.{a,b}` — as DUAS etiquetas NA MESMA CAIXA, e as caixas em
// disjunção. Portanto o E aqui é `cardTemTodasNaMesmaCaixa`, que pergunta caixa
// por caixa, e o OU entre caixas é o de fora. Um `every` sobre a união das três
// caixas (o que `cardTemMarcador` devolve) aceitaria "vip na conversa E
// orçamento no contato" — que é justamente o caso que a issue registra como
// decisão de produto pendente, e que o servidor NÃO aceita. Aceitar aqui e não
// lá faria o mesmo filtro dar resultados diferentes em cada lista.
const passaMarcador = (lead: Lead): boolean => {
if (marcadores.length === 0) return true;
if (f.tagMode === "ou") return marcadores.some((m) => cardTemMarcador(lead, m));
return cardTemTodasNaMesmaCaixa(lead, marcadores);
};
return leads.filter((l) => {
// "Sem responsável" é sem dono NENHUM — lead de dono agente tem dono.
@@ -113,10 +187,11 @@ export function applyFilters(
const categoria = motivo ? contexto?.categoriaDo?.(motivo) : undefined;
if (categoria !== f.lostCategory) return false;
}
// As TRÊS caixas de marcador (negócio, contato, conversa) — ver
// As TRÊS caixas de marcador (negócio, contato e conversa) — ver
// lib/kanban/marcadores-do-card.ts. Só `l.tags` deixava o marcador escrito
// no contato ou na conversa sem casar card nenhum.
if (f.tag && !cardTemMarcador(l, f.tag)) return false;
// no contato ou na conversa sem casar card nenhum. O E/OU mora em
// `passaMarcador`, lá em cima.
if (!passaMarcador(l)) return false;
if (
search &&
!`${l.title} ${l.description ?? ""}`.toLowerCase().includes(search)
+23
View File
@@ -64,3 +64,26 @@ export function cardTemMarcador(lead: Lead, marcador: string): boolean {
(lead.conversation_tags ?? []).includes(marcador)
);
}
/**
* O card tem TODAS estas etiquetas NA MESMA caixa? É o modo E do filtro (#1274).
*
* ─── Por que não basta o `every` sobre `cardTemMarcador` ────────────────────
*
* `cardTemMarcador` pergunta às três caixas em UNIÃO: "esta etiqueta está em
* alguma delas". Um `every` em cima disso aceita "vip na conversa E orçamento
* no negócio" — etiquetas juntas só na composição, nunca numa caixa. É
* justamente a mistura de caixas que a #1274 registra como DECISÃO DE PRODUTO
* pendente e que o servidor NÃO expressa: lá o E é `tags.cs.{a,b}` OU
* `tags_do_contato.cs.{a,b}` — as duas etiquetas dentro de UM literal, numa caixa
* só. Aceitar no funil e recusar no servidor faria o mesmo filtro devolver
* listas diferentes nas duas telas, sem erro em nenhuma.
*
* Com UMA etiqueta isto é sinônimo de `cardTemMarcador`, que é por que o filtro
* de uma etiqueta do funil não mudou de comportamento.
*/
export function cardTemTodasNaMesmaCaixa(lead: Lead, marcadores: readonly string[]): boolean {
if (marcadores.length === 0) return true;
const caixas = [lead.tags, lead.contact_tags ?? [], lead.conversation_tags ?? []];
return caixas.some((caixa) => marcadores.every((m) => caixa.includes(m)));
}
+7
View File
@@ -77,6 +77,13 @@ export const crmListConversations: McpToolDefinition<typeof listInputShape> = {
// é erro de tipo. A tool do MCP não expõe filtro por comando (quem
// pergunta é a tela), então ela não filtra por ele.
comando: undefined,
// `tag` e `modo` também saem `undefined` EXPLICITO, e pela MESMA razão do
// `comando`: o `.transform()` do schema de marcador (#1274) torna a chave
// de SAÍDA obrigatória-de-tipo (`string[] | undefined`), não opcional.
// A tool do MCP não expõe filtro por etiqueta (quem pergunta é a tela), e
// omitir a chave seria erro de tipo — não omissão silenciosa.
tag: undefined,
modo: undefined,
limit: input.limit,
cursor: input.cursor,
},
+2 -11
View File
@@ -14,6 +14,7 @@
import { z } from "zod";
import {
LIMIAR_PADRAO_BUSCA,
buscarConhecimento,
resolverAcervoDoAgente,
} from "@/lib/ai/knowledge/busca";
@@ -33,16 +34,6 @@ const buscarInputShape = {
assistente_id: z.string().uuid().optional(),
};
/**
* Limiar de similaridade — o MESMO default do banco desde a migration 0097.
*
* Era 0.72 aqui, e o produto tinha TRÊS limiares para o mesmo acervo: 0.40 na
* RPC, 0.72 no turno do agente e 0.72 nesta capacidade. Duas pessoas
* perguntando a mesma coisa pelo mesmo material recebiam respostas diferentes
* conforme a porta por onde entraram.
*/
const LIMIAR_PADRAO = 0.4;
export const crmSearchKnowledge: McpToolDefinition<typeof buscarInputShape> = {
name: "crm_search_knowledge",
description:
@@ -82,7 +73,7 @@ export const crmSearchKnowledge: McpToolDefinition<typeof buscarInputShape> = {
knowledgeSourceIds: fontes,
pergunta: input.pergunta,
topK: input.quantidade,
limiar: LIMIAR_PADRAO,
limiar: LIMIAR_PADRAO_BUSCA,
});
return {
+8 -1
View File
@@ -14,7 +14,14 @@ export type MessageKind = "image" | "video" | "audio" | "document";
* `docs/doctrine/restricao-de-canal.md` proíbe.
*/
export function isMediaPathOwnedBy(path: string, orgId: string, conversationId: string): boolean {
return path.startsWith(`${orgId}/${conversationId}/`);
const prefixo = `${orgId}/${conversationId}/`;
if (!path.startsWith(prefixo)) return false;
// O prefixo sozinho não prova posse: `{org}/{conv}/../../{outra}/x` começa certo e,
// se o Storage resolver o `..`, aponta para o arquivo de outra conversa ou organização.
// Só segmento de nome comum depois do prefixo — nada de `..`, `.`, vazio ou barra invertida.
const resto = path.slice(prefixo.length);
if (resto.includes("\\")) return false;
return resto.split("/").every((s) => s !== "" && s !== "." && s !== "..");
}
const DOCUMENT_MIMES = new Set([
+34 -1
View File
@@ -10,6 +10,10 @@
import { z } from "zod";
import { normalizarTag, normalizarTags } from "@/lib/contacts/tag-normalizada";
import {
MAXIMO_DE_ETIQUETAS_NO_FILTRO,
MODOS_DE_ETIQUETA,
} from "@/lib/inbox/marcador-da-conversa";
import { isValidCpf, type PerfilDoPais } from "@/lib/legal/perfil-do-pais";
const PHONE_REGEX = /^\+\d{8,15}$/;
@@ -111,7 +115,36 @@ export const contactListQuerySchema = z.object({
search: z.string().optional(),
// O filtro normaliza pelo MESMO caminho da escrita: `?tag=VIP` acha o que a
// ficha gravou como "vip" (issue #1224).
tag: z.string().transform(normalizarTag).optional(),
//
// ⚠️ E agora VÁRIAS etiquetas (#1274). Aceita `string` OU `string[]`, e a
// repetição na URL (`?tag=vip&tag=orçamento`) é lida por `getAll`. Aceitar as
// DUAS formas é o que mantém o `?tag=vip` singular funcionando: `get` devolve
// string e `getAll` devolve array de um, e os dois precisam passar pelo MESMO
// schema — se este só aceitasse array, toda chamada antiga quebraria com 422.
tag: z
.union([
z.string().transform(normalizarTag),
z.array(z.string().transform(normalizarTag)).max(MAXIMO_DE_ETIQUETAS_NO_FILTRO),
])
.optional()
.transform((v) => {
if (v === undefined) return undefined;
const lista = Array.isArray(v) ? v : [v];
// Lista VAZIA vira `undefined`, e não `[]` — mesma razão do schema do
// Inbox: `getAll` devolve `[]` sem o parâmetro, e `[]` num `cs` é um filtro
// que não casa nada, com o filtro desligado.
return lista.length > 0 ? lista : undefined;
}),
/**
* E ou OU entre as etiquetas escolhidas (#1274). `e` é o padrão — e é o que
* uma etiqueta só já significava, logo o parâmetro só importa havendo duas.
*
* `z.enum` recusa o valor fora dos dois, e a recusa vira 422: `?modo=xou` é
* quase sempre alguém copiando o nome do parâmetro errado, e a resposta
* ensina o integrador a corrigir. Na TELA quem lê é `modoDeEtiqueta`, que cai
* no `e` — uma tela não pode quebrar por um parâmetro inventado.
*/
modo: z.enum(MODOS_DE_ETIQUETA).optional(),
source: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
+41 -1
View File
@@ -7,6 +7,10 @@
*/
import { z } from "zod";
import { COMANDOS_DO_BANCO, type ComandoDoBanco } from "@/lib/inbox/comando-da-conversa";
import {
MAXIMO_DE_ETIQUETAS_NO_FILTRO,
MODOS_DE_ETIQUETA,
} from "@/lib/inbox/marcador-da-conversa";
import { PISO_DA_BUSCA, buscaValeConsulta } from "@/lib/inbox/termo-de-busca";
/**
@@ -325,7 +329,43 @@ export const listConversationsQuerySchema = z.object({
exclude_finished: z.boolean().optional(),
assigned_to: z.union([z.string().uuid(), z.literal("me"), z.literal("unassigned")]).optional(),
channel_session_id: z.string().uuid().optional(),
tag: conversationTagSchema.optional(),
/**
* O MARCADOR, e agora VÁRIOS (#1274).
*
* Aceita `string` OU `string[]`, e a repetição na URL (`?tag=vip&tag=orçamento`)
* é lida por `getAll`. Aceitar as DUAS formas é o que mantém o `?tag=vip`
* singular funcionando byte a byte: `get` devolve string e `getAll` devolve
* array de um, e os dois precisam passar pelo MESMO schema — se este só
* aceitasse array, toda chamada antiga de API quebraria com 422.
*
* A lista é normalizada (trim/lowercase) item a item, pela MESMA razão de
* `conversationTagSchema` normalizar: o `?tag=VIP` tem de achar o que foi
* gravado como `vip` (issue #1224). Teto de 20, o mesmo da escrita.
*/
tag: z
.union([conversationTagSchema, z.array(conversationTagSchema).max(MAXIMO_DE_ETIQUETAS_NO_FILTRO)])
.optional()
.transform((v) => {
if (v === undefined) return undefined;
const lista = Array.isArray(v) ? v : [v];
// Lista VAZIA vira `undefined`, e não `[]`. O `getAll` devolve `[]` numa
// URL sem o parâmetro, e `[]` dentro de um `cs` é um filtro que NÃO CASA
// NADA: a tela responderia "nenhuma conversa" com o filtro desligado, sem
// erro nenhum. `undefined` é o que "sem filtro" significa.
return lista.length > 0 ? lista : undefined;
}),
/**
* E ou OU entre as etiquetas escolhidas (#1274). `e` é o padrão, e é o que
* uma etiqueta só já significava — logo, o parâmetro só importa havendo duas.
*
* `z.enum` RECUSA o valor fora dos dois, e a recusa vira 422. Aqui a escolha é
* deliberada — o inverso do `status`/`comando`, que transformam lista vazia em
* `undefined`: `?modo=xou` é quase sempre alguém copiando o nome do parâmetro
* errado, e responder 422 ensina o integrador a corrigir. Na TELA quem chama
* é `modoDeEtiqueta`, que devolve `undefined` e cai no `e` — uma tela não pode
* quebrar por um parâmetro inventado.
*/
modo: z.enum(MODOS_DE_ETIQUETA).optional(),
/**
* A aba "Grupos" do inbox (Task 10). `"true"`/`"false"` como STRING — vem de
* `searchParams`, que só conhece texto — e não `z.coerce.boolean()`, que
+20
View File
@@ -43904,4 +43904,24 @@ create policy "conversation_notes_write" on public.conversation_notes
drop policy if exists "conversation_notes_select_platform_admin" on public.conversation_notes;
create policy "conversation_notes_select_platform_admin" on public.conversation_notes
for select using (public.fn_is_platform_admin());
-- ---- busca humana na telemetria: author_kind (migration 0484) ----
-- 0484 — a busca HUMANA do acervo entra na telemetria (F2 da #1869).
-- `knowledge_searches` só recebia o caminho do agente; o grafico de
-- `/app/ai/evolution` conta linhas sem filtrar, entao a linha humana
-- aparece sozinha — mas so se alguem gravar.
-- `author_kind` segue o vocabulario da 0281 ('human','ai'); NAO usa
-- `agent_id is null`, que e `on delete set null` desde a 0181 e nao
-- distingue 'foi o operador' de 'o agente foi apagado depois'.
-- DEFAULT 'ai' e o proprio backfill: toda linha existente veio do
-- agente. Sem check acoplando author_user_id: quebraria o set null.
alter table public.knowledge_searches
add column if not exists author_kind text not null default 'ai'
check (author_kind in ('human', 'ai'));
alter table public.knowledge_searches
add column if not exists author_user_id uuid
references auth.users(id) on delete set null;
comment on column public.knowledge_searches.author_kind is
'Quem perguntou: ''ai'' = turno do agente ou a ferramenta MCP crm_search_knowledge; ''human'' = o operador na tela "Perguntar ao acervo" (F1 da #1869). Vocabulário da 0281.';
comment on column public.knowledge_searches.author_user_id is
'Operador que perguntou no caminho humano. null no caminho do agente, e também quando a pessoa sai do sistema — on delete set null preserva a pergunta, por isso não há check acoplando esta coluna à author_kind.';
@@ -0,0 +1,68 @@
-- 0484 — a busca HUMANA do acervo entra na telemetria (F2 da issue #1869)
--
-- ## O buraco (medido na base 83f10c3e5)
--
-- `knowledge_searches` só era preenchida por UM caminho: `search-knowledge.ts:122`,
-- o turno do agente. A capacidade MCP `crm_search_knowledge` promoveu a *capacidade*
-- à primeira classe — o docblock dela diz literalmente "PROMOÇÃO DE CAPACIDADE
-- SOMBRA: o humano não a via, não a desligava e não auditava" — mas o uso HUMANO
-- continuou fora da métrica. O gráfico de `/app/ai/evolution` contava só a IA
-- perguntando, e a tela "Perguntar ao acervo" (F1 da mesma issue) passaria a
-- perguntar sem deixar rastro nenhum.
--
-- ## Por que coluna e não `agent_id is null`
--
-- Parece o atalho óbvio e é uma armadilha: `agent_id` é `on delete set null`
-- desde a 0181, então ele fica NULL também quando um agente é apagado depois de
-- ter perguntado. NULL não distingue "foi o operador" de "o agente sumiu" — e a
-- segunda hipótese é exatamente o caso em que a métrica precisa continuar
-- atribuindo a busca à IA. Coluna própria diz o fato, não deduz de ausência.
--
-- ## Vocabulário
--
-- Segue a 0281 (`author_kind text not null check (author_kind in ('human','ai'))`
-- em `agent_case_chat_messages`), que é a última vez em que a casa resolveu
-- "quem perguntou" nesta codebase. Mantém-se `('human','ai')` — e não o
-- `('user','ai')` do `owner_kind` (0070), que nomeia o DONO de um registro, não
-- o AUTOR de uma pergunta. Dois vocabularios para o mesmo fato é o anti-pattern
-- de duplicação que o CLAUDE.md já proíbe.
--
-- ## Backfill
--
-- Nenhum `update` necessário: `default 'ai'` cobre as linhas existentes porque
-- TODAS vieram do agente — é essa a verdade histórica. A 0070 precisou de
-- backfill manual porque o default dela era null e a coluna que indicava o dono
-- já existia antes; aqui a coluna nasce junto com o default certo.
--
-- ## `author_user_id`
--
-- `on delete set null`: a saída de uma pessoa do sistema não apaga o que ela
-- perguntou — mesma decisão da 0281, e pelo mesmo motivo NÃO existe check
-- acoplando `author_kind` a `author_user_id`: ele quebraria o próprio `set null`
-- (a constraint passaria a exigir 'human' numa linha cujo usuário acabou de ser
-- apagado, e a telemetria de ontem viraria erro de hoje).
--
-- ## O que isto NÃO faz
--
-- Não muda o INSERT do agente: ele já grava sem esta coluna e o `default`
-- responde por ele. Importante, porque `search-knowledge.test.ts:127` mede as
-- posições $1..$8 do insert ("parâmetro novo entra no FIM") — mexer na ordem
-- deixaria aquela guarda vermelha por um motivo que não é o dela.
-- Não guarda o texto da pergunta: a decisão da 0086 (sem PII em telemetria de
-- retenção longa) continua valendo.
alter table public.knowledge_searches
add column if not exists author_kind text not null default 'ai'
check (author_kind in ('human', 'ai'));
alter table public.knowledge_searches
add column if not exists author_user_id uuid
references auth.users(id) on delete set null;
comment on column public.knowledge_searches.author_kind is
'Quem perguntou: ''ai'' = turno do agente ou a ferramenta MCP crm_search_knowledge; ''human'' = o operador na tela "Perguntar ao acervo" (F1 da #1869). Vocabulário da 0281.';
comment on column public.knowledge_searches.author_user_id is
'Operador que perguntou no caminho humano. null no caminho do agente, e também quando a pessoa sai do sistema — on delete set null preserva a pergunta, por isso não há check acoplando esta coluna à author_kind.';
notify pgrst, 'reload schema';
+1
View File
@@ -475,3 +475,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260928150200` | `0480_honorarios_modulo_oficial` | **Honorários, o primeiro módulo opcional no formato da ADR-0002 (contribuição de @nsbastosconsultoria, #1578).** Cria só FUNÇÕES: `fn_honorarios_provisionar()` (sem parâmetro, `security definer`, só `service_role` executa) guarda o schema de `honorarios_contratos` e `honorarios_parcelas`, que só nascem quando o administrador da instalação instala o módulo em `/admin/modulos` (`fn_modulo_instalar`); quem não instala não carrega tabela nenhuma. `fn_honorarios_parcela_pagar` paga uma parcela numa transação só, com `for update` — um clique duplo não lança o dinheiro duas vezes — e recusa conta ou plano de contas de outra organização. Revoga `execute` de `public, anon` nas duas. RLS por operação (molde da 0464): leitura para a organização; criar, editar e apagar só `manager`+; parcela paga não se apaga nem se reescreve pela sessão, nem o contrato que a tem, e a parcela só aponta para contrato da própria organização. |
| `20260928150300` | `0481_indice_cooldown_de_silencio` | **Índice para a consulta de cooldown do gatilho de silêncio (revisão do PR de correção do loop de reinscrição).** A consulta nova `loadContactIdsEmCooldown` (`lib/followup/silence-sweep.ts`) filtra `followup_enrollments` por `(organization_id, pointer_id, contact_id, updated_at)` a cada tick do cron de follow-up (1×/min); o único índice existente na tabela para `(organization_id, contact_id)` não cobre `pointer_id`. Índice composto `idx_followup_enrollments_pointer_contact_cooldown`, idempotente (`create index if not exists`), sem coluna nova. |
| `20260928184045` | `0483_anexo_na_nota_interna` | **A nota interna ganha anexo (#1863, F3): a análise de bloqueio media que sem coluna não havia por onde gravar o arquivo e sem bucket próprio ele caía no varredor órfão da 0435 (apagado em 1 dia).** Três colunas nullable em `conversation_notes` (`media_storage_path`/`media_mime`/`media_size_bytes bigint` — o trio de `messages`; o kind é derivado do mime no render, coluna que espelha função é coluna que diverge) + bucket `internal-media` (50 MB, `public=false`, SEM policy em `storage.objects`, como o próprio `whatsapp-media` da 0055: escrita/leitura pelo `service_role` e a rota de nota, nunca o Storage API direto). LGPD: a cascata ganha o passo 6d (redige `body`, zera o ponteiro e enfileira o arquivo com bucket `internal-media` — o passo 7 só enfileira `whatsapp-media`, e enfileirar ali apontaria a remoção para um bucket onde o arquivo não está) e `fn_enfileirar_midia_vencida` ganha o passo 2b (órfãos de `internal-media`, mesmo carência de 1 dia, contam em `v_orfas` para não mudar a chave congelada da 0435). Reaplicável: `add column if not exists`, `on conflict do update` no bucket, `create or replace`. Gate: `tests/invariants/anexo-da-nota-interna-responde-a-lgpd.test.ts`. |
| `20260928200400` | `0484_busca_humana_entra_na_telemetria` | **A busca HUMANA do Acervo entra na telemetria (F2 da #1869).** `knowledge_searches` só era preenchida pelo caminho do agente (`search-knowledge.ts:122`), então o gráfico de `/app/ai/evolution` — que conta linhas sem filtrar (`aggregate.ts:201`) — mostrava só a IA perguntando e a tela "Perguntar ao acervo" ficaria invisível na própria métrica. Colunas `author_kind text not null default 'ai' check (in ('human','ai'))` e `author_user_id uuid` (`on delete set null`): vocabulário da 0281, e o default é o backfill — toda linha existente veio do agente. **Sem check acoplando as duas colunas**, pelo mesmo motivo da 0281: quebraria o próprio `set null` e transformaria a saída de uma pessoa em erro de telemetria. `agent_id is null` NÃO serviria para distinguir (é `on delete set null` desde a 0181). Não mexe no INSERT do agente — `search-knowledge.test.ts:127` mede as posições $1..$8 dele. |
+69 -3
View File
@@ -214,7 +214,9 @@ test.describe("busca dentro da conversa", () => {
});
console.info(`busca-na-conversa · fileiras da barra de ações: ${JSON.stringify(barra)}`);
// soft: o resto da jornada roda e é medido mesmo se a lupa quebrar a barra.
expect.soft(barra.comLupa, "a lupa fez a barra de ações ganhar uma fileira em 1280px").toBe(barra.semLupa);
expect
.soft(barra.comLupa, "a lupa fez a barra de ações ganhar uma fileira em 1280px")
.toBe(barra.semLupa);
await lupa.click();
const campo = page.getByRole("searchbox", { name: "Buscar nas mensagens carregadas" });
@@ -224,7 +226,9 @@ test.describe("busca dentro da conversa", () => {
// ── digitar o termo: contador e marca
await campo.fill(TERMO);
const contador = page.getByRole("status").filter({ hasText: "Resultados nas mensagens carregadas" });
const contador = page
.getByRole("status")
.filter({ hasText: "Resultados nas mensagens carregadas" });
await expect(contador).toHaveText(/Resultados nas mensagens carregadas:\s*2$/);
await expect(page.locator('[data-search-match="true"]')).toHaveCount(2);
@@ -245,7 +249,9 @@ test.describe("busca dentro da conversa", () => {
expect(m.anel, `sem anel em "${m.texto}" — box-shadow: ${m.boxShadow}`).toBeNull();
}
// Uma enviada e uma recebida entre as marcadas: o anel nas duas cores de bolha.
expect(new Set(comTermo.map((m) => m.fundo)).size, "anel sobre os dois fundos de bolha").toBe(2);
expect(new Set(comTermo.map((m) => m.fundo)).size, "anel sobre os dois fundos de bolha").toBe(
2,
);
fs.mkdirSync(EVIDENCE, { recursive: true });
await page.screenshot({ path: path.join(EVIDENCE, "1-duas-bolhas-marcadas.png") });
@@ -285,3 +291,63 @@ test.describe("busca dentro da conversa", () => {
await expect(campo).toHaveValue("");
});
});
// ── Perguntar ao acervo pelo painel da conversa (#1877) ──────────────────────
//
// O estado provado é o de quem acabou de instalar: sem material publicado, a
// caixa tem de dizer que o acervo está VAZIO — não "a base não sabe", e nunca o
// erro genérico. O banco do CI é compartilhado e outras specs criam fontes na
// mesma organização, então o ramo é escolhido pela contagem MEDIDA no momento,
// e o log diz qual rodou. Com fonte ativa e sem chave de embedding (o CI não
// tem), o esperado é o 409 que manda cadastrar a chave. A busca com material
// indexado e chave real NÃO é medida aqui.
test.describe("perguntar ao acervo no painel da conversa", () => {
test.describe.configure({ timeout: 180_000 });
test("a caixa responde com o diagnóstico do acervo, nunca com o erro genérico", async ({
page,
}) => {
const [conversaA] = conversas as [string];
await login(page);
await page.goto(`/app/inbox?id=${conversaA}&filter=all`);
await expect(bolhas(page)).toHaveCount(MENSAGENS_A.length, { timeout: 30_000 });
const caixa = page.getByTestId("inbox-acervo");
await caixa.scrollIntoViewIfNeeded();
await expect(caixa).toBeVisible();
const { count, error } = await db
.from("ai_knowledge_sources")
.select("id", { count: "exact", head: true })
.eq("organization_id", orgId)
.eq("is_active", true);
if (error) throw new Error(`fontes ativas: ${error.message}`);
console.info(`busca-na-conversa · acervo: fontes ativas na organização = ${count}`);
await page.getByTestId("acervo-pergunta").fill("qual o prazo de entrega?");
await page.getByTestId("acervo-buscar").click();
if (count === 0) {
await expect(page.getByTestId("acervo-motivo")).toHaveText(
"Este acervo ainda não tem material publicado.",
{
timeout: 30_000,
},
);
} else {
await expect(
page
.getByTestId("acervo-erro")
.or(page.getByTestId("acervo-motivo"))
.or(page.getByTestId("acervo-trechos"))
.first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId("acervo-erro").filter({ hasText: "Não consegui consultar o acervo." }),
).toHaveCount(0);
}
fs.mkdirSync(EVIDENCE, { recursive: true });
await caixa.screenshot({ path: path.join(EVIDENCE, "3-acervo-na-conversa.png") });
});
});
+4 -1
View File
@@ -226,7 +226,10 @@ test("ligar 'Clientes pela agenda' transforma quem tem horário marcado em clien
await page.goto("/app/contacts");
await expect(linhaDoContato(page).getByText("Cliente", { exact: true })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /^Tag:/ }).click();
await page.getByRole("menuitem", { name: "cliente", exact: true }).click();
// Checkbox (#1274): marca e NÃO fecha o menu; fecha-se para o gatilho sair
// do aria-hidden que o Radix põe no resto da página.
await page.getByRole("menuitemcheckbox", { name: "cliente", exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: "Tag: cliente" })).toBeVisible();
await expect(linhaDoContato(page)).toBeVisible({ timeout: 30_000 });
await evidencia(page, info, "5-contatos-filtro-cliente");
+51 -32
View File
@@ -168,7 +168,7 @@ test.describe("filtro por marcador, pela tela", () => {
await page.addInitScript(() => {
const w = window as unknown as { __filtro?: string[] };
w.__filtro = [];
const conta = () => document.querySelectorAll('[role="option"]').length;
const conta = () => document.querySelectorAll('[role="menuitemcheckbox"]').length;
// Teto: o painel da conversa re-renderiza a cada 4s (`ReplyReviewPanel`
// tem `refetchInterval: 4000`, e isso é da BASE, não desta branch — 72
// chamadas medidas no trace). Sem teto, o filme vira ruído e o log do job
@@ -176,15 +176,15 @@ test.describe("filtro por marcador, pela tela", () => {
const marca = (verbo: string, alvo: string) =>
w.__filtro!.length < 400 &&
w.__filtro!.push(
`${performance.now().toFixed(0)}ms ${verbo} ${alvo} | listbox=${
document.querySelectorAll('[role="listbox"]').length
`${performance.now().toFixed(0)}ms ${verbo} ${alvo} | menu=${
document.querySelectorAll('[role="menu"]').length
} options=${conta()} altura=${document.body.scrollHeight} viewport=${window.innerHeight}`,
);
const interessa = (nó: Node): string | null => {
if (!(nó instanceof Element)) return null;
const papel = nó.getAttribute("role");
if (papel === "option" || papel === "listbox") return `${papel}:${nó.textContent?.trim().slice(0, 40) ?? ""}`;
const dentro = nó.querySelector('[role="listbox"], [role="option"]');
if (papel === "menuitemcheckbox" || papel === "menu") return `${papel}:${nó.textContent?.trim().slice(0, 40) ?? ""}`;
const dentro = nó.querySelector('[role="menu"], [role="menuitemcheckbox"]');
return dentro ? `ancestral-de:${dentro.getAttribute("role")}` : null;
};
new MutationObserver((lista) => {
@@ -217,12 +217,12 @@ test.describe("filtro por marcador, pela tela", () => {
await marcar(page, "Adicionar tag ao contato", "/contacts/", tagDoContato);
// 3. O seletor oferece a UNIÃO dos dois vocabulários.
const seletor = page.getByRole("combobox", { name: "Filtrar por tag" });
const seletor = page.getByRole("button", { name: "Filtrar por tag" });
await seletor.click();
await expect(page.getByRole("option", { name: tagDaConversa, exact: true })).toBeVisible({
await expect(page.getByRole("menuitemcheckbox", { name: tagDaConversa, exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("option", { name: tagDoContato, exact: true })).toBeVisible();
await expect(page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true })).toBeVisible();
// ── EXPERIMENTO: o cerco ao `captura()` ───────────────────────────────────
// `fullPage: true` redimensiona a viewport para a ALTURA DO DOCUMENTO. Esta
@@ -233,13 +233,13 @@ test.describe("filtro por marcador, pela tela", () => {
// opção já tinha saído antes dele", que é a bifurcação que um bit não dá.
const olha = async (marco: string) => {
const s = await page.evaluate(() => ({
listbox: document.querySelectorAll('[role="listbox"]').length,
opcoes: [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent?.trim() ?? ""),
menu: document.querySelectorAll('[role="menu"]').length,
opcoes: [...document.querySelectorAll('[role="menuitemcheckbox"]')].map((o) => o.textContent?.trim() ?? ""),
altura: document.body.scrollHeight,
viewport: window.innerHeight,
}));
registra(
`filtro-instrumento · ${marco} · listbox=${s.listbox} opcoes=[${s.opcoes.join("|")}] altura=${s.altura} viewport=${s.viewport}`,
`filtro-instrumento · ${marco} · menu=${s.menu} opcoes=[${s.opcoes.join("|")}] altura=${s.altura} viewport=${s.viewport}`,
);
};
await olha("antes-do-screenshot");
@@ -252,7 +252,7 @@ test.describe("filtro por marcador, pela tela", () => {
// agora é o filme, despejado logo abaixo.
try {
await page
.getByRole("option", { name: tagDoContato, exact: true })
.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true })
.click({ timeout: 15_000 });
registra("filtro-instrumento · clique OK");
} catch (e) {
@@ -266,14 +266,21 @@ test.describe("filtro por marcador, pela tela", () => {
registra(`filtro-instrumento · ERROS (${erros.length}):`);
for (const linha of erros) registra(`filtro-instrumento · ${linha}`);
await olha("depois-do-clique");
// O menu de checkbox marca e NÃO fecha (#1274): fecha-se para a lista voltar
// a ser alcançável (o Radix marca o resto da página com aria-hidden).
await page.keyboard.press("Escape");
await expect(itemDaLista(page, b.conversa)).toBeVisible({ timeout: 30_000 });
await expect(itemDaLista(page, a.conversa)).toHaveCount(0);
await expect(itemDaLista(page, n.conversa)).toHaveCount(0);
await captura(page, "filtro-tela-02-inbox-pelo-contato");
// 5. O marcador da CONVERSA acha a outra — e só ela.
// Com várias etiquetas o padrão é E na MESMA caixa: marcar a da conversa
// sem desmarcar a do contato zeraria a lista. Troca-se, como antes.
await seletor.click();
await page.getByRole("option", { name: tagDaConversa, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: tagDaConversa, exact: true }).click();
await page.keyboard.press("Escape");
await expect(itemDaLista(page, a.conversa)).toBeVisible({ timeout: 30_000 });
await expect(itemDaLista(page, b.conversa)).toHaveCount(0);
await expect(itemDaLista(page, n.conversa)).toHaveCount(0);
@@ -332,7 +339,7 @@ test.describe("filtro por marcador, pela tela", () => {
await page.addInitScript(() => {
const w = window as unknown as { __filtro?: string[] };
w.__filtro = [];
const conta = () => document.querySelectorAll('[role="option"]').length;
const conta = () => document.querySelectorAll('[role="menuitemcheckbox"]').length;
// Teto: o painel da conversa re-renderiza a cada 4s (`ReplyReviewPanel`
// tem `refetchInterval: 4000`, e isso é da BASE, não desta branch — 72
// chamadas medidas no trace). Sem teto, o filme vira ruído e o log do job
@@ -340,15 +347,15 @@ test.describe("filtro por marcador, pela tela", () => {
const marca = (verbo: string, alvo: string) =>
w.__filtro!.length < 400 &&
w.__filtro!.push(
`${performance.now().toFixed(0)}ms ${verbo} ${alvo} | listbox=${
document.querySelectorAll('[role="listbox"]').length
`${performance.now().toFixed(0)}ms ${verbo} ${alvo} | menu=${
document.querySelectorAll('[role="menu"]').length
} options=${conta()} altura=${document.body.scrollHeight} viewport=${window.innerHeight}`,
);
const interessa = (nó: Node): string | null => {
if (!(nó instanceof Element)) return null;
const papel = nó.getAttribute("role");
if (papel === "option" || papel === "listbox") return `${papel}:${nó.textContent?.trim().slice(0, 40) ?? ""}`;
const dentro = nó.querySelector('[role="listbox"], [role="option"]');
if (papel === "menuitemcheckbox" || papel === "menu") return `${papel}:${nó.textContent?.trim().slice(0, 40) ?? ""}`;
const dentro = nó.querySelector('[role="menu"], [role="menuitemcheckbox"]');
return dentro ? `ancestral-de:${dentro.getAttribute("role")}` : null;
};
new MutationObserver((lista) => {
@@ -381,12 +388,12 @@ test.describe("filtro por marcador, pela tela", () => {
await marcar(page, "Adicionar tag ao contato", "/contacts/", tagDoContato);
// 3. O seletor oferece a UNIÃO dos dois vocabulários.
const seletor = page.getByRole("combobox", { name: "Filtrar por tag" });
const seletor = page.getByRole("button", { name: "Filtrar por tag" });
await seletor.click();
await expect(page.getByRole("option", { name: tagDaConversa, exact: true })).toBeVisible({
await expect(page.getByRole("menuitemcheckbox", { name: tagDaConversa, exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("option", { name: tagDoContato, exact: true })).toBeVisible();
await expect(page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true })).toBeVisible();
// ── EXPERIMENTO: o mesmo olhar do caso A, sem o screenshot no meio ───────
// O caso A mede ANTES e DEPOIS do `captura()`. Aqui não há `captura()` no
@@ -394,13 +401,13 @@ test.describe("filtro por marcador, pela tela", () => {
// lista quando nada redimensiona a viewport.
const olha = async (marco: string) => {
const s = await page.evaluate(() => ({
listbox: document.querySelectorAll('[role="listbox"]').length,
opcoes: [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent?.trim() ?? ""),
menu: document.querySelectorAll('[role="menu"]').length,
opcoes: [...document.querySelectorAll('[role="menuitemcheckbox"]')].map((o) => o.textContent?.trim() ?? ""),
altura: document.body.scrollHeight,
viewport: window.innerHeight,
}));
registra(
`ablacao-instrumento · ${marco} · listbox=${s.listbox} opcoes=[${s.opcoes.join("|")}] altura=${s.altura} viewport=${s.viewport}`,
`ablacao-instrumento · ${marco} · menu=${s.menu} opcoes=[${s.opcoes.join("|")}] altura=${s.altura} viewport=${s.viewport}`,
);
};
// ← A ABLAÇÃO: nenhuma screenshot aqui. É a ÚNICA diferença para o caso A.
@@ -418,7 +425,7 @@ test.describe("filtro por marcador, pela tela", () => {
let falhaDoClique: Error | null = null;
try {
await page
.getByRole("option", { name: tagDoContato, exact: true })
.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true })
.click({ timeout: 15_000 });
registra("ablacao-instrumento · clique OK");
} catch (e) {
@@ -435,14 +442,21 @@ test.describe("filtro por marcador, pela tela", () => {
await olha("depois-do-clique");
await captura(page, "ablacao-filtro-tela-01-uniao-dos-vocabularios-tardia");
if (falhaDoClique) throw falhaDoClique;
// O menu de checkbox marca e NÃO fecha (#1274): fecha-se para a lista voltar
// a ser alcançável (o Radix marca o resto da página com aria-hidden).
await page.keyboard.press("Escape");
await expect(itemDaLista(page, b.conversa)).toBeVisible({ timeout: 30_000 });
await expect(itemDaLista(page, a.conversa)).toHaveCount(0);
await expect(itemDaLista(page, n.conversa)).toHaveCount(0);
await captura(page, "ablacao-filtro-tela-02-inbox-pelo-contato");
// 5. O marcador da CONVERSA acha a outra — e só ela.
// Com várias etiquetas o padrão é E na MESMA caixa: marcar a da conversa
// sem desmarcar a do contato zeraria a lista. Troca-se, como antes.
await seletor.click();
await page.getByRole("option", { name: tagDaConversa, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: tagDaConversa, exact: true }).click();
await page.keyboard.press("Escape");
await expect(itemDaLista(page, a.conversa)).toBeVisible({ timeout: 30_000 });
await expect(itemDaLista(page, b.conversa)).toHaveCount(0);
await expect(itemDaLista(page, n.conversa)).toHaveCount(0);
@@ -500,15 +514,16 @@ test.describe("filtro por marcador, pela tela", () => {
await expect(page.getByRole("group", { name: `Lead: ${cardNeutro}` })).toBeVisible();
await page.getByRole("button", { name: "Tag: todas" }).click();
await expect(page.getByRole("menuitem", { name: tagDoContato, exact: true })).toBeVisible({
await expect(page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true })).toBeVisible({
timeout: 30_000,
});
// O da CONVERSA também é oferecido: é a terceira caixa, e o dono decidiu
// que ela filtra o quadro (doc 40, item 7, 19/09).
await expect(page.getByRole("menuitem", { name: soNaConversa, exact: true })).toBeVisible();
await expect(page.getByRole("menuitemcheckbox", { name: soNaConversa, exact: true })).toBeVisible();
await captura(page, "filtro-tela-04-quadro-oferece-o-do-contato-e-o-da-conversa");
await page.getByRole("menuitem", { name: tagDoContato, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("group", { name: `Lead: ${cardMarcado}` })).toBeVisible({
timeout: 30_000,
});
@@ -516,9 +531,13 @@ test.describe("filtro por marcador, pela tela", () => {
await captura(page, "filtro-tela-05-quadro-filtrado-pelo-contato");
// E pelo marcador da conversa: o mesmo card, e o neutro continua fora.
// Com um marcador escolhido, o botão do seletor passa a se chamar por ele.
await page.getByRole("button", { name: tagDoContato, exact: true }).click();
await page.getByRole("menuitem", { name: soNaConversa, exact: true }).click();
// Com um marcador escolhido, o botão do seletor passa a se chamar
// "Tag: <marcador>". E o segundo marcador SOMA (E na mesma caixa), então
// desmarca-se o do contato antes, para trocar em vez de misturar caixas.
await page.getByRole("button", { name: `Tag: ${tagDoContato}`, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: tagDoContato, exact: true }).click();
await page.getByRole("menuitemcheckbox", { name: soNaConversa, exact: true }).click();
await page.keyboard.press("Escape");
await expect(page.getByRole("group", { name: `Lead: ${cardMarcado}` })).toBeVisible({
timeout: 30_000,
});
@@ -336,6 +336,15 @@ const PARES: Array<{
arquivo: "lib/ai/conversa-do-caso/vocabulario.ts",
simbolo: "CASE_CHAT_AUTHOR_KINDS",
},
{
tabela: "knowledge_searches",
coluna: "author_kind",
// lib/ai/knowledge/busca.ts → KNOWLEDGE_SEARCH_AUTHOR_KINDS (tupla `as const`).
// Nasce com a migration 0484 (#1877): a rota da caixa "Acervo" grava
// `'human'` e a Evolução separa as séries por esta coluna.
arquivo: "lib/ai/knowledge/busca.ts",
simbolo: "KNOWLEDGE_SEARCH_AUTHOR_KINDS",
},
{
tabela: "passagens_de_atendimento",
coluna: "motor",
+11 -2
View File
@@ -125,7 +125,16 @@ describe("o marcador da contagem é o da lista — uma régua só", () => {
});
it.each([CONTAGEM, LISTA])("%s consome a régua", (caminho) => {
expect(readFileSync(caminho, "utf8")).toContain("aplicarMarcador(");
// ⚠️ `aplicarMarcadores` (o PLURAL) desde #1274: quem lista e quem conta
// precisam aplicar a MESMA função, e a plural é a que sabe o E/OU e o caso
// de uma etiqueta só. Se a cerca aceitasse as duas, uma rota que voltasse
// ao singular passaria aqui e perderia o E/OU — que é o filtro inteiro.
const src = readFileSync(caminho, "utf8");
expect(src).toContain("aplicarMarcadores(");
expect(
src,
"a rota voltou ao caminho singular — o filtro de VÁRIAS etiquetas (E/OU) seria ignorado",
).not.toMatch(/aplicarMarcador\((?!s)/);
});
it.each([CONTAGEM, LISTA])("%s não escreve o predicado à mão", (caminho) => {
@@ -193,7 +202,7 @@ describe("nenhuma contagem é montada por fora da fábrica", () => {
fonte.indexOf("const countExact = () =>"),
fonte.indexOf("await Promise.all(["),
);
expect(fabrica, "o marcador não entra na fábrica").toContain("aplicarMarcador(");
expect(fabrica, "o marcador não entra na fábrica").toContain("aplicarMarcadores(");
});
it("a aba Fechadas TEM contagem — o concorrente mostra 8067 e nós mostrávamos nada", () => {
+495
View File
@@ -0,0 +1,495 @@
/**
* O FILTRO POR VÁRIAS ETIQUETAS — E/OU NAS TRÊS LISTAS, SEM QUEBRAR O `?tag=` (#1274)
*
* ─── O que este arquivo afirma, e por que cada afirmação é diferente ───────
*
* A feature é pequena e o defeito possível é silencioso. Um filtro que não
* filtra devolve uma lista *plausível*: o operador lê "nenhuma conversa" ou
* "quase todas" e não tem como saber que a escolha dele foi ignorada. Por isso
* nada aqui é testado pelo resultado visível na tela, e sim pelo TEXTO que sai
* para o PostgREST — é o único lugar onde E e OU são distinguíveis sem banco.
*
* Os cinco blocos:
*
* 1. **A régua** (E/OU, o caso de uma etiqueta, o controle negativo de vazio).
* 2. **O schema e a rota** (a repetição na URL sobrevive até o handler).
* 3. **A não-regressão do `?tag=` singular**, byte a byte.
* 4. **O funil**, que filtra no cliente e por isso não tem `cs`/`ov` para
* delegar — a semântica é reimplementada e precisa bater.
* 5. **O handler**, que é quem monta o texto que vai ao PostgREST: a expressão
* do E e a do OU, nas DUAS rotas que filtram no servidor (conversas e
* contatos).
*
* ─── A semântica, e por que ela é a que é ──────────────────────────────────
*
* - **E** = a caixa tem TODAS as escolhidas (`cs` com a lista inteira). As DUAS
* caixas (conversa e contato) continuam em OU, que é a régua que a migration
* 0323 fixou.
* - **OU** = qualquer uma das escolhidas, em qualquer das duas caixas (`ov`).
*
* "vip na conversa E orçamento no contato" (misturando caixas) NÃO é aceito, e
* é decisão de produto registrada na issue — não um furo. O caso está escrito
* aqui para que ninguém o "conserte" achando que é bug.
*/
import { describe, expect, it } from "vitest";
import { listContactsHandler } from "@/app/api/v1/contacts/_handler";
import { listConversationsHandler } from "@/app/api/v1/conversations/_handler";
import {
aplicarMarcador,
aplicarMarcadores,
arrayDeUmValorParaOr,
listaDeValoresParaOr,
marcadoresEscolhidos,
modoDeEtiqueta,
predicadoDeVariasEtiquetas,
} from "@/lib/inbox/marcador-da-conversa";
import { listConversationsQuerySchema } from "@/lib/schemas";
import { applyFilters, filtersFromParams, filtersToParams, marcadoresDoFiltro } from "@/lib/kanban/filters";
import type { Lead } from "@/lib/types/leads";
/** Uma consulta que registra os `or=` aplicados, como o builder do supabase-js. */
function consultaQueRegistra() {
const ors: string[] = [];
const consulta = {
or: (filtro: string) => {
ors.push(filtro);
return consulta;
},
};
return { consulta, ors };
}
/** Os `or=` que a régua emite para esta lista de marcadores e este modo. */
const orsDe = (marcadores: string[], modo?: "e" | "ou"): string[] => {
const reg = consultaQueRegistra();
aplicarMarcadores(reg.consulta, marcadores, modo);
return reg.ors;
};
// ══════════════════════════════════════════════════════ 1. A RÉGUA
describe("a régua do filtro por várias etiquetas", () => {
it("E com DUAS etiquetas: `cs` com a LISTA nas DUAS caixas", () => {
// O `cs` de um valor só cada seria OU ("vip OU orçamento"); a lista inteira
// num literal só é o "contém todos" que o E pede.
expect(predicadoDeVariasEtiquetas(["vip", "orcamento"], "e")).toBe(
'tags.cs."{\\"vip\\",\\"orcamento\\"}",tags_do_contato.cs."{\\"vip\\",\\"orcamento\\"}"',
);
});
it("OU com DUAS etiquetas: `ov` (tem ALGUMA) nas DUAS caixas", () => {
// Aqui `cs` seria o E de novo com outro nome — e o nome mente. O `ov` é o
// "tem ALGUMA", que é o que OU pede.
expect(predicadoDeVariasEtiquetas(["vip", "orcamento"], "ou")).toBe(
'tags.ov."{\\"vip\\",\\"orcamento\\"}",tags_do_contato.ov."{\\"vip\\",\\"orcamento\\"}"',
);
});
it("cada elemento da lista vai entre ASPAS — sem elas seria um marcador só", () => {
// `{vip,orcamento}` é um literal de UM elemento cujo nome é "vip,orcamento".
// O filtro casaria conversas com uma etiqueta chamada assim, que ninguém
// escreveu, e a lista de duas jamais casaria nada. Esta é a diferença entre
// uma lista e uma palavra, e ela é um caractere só.
const valor = listaDeValoresParaOr(["vip", "orcamento"]);
// O que o PostgREST desescapifica é o literal `{"vip","orcamento"}`.
expect(valor.replace(/\\\\/g, "")).toBe('"{\\"vip\\",\\"orcamento\\"}"');
});
it("a lista de UM é byte a byte o valor único — é o que segura o `?tag=` antigo", () => {
// Sem este caso, a forma de um item teria aspas internas diferentes da
// singular, e o `?tag=vip` casaria um literal diferente do de sempre — lista
// quase inteira, sem erro.
expect(listaDeValoresParaOr(["vip"])).toBe(arrayDeUmValorParaOr("vip"));
});
it("E e OU com as MESMAS etiquetas só diferem no OPERADOR — e é o que se espera", () => {
const e = predicadoDeVariasEtiquetas(["vip", "orcamento"], "e");
const ou = predicadoDeVariasEtiquetas(["vip", "orcamento"], "ou");
expect(e).not.toBe(ou);
// As DUAS caixas continuam em OU nos dois modos: é a régua da 0323, e perdê-la
// em um dos modos faria o filtro de etiqueta do contato desaparecer.
for (const predicado of [e, ou]) {
expect(predicado).toContain("tags.");
expect(predicado).toContain("tags_do_contato.");
}
expect(e.startsWith("tags.cs.")).toBe(true);
expect(ou.startsWith("tags.ov.")).toBe(true);
});
it("modo `e` é o PADRÃO: sem dizer nada, o filtro é o de sempre", () => {
expect(predicadoDeVariasEtiquetas(["vip", "orcamento"])).toBe(
predicadoDeVariasEtiquetas(["vip", "orcamento"], "e"),
);
});
it("CONTROLE NEGATIVO: lista vazia, ou só vazios/repetidos, não filtra NADA", () => {
// Sem este caso, uma implementação que sempre emitisse um `or=` passaria nos
// de cima e a contagem/lista passaria a MENTIR para baixo: em vez de devolver
// tudo (o que "sem filtro" significa), devolveria nada — e a tela pintaria
// "nenhuma conversa" com o filtro desligado.
expect(orsDe([])).toEqual([]);
expect(orsDe(["", " "])).toEqual([]);
// ⚠️ Repetido NAO e lista vazia: `["vip","vip","vip"]` reduz a UMA
// etiqueta, e uma etiqueta e o filtro de sempre — nao `[]`. O que se fixa
// aqui e que nao ha `or=` para uma lista que nao sobrou nada, e o caso de
// uma so ja esta coberto pela comparacao byte a byte do singular.
expect(orsDe(["vip", "vip", "vip"])).toEqual(orsDe(["vip"]));
expect(marcadoresEscolhidos([undefined, null, " "])).toEqual([]);
// E o predicado puro também devolve vazio — ninguém deve conseguir escrever
// `or=()` com ele.
expect(predicadoDeVariasEtiquetas([], "e")).toBe("");
expect(predicadoDeVariasEtiquetas([], "ou")).toBe("");
});
it("a lista escolhida sai sem vazio, sem repetido, na ordem da primeira aparição", () => {
// ⚠️ O que esta função NÃO faz: mexer na CAIXA. `VIP` e `vip` são dois
// marcadores diferentes para ela, e é a normalização do schema
// (`conversationTagSchema`, minúsculo) que os junta antes. Se esta função
// também normalizasse, teríamos DUAS regras de caixa no mesmo caminho, e a
// segunda sempre diverge da primeira — que é o defeito que o arquivo de
// regra existe para evitar.
expect(marcadoresEscolhidos(["vip", "", " ", "orcamento", "vip", " VIP "])).toEqual([
"vip",
"orcamento",
"VIP",
]);
});
it("`modo` fora dos dois não vira modo: fica indefinido, e quem chamou decide", () => {
expect(modoDeEtiqueta("e")).toBe("e");
expect(modoDeEtiqueta("ou")).toBe("ou");
expect(modoDeEtiqueta("xou")).toBeUndefined();
expect(modoDeEtiqueta(null)).toBeUndefined();
expect(modoDeEtiqueta(undefined)).toBeUndefined();
});
it("marcador com vírgula, chave e aspas chega inteiro nos DOIS modos", () => {
// O mesmo cuidado do valor único (`inbox-filtro-de-tag-le-as-duas-caixas`),
// estendido à lista: o `,` DENTRO do nome de um marcador não pode ter virado
// o separador da lista, e é por isso que o escape é por item.
const marcador = 'a,b{c}"d\\e';
const valor = listaDeValoresParaOr([marcador, "outra"]);
// Um ESCAPE por caractere perigoso e, por baixo, DOIS elementos: a vírgula do
// nome continua dentro do elemento, e a que separa os dois fica solta.
expect(valor).toBe('"{\\"a,b{c}\\\\\\"d\\\\\\\\e\\",\\"outra\\"}"');
// E o predicado dos dois modos carrega a mesma coisa — nenhum dos dois
// pode perder o marcador por causa de caractere especial.
for (const modo of ["e", "ou"] as const) {
expect(predicadoDeVariasEtiquetas([marcador, "outra"], modo)).toContain(valor);
}
});
});
// ══════════════════════════════════════════════════════ 3. A NÃO-REGRESSÃO DO SINGULAR
describe("o `?tag=` singular continua byte a byte o de antes", () => {
it("UMA etiqueta pelo caminho plural é IDÊNTICA ao caminho singular", () => {
// Este é o teste que impede a regressão SILENCIOSA: um `cs` de lista de um
// item e o `cs` de valor único casam a mesma conversa, então trocar a forma
// não quebraria a lista — só a deixaria de filtrar, e nenhum teste de
// contagem notaria.
const singular = consultaQueRegistra();
aplicarMarcador(singular.consulta, "vip");
const plural = consultaQueRegistra();
aplicarMarcadores(plural.consulta, ["vip"]);
expect(plural.ors).toEqual(singular.ors);
// O literal é escrito a partir da PRÓPRIA função singular, e não copiado à
// mão: um literal escrito à mão diverge da regra no primeiro ajuste de escape
// que ela ganhe, e o teste passa a proteger a cópia, não o comportamento.
const valor = arrayDeUmValorParaOr("vip");
expect(plural.ors).toEqual([
`tags.cs.${valor},tags_do_contato.cs.${valor}`,
]);
});
it("o modo não muda NADA com uma etiqueta só — nem E nem OU", () => {
// `?tag=vip&modo=ou` é um link que o produto aceita, e ele tem de filtrar
// como `?tag=vip`. Se o modo alterasse o caso de um item, a mesma
// configuração teria dois resultados, e o operador não teria como saber qual.
for (const modo of ["e", "ou"] as const) {
expect(orsDe(["vip"], modo)).toEqual(
orsDe(["vip"]),
);
}
});
});
// ══════════════════════════════════════════════════════ 2. O SCHEMA E A ROTA
describe("a query string leva as várias etiquetas até o schema", () => {
const spDe = (q: string) => new URLSearchParams(q);
it("a repetição na URL vira LISTA no schema", () => {
const r = listConversationsQuerySchema.parse({
tag: spDe("?tag=vip&tag=orçamento").getAll("tag"),
});
expect(r.tag).toEqual(["vip", "orçamento"]);
});
it("`?tag=vip` (um só) continua sendo ACEITO, e vira lista de um", () => {
const r = listConversationsQuerySchema.parse({
tag: spDe("?tag=vip").getAll("tag"),
});
expect(r.tag).toEqual(["vip"]);
});
it("a forma ANTIGA (string, não array) também passa — a API chama com as duas", () => {
// `get` devolve string; `getAll` devolve array. Se o schema só aceitasse uma,
// a outra chamada quebraria com 422 — e a quebra seria num link salvo.
expect(listConversationsQuerySchema.parse({ tag: "vip" }).tag).toEqual(["vip"]);
expect(listConversationsQuerySchema.parse({}).tag).toBeUndefined();
});
it("o marcador é normalizado item a item (`?tag=VIP` acha o que gravou `vip`)", () => {
const r = listConversationsQuerySchema.parse({ tag: [" VIP ", "Orçamento"] });
expect(r.tag).toEqual(["vip", "orçamento"]);
});
it("`modo` fora dos dois é RECUSADO (422) — quem chama precisa corrigir", () => {
const r = listConversationsQuerySchema.safeParse({ tag: ["vip"], modo: "xou" });
expect(r.success).toBe(false);
// E o valor válido passa.
expect(listConversationsQuerySchema.safeParse({ tag: ["vip"], modo: "ou" }).success).toBe(true);
});
it("CONTROLE: mais etiquetas que o teto é recusado, e não descartado em silêncio", () => {
const muitas = Array.from({ length: 21 }, (_, i) => `t${i}`);
expect(listConversationsQuerySchema.safeParse({ tag: muitas }).success).toBe(false);
// Descartar as que passassem do teto daria uma lista MENOR sem dizer por quê —
// e uma lista curta parece resposta.
expect(listConversationsQuerySchema.safeParse({ tag: muitas.slice(0, 20) }).success).toBe(true);
});
});
// ══════════════════════════════════════════════════════ 4. O FUNIL (filtro no cliente)
const lead = (id: string, tags: Partial<Lead> = {}): Lead =>
({
id,
title: `lead ${id}`,
status: "open",
owner_user_id: "u1",
owner_agent_id: null,
tags: [],
conversation_tags: [],
contact_tags: [],
...tags,
}) as Lead;
describe("o funil: E e OU dão listas DIFERENTES", () => {
const ambos = lead("a", { tags: ["vip", "orcamento"] });
const soVip = lead("b", { tags: ["vip"] });
const soOrcamento = lead("c", { tags: ["orcamento"] });
const nenhum = lead("d", { tags: ["retorno"] });
const leia = [ambos, soVip, soOrcamento, nenhum];
it("E devolve só quem tem as DUAS", () => {
const out = applyFilters(leia, { tag: ["vip", "orcamento"], tagMode: "e" });
expect(out.map((l) => l.id)).toEqual(["a"]);
});
it("OU devolve quem tem QUALQUER uma — e a lista CRESCE", () => {
const out = applyFilters(leia, { tag: ["vip", "orcamento"], tagMode: "ou" });
expect(out.map((l) => l.id).sort()).toEqual(["a", "b", "c"]);
});
it("CONTROLE: a diferença entre E e OU é o TAMANHO, e ninguém confunde as duas", () => {
// Sem isto, o `tagMode` poderia ser lido e simplesmente ignorado, e os dois
// modos devolveriam a mesma lista — o filtro "funcionaria" e não filtraria.
const emE = applyFilters(leia, { tag: ["vip", "orcamento"], tagMode: "e" });
const emOu = applyFilters(leia, { tag: ["vip", "orcamento"], tagMode: "ou" });
expect(emE).toHaveLength(1);
expect(emOu.length).toBeGreaterThan(emE.length);
});
it("E é o PADRÃO: sem `tagMode`, o filtro é o de E", () => {
expect(applyFilters(leia, { tag: ["vip", "orcamento"] }).map((l) => l.id)).toEqual(["a"]);
});
it("o funil casa as TRÊS caixas do card (negócio, contato e conversa)", () => {
const porContato = lead("e", { contact_tags: ["vip", "orcamento"] });
const porConversa = lead("f", { conversation_tags: ["vip", "orcamento"] });
expect(applyFilters([porContato, porConversa], { tag: ["vip", "orcamento"] })).toHaveLength(2);
});
it("uma etiqueta SÓ no funil continua filtrando como antes", () => {
// A não-regressão do funil: `tag: "vip"` (string) tem de virar lista de um
// e casar igual a `tag: ["vip"]`.
expect(applyFilters(leia, { tag: "vip" }).map((l) => l.id).sort()).toEqual(["a", "b"]);
expect(applyFilters(leia, { tag: "vip" })).toEqual(applyFilters(leia, { tag: ["vip"] }));
});
it("CONTROLE NEGATIVO: sem etiquetas escolhidas, o funil não filtra por etiqueta", () => {
// Um `every` sobre lista vazia é `true` (vacuoso) e um `some` sobre lista
// vazia é `false` (nada casa). O primeiro é o que o "sem filtro" quer; o
// segundo esconderia o quadro inteiro. Aqui se fixa que lista vazia NÃO
// filha nada.
expect(applyFilters(leia, {})).toHaveLength(4);
expect(applyFilters(leia, { tag: [] })).toHaveLength(4);
expect(applyFilters(leia, { tag: [], tagMode: "ou" })).toHaveLength(4);
});
it("E do funil NÃO aceita mistura de caixas — é o caso que a issue deixa pendente", () => {
// O E do servidor é `tags.cs.{a,b}` OU `tags_do_contato.cs.{a,b}`: as duas
// etiquetas DENTRO de uma caixa. "vip na conversa E orçamento no negócio" é a
// mistura que a #1274 registra como decisão de produto e que o servidor não
// expressa — aceitar no funil e recusar no servidor faria o MESMO filtro
// devolver listas diferentes nas duas telas, sem erro em nenhuma.
const misturado = lead("g", { tags: ["vip"], contact_tags: ["orcamento"] });
expect(applyFilters([misturado], { tag: ["vip", "orcamento"], tagMode: "e" })).toHaveLength(0);
// No OU a mesma mistura passa: é "qualquer uma em qualquer caixa".
expect(applyFilters([misturado], { tag: ["vip", "orcamento"], tagMode: "ou" })).toHaveLength(1);
// E as DUAS na mesma caixa passam no E — em QUALQUER uma das três.
const naConversa = lead("h", { conversation_tags: ["vip", "orcamento"] });
const noNegocio = lead("i", { tags: ["vip", "orcamento"] });
expect(applyFilters([naConversa, noNegocio], { tag: ["vip", "orcamento"], tagMode: "e" })).toHaveLength(2);
});
it("uma etiqueta só no funil não muda de sentido por causa da mesma-caixa", () => {
// Com um item só, "todas na mesma caixa" é sinônimo de "está em alguma
// caixa": o filtro de sempre continua filtrando o de sempre.
const emCaixasDiferentes = lead("j", { tags: ["vip"], conversation_tags: ["vip"] });
expect(applyFilters([emCaixasDiferentes], { tag: "vip" })).toHaveLength(1);
});
});
describe("o deep-link do funil leva as várias etiquetas e volta", () => {
it("ida e volta: `?tag=vip&tag=orçamento&modo=ou` sobrevive ao F5", () => {
const params = filtersToParams({ tag: ["vip", "orçamento"], tagMode: "ou" });
const sp = new URLSearchParams(params);
// A URL precisa Trazer as DUAS: com `set`, a segunda substituiria a primeira.
expect(sp.getAll("tag")).toEqual(["vip", "orçamento"]);
expect(sp.get("modo")).toBe("ou");
const lido = filtersFromParams(sp);
expect(marcadoresDoFiltro(lido.tag)).toEqual(["vip", "orçamento"]);
expect(lido.tagMode).toBe("ou");
});
it("`?tag=vip` de hoje não ganha `&modo=e` colado — o link é o mesmo de sempre", () => {
const params = filtersToParams({ tag: "vip" });
expect(params).toBe("tag=vip");
// E a leitura devolve a mesma coisa que a de sempre.
expect(marcadoresDoFiltro(filtersFromParams(new URLSearchParams(params)).tag)).toEqual(["vip"]);
});
it("um `modo` inválido no link vira E, e não erro — o link é deep-link, não API", () => {
const lido = filtersFromParams(new URLSearchParams("tag=vip&tag=x&modo=xou"));
expect(lido.tagMode).toBeUndefined();
expect(marcadoresDoFiltro(lido.tag)).toEqual(["vip", "x"]);
});
});
// ══════════════════════════════════════════════════════ 5. O HANDLER
/**
* Um builder do supabase que registra cada chamada — o mesmo desenho de
* `inbox-filtro-de-tag-le-as-duas-caixas`, porque quem filtra no servidor só
* revela a semântica pelo TEXTO que manda ao PostgREST.
*/
function supabaseQueRegistra() {
const chamadas: { metodo: string; args: unknown[] }[] = [];
const proxy: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop) {
if (prop === "then") {
return (ok: (v: unknown) => unknown) => ok({ data: [], error: null });
}
return (...args: unknown[]) => {
chamadas.push({ metodo: String(prop), args });
return proxy;
};
},
},
) as Record<string, unknown>;
return { client: { from: () => proxy } as never, chamadas };
}
const ctxDoHandler = {
organization_id: "11111111-1111-4111-8111-111111111111",
requestId: "req-1274",
actor: { type: "user" as const, id: "user-1" },
} as never;
/** Os `or=` que a consulta emitiu, na ordem em que saíram. */
const orsDaConsulta = (chamadas: { metodo: string; args: unknown[] }[]): string[] =>
chamadas.filter((c) => c.metodo === "or").map((c) => String(c.args[0]));
/**
* Os textos EXATOS que as duas rotas têm de mandar. Estão escritos à mão de
* propósito: derivá-los de `predicadoDeVariasEtiquetas` protegeria a função
* contra si mesma, e é justamente a função que a sabotagem troca.
*/
const OR_DE_E = String.raw`tags.cs."{\"vip\",\"orcamento\"}",tags_do_contato.cs."{\"vip\",\"orcamento\"}"`;
const OR_DE_OU = String.raw`tags.ov."{\"vip\",\"orcamento\"}",tags_do_contato.ov."{\"vip\",\"orcamento\"}"`;
const OR_SINGULAR = String.raw`tags.cs."{\"vip\"}",tags_do_contato.cs."{\"vip\"}"`;
const OR_DE_CONTATOS_E = String.raw`tags.ov."{\"vip\"}",tags.ov."{\"orcamento\"}"`;
describe("o handler monta a expressão certa, nos DOIS modos", () => {
it("conversas, modo E: um `or=` só, com `cs` e as DUAS etiquetas num literal", async () => {
const { client, chamadas } = supabaseQueRegistra();
await listConversationsHandler(client, ctxDoHandler, {
limit: 50,
tag: ["vip", "orcamento"],
modo: "e",
} as never);
expect(orsDaConsulta(chamadas)).toEqual([OR_DE_E]);
});
it("conversas, modo OU: o MESMO literal com o operador `ov`", async () => {
const { client, chamadas } = supabaseQueRegistra();
await listConversationsHandler(client, ctxDoHandler, {
limit: 50,
tag: ["vip", "orcamento"],
modo: "ou",
} as never);
expect(orsDaConsulta(chamadas)).toEqual([OR_DE_OU]);
// O E e o OU diferem num caractere — e é o caractere certo.
expect(orsDaConsulta(chamadas)).not.toEqual([OR_DE_E]);
});
it("conversas, uma etiqueta só: continua o `or=` singular de sempre", async () => {
const { client, chamadas } = supabaseQueRegistra();
await listConversationsHandler(client, ctxDoHandler, {
limit: 50,
tag: ["vip"],
modo: "ou",
} as never);
expect(orsDaConsulta(chamadas)).toEqual([OR_SINGULAR]);
});
it("contatos, modo E: `contains` com a LISTA — um parâmetro só, sem `or=`", async () => {
const { client, chamadas } = supabaseQueRegistra();
await listContactsHandler(client, ctxDoHandler, {
limit: 25,
tag: ["vip", "orcamento"],
modo: "e",
} as never);
const contem = chamadas.filter((c) => c.metodo === "contains" && c.args[0] === "tags");
expect(contem.map((c) => c.args[1])).toEqual([["vip", "orcamento"]]);
// Um `or=` com `tags.ov` aqui seria o OU com o nome do E.
expect(orsDaConsulta(chamadas)).not.toContain(OR_DE_CONTATOS_E);
});
it("contatos, modo OU: um `or=` com um `ov` por etiqueta", async () => {
const { client, chamadas } = supabaseQueRegistra();
await listContactsHandler(client, ctxDoHandler, {
limit: 25,
tag: ["vip", "orcamento"],
modo: "ou",
} as never);
expect(orsDaConsulta(chamadas)).toEqual([OR_DE_CONTATOS_E]);
// O `contains` (E) não pode ter saído junto: repetir `contains` no
// PostgREST é E de novo, com o nome de OU.
const contem = chamadas.filter((c) => c.metodo === "contains" && c.args[0] === "tags");
expect(contem).toEqual([]);
});
it("contatos, uma etiqueta só: `contains` com lista de um, byte a byte o de antes", async () => {
const { client, chamadas } = supabaseQueRegistra();
await listContactsHandler(client, ctxDoHandler, { limit: 25, tag: ["vip"] } as never);
const contem = chamadas.filter((c) => c.metodo === "contains" && c.args[0] === "tags");
expect(contem.map((c) => c.args[1])).toEqual([["vip"]]);
});
});
@@ -146,8 +146,12 @@ describe("os pontos de chamada — a regra só vale se quem a usa a chama", () =
it("o filtro CASA pela mesma regra", () => {
const fonte = readFileSync("lib/kanban/filters.ts", "utf8");
expect(fonte, "applyFilters não filtra com cardTemMarcador(l, f.tag)").toContain(
"cardTemMarcador(l, f.tag)",
// ⚠️ O nome da chamada mudou com #1274: o filtro passou a casar uma LISTA de
// marcadores (com E/OU), então o predicado virou `cardTemMarcador(lead, m)`
// dentro de um `every`/`some`. A régua que este arquivo vigia é a mesma — a
// única diferença é quantos marcadores a chamada recebe.
expect(fonte, "applyFilters não filtra com a régua das três caixas").toContain(
"cardTemMarcador(lead, m)",
);
});
});
+4
View File
@@ -226,6 +226,10 @@ describe("InboxFilters — seletor de número e o filtro órfão", () => {
render(
<InboxFilters value={{ ...VALUE, tag: "etiqueta-orfa" }} onChange={() => {}} />,
);
// ⚠️ `getByLabelText` continua valendo (#1274): o seletor deixou de ser um
// `Select` (que era `role="combobox"`) e virou um botão de menu, mas o RÓTULO
// ACESSÍVEL é o mesmo — e é por ele que se procura o controle, e por ele que
// o dicionário de tradução o indexa.
const seletor = screen.getByLabelText("Filtrar por tag");
expect(seletor).toBeInTheDocument();
expect(seletor).toHaveTextContent("etiqueta-orfa");
@@ -124,6 +124,25 @@ describe("listConversationsHandler — filtro por marcador", () => {
expect(termosDoOr(or!).map((t) => t.elemento)).toEqual([marcador, marcador]);
});
it("VÁRIAS etiquetas: o E e o OU saem em OPERADORES diferentes (#1274)", async () => {
// A lista é lida pelo schema; aqui o handler é chamado direto, e a régua tem
// de fazer a parte dela. E e OU com as MESMAS etiquetas não podem produzir o
// mesmo `or=` — se produzissem, o filtro de duas etiquetas existiria e o
// E/OU não, que é a feature pela metade.
const e = orsDe(await rodar({ tag: ["vip", "orcamento"] }));
const ou = orsDe(await rodar({ tag: ["vip", "orcamento"], modo: "ou" }));
expect(e).toHaveLength(1);
expect(ou).toHaveLength(1);
expect(e[0]).toContain("tags.cs.");
expect(ou[0]).toContain("tags.ov.");
expect(e[0]).not.toBe(ou[0]);
// As DUAS caixas continuam em OU nos dois modos (a régua da 0323).
for (const or of [e[0]!, ou[0]!]) {
expect(or).toContain("tags.");
expect(or).toContain("tags_do_contato.");
}
});
it("sem marcador no filtro, nenhum `or` de marcador", async () => {
expect(orsDe(await rodar({}))).toEqual([]);
});
@@ -52,6 +52,24 @@ vi.mock("@/hooks/inbox/useConversationCounts", () => ({
const VALUE: InboxFiltersValue = { tab: "unassigned", search: "", onlyUnread: false };
const GATILHO = "Filtrar por tag";
/**
* ⚠️ O GATILHO DEIXOU DE SER UM `Select` (#1274) — e com ele mudou o PAPEL que a
* tela de reading usa.
*
* O `Select` do Radix é `role="combobox"` e as opções dele são `role="option"`.
* O `DropdownMenu` é `role="menu"` com `role="menuitemcheckbox"`, porque é
* multi-seleção: o item MARCA e NÃO fecha, que é o defeito que o `Select`
* tinha (a segunda escolha exigiria reabrir o menu).
*
* O que este arquivo continua vigando é o defeito, não o elemento: com o menu
* ABERTO, uma oscilação do vocabulário não pode DESMONTAR o gatilho. Por isso
* a busca é por NOME ACESSÍVEL (`Filtrar por tag`), que o botão continua tendo
* — o rótulo não mudou, e mudar o rótulo quebraria quem procura o controle
* (e a tradução no dicionário).
*/
const gatilho = (comMenuAberto = false) =>
screen.queryByRole("button", { name: GATILHO, ...(comMenuAberto ? { hidden: true } : {}) });
beforeEach(() => {
// Radix Select em jsdom: o gatilho usa captura de ponteiro e o conteúdo rola
// até o item — nenhum dos dois existe aqui.
@@ -72,9 +90,9 @@ async function abreOMenu() {
// vermelho que não ensina nada.
const user = userEvent.setup({ delay: null });
const tela = render(<InboxFilters value={VALUE} onChange={() => {}} />);
await user.click(screen.getByRole("combobox", { name: GATILHO }));
expect(screen.getByRole("option", { name: "vip" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "retorno" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: GATILHO }));
expect(screen.getByRole("menuitemcheckbox", { name: /vip/ })).toBeInTheDocument();
expect(screen.getByRole("menuitemcheckbox", { name: /retorno/ })).toBeInTheDocument();
return tela;
}
@@ -89,8 +107,11 @@ describe("o menu de etiqueta aberto sobrevive à oscilação do vocabulário", (
// Com o menu aberto o Radix marca o resto da árvore com `aria-hidden`, e é
// por `hidden: true` que o gatilho é alcançável — o que se afirma aqui é que
// ele não DESMONTOU, não que esteja exposto à leitura de tela.
expect(screen.queryByRole("combobox", { name: GATILHO, hidden: true })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "vip" })).toBeInTheDocument();
// Com o menu aberto o Radix marca o resto da árvore com `aria-hidden`, e é
// por isso que se procura o gatilho com `hidden: true`: o que se afirma aqui
// é que ele não DESMONTOU, e não que esteja exposto à leitura de tela.
expect(gatilho(true)).toBeInTheDocument();
expect(screen.getByRole("menuitemcheckbox", { name: /vip/ })).toBeInTheDocument();
});
it("vocabulário voltando VAZIO por um render não derruba o menu", async () => {
@@ -103,8 +124,11 @@ describe("o menu de etiqueta aberto sobrevive à oscilação do vocabulário", (
// Com o menu aberto o Radix marca o resto da árvore com `aria-hidden`, e é
// por `hidden: true` que o gatilho é alcançável — o que se afirma aqui é que
// ele não DESMONTOU, não que esteja exposto à leitura de tela.
expect(screen.queryByRole("combobox", { name: GATILHO, hidden: true })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "vip" })).toBeInTheDocument();
// Com o menu aberto o Radix marca o resto da árvore com `aria-hidden`, e é
// por isso que se procura o gatilho com `hidden: true`: o que se afirma aqui
// é que ele não DESMONTOU, e não que esteja exposto à leitura de tela.
expect(gatilho(true)).toBeInTheDocument();
expect(screen.getByRole("menuitemcheckbox", { name: /vip/ })).toBeInTheDocument();
});
});
@@ -113,20 +137,62 @@ describe("não-regressão: o que a condicional protegia", () => {
tagsRef.current = [];
tagsDoContatoRef.current = [];
render(<InboxFilters value={VALUE} onChange={() => {}} />);
expect(screen.queryByRole("combobox", { name: GATILHO })).not.toBeInTheDocument();
expect(gatilho()).not.toBeInTheDocument();
});
it("vocabulário em voo, sem nada conhecido ainda, também não desenha o seletor", () => {
tagsRef.current = undefined;
tagsDoContatoRef.current = undefined;
render(<InboxFilters value={VALUE} onChange={() => {}} />);
expect(screen.queryByRole("combobox", { name: GATILHO })).not.toBeInTheDocument();
expect(gatilho()).not.toBeInTheDocument();
});
it("filtro órfão mantém a válvula: o seletor aparece com a etiqueta que sumiu do vocabulário", () => {
tagsRef.current = ["retorno"];
tagsDoContatoRef.current = [];
render(<InboxFilters value={{ ...VALUE, tag: "apagada" }} onChange={() => {}} />);
expect(screen.getByRole("combobox", { name: GATILHO })).toBeInTheDocument();
expect(gatilho()).toBeInTheDocument();
});
it("filtro órfão de DUAS etiquetas também mantém a válvula (#1274)", () => {
// Com VÁRIAS etiquetas, "está no vocabulário" deixa de ser uma pergunta de
// sim/não: basta UMA das escolhidas ter sumido para o operador precisar da
// válvula. Sem este caso, uma combinação com uma etiqueta apagada ficaria sem
// forma de ser desfeita — o pior dos dois: filtro ativo sem como tirá-lo.
tagsRef.current = ["retorno"];
tagsDoContatoRef.current = [];
render(
<InboxFilters value={{ ...VALUE, tag: ["retorno", "apagada"] }} onChange={() => {}} />,
);
expect(gatilho()).toBeInTheDocument();
});
});
describe("o modo E/OU marca só o modo ativo (#1274)", () => {
// O ✓ manual usava a MESMA condição (`tagMode === "ou"`) nos dois itens: com OU
// os dois apareciam marcados, com E nenhum. O rádio marca um só e expõe
// `aria-checked`, que é o que este caso mede.
async function abreComDuas(tagMode?: "ou") {
const user = userEvent.setup({ delay: null });
render(
<InboxFilters value={{ ...VALUE, tag: ["vip", "retorno"], tagMode }} onChange={() => {}} />,
);
await user.click(screen.getByRole("button", { name: GATILHO }));
return {
e: screen.getByRole("menuitemradio", { name: "Todas (E)" }),
ou: screen.getByRole("menuitemradio", { name: "Qualquer uma (OU)" }),
};
}
it("com OU ativo, só o OU está marcado", async () => {
const { e, ou } = await abreComDuas("ou");
expect(ou).toHaveAttribute("aria-checked", "true");
expect(e).toHaveAttribute("aria-checked", "false");
});
it("sem modo (E, o padrão), só o E está marcado", async () => {
const { e, ou } = await abreComDuas();
expect(e).toHaveAttribute("aria-checked", "true");
expect(ou).toHaveAttribute("aria-checked", "false");
});
});
+27 -3
View File
@@ -62,8 +62,29 @@ async function queryRecebidaCom(qs: string): Promise<Record<string, unknown>> {
describe("GET /api/v1/conversations — o `tag` da query string chega ao handler", () => {
beforeEach(() => listConversationsHandler.mockClear());
it("com `?tag=vip`, o handler recebe `tag: \"vip\"`", async () => {
expect((await queryRecebidaCom("?tag=vip")).tag).toBe("vip");
// ⚠️ O `tag` virou LISTA pela MESMAVia que o `status` já tinha virado (e pelo
// mesmo motivo: um filtro de vários valores viaja como repetição na URL). A
// compatibilidade NÃO está no TIPO que chega ao handler e sim no PREDICADO: o
// caso de uma etiqueta desvia para `aplicarMarcador`, que produz o `or=` de
// sempre, byte a byte. O que este arquivo mede é a LEITURA da URL — e a
// leitura de uma repetição só existe com `getAll`.
it("com `?tag=vip`, o handler recebe a lista `['vip']`", async () => {
expect((await queryRecebidaCom("?tag=vip")).tag).toEqual(["vip"]);
});
it("com `?tag=vip&tag=orçamento`, as DUAS chegam — a segunda não se perde", async () => {
// A rotura que um `get` aqui causaria: leria a primeira e a tela mostraria
// duas etiquetas escolhidas filtrando por uma. Silencioso, e por isso um
// caso próprio, e não uma consequência do de cima.
expect((await queryRecebidaCom("?tag=vip&tag=orçamento")).tag).toEqual([
"vip",
"orçamento",
]);
});
it("`?tag=vip&modo=ou` chega com o modo — E/OU não pode ser lido só na tela", async () => {
const q = await queryRecebidaCom("?tag=vip&tag=orçamento&modo=ou");
expect([q.tag, q.modo]).toEqual([["vip", "orçamento"], "ou"]);
});
/**
@@ -72,6 +93,9 @@ describe("GET /api/v1/conversations — o `tag` da query string chega ao handler
* ficaria verde sem provar que a leitura da query string existe.
*/
it("sem `tag` na URL, o handler recebe `tag: undefined`", async () => {
// `getAll` devolve `[]` numa URL sem `tag`, e o schema transforma lista vazia
// em `undefined` — "sem filtro". Sem essa transformação, o handler receberia
// `[]`, que é um filtro que não casa nada: lista vazia com filtro desligado.
expect((await queryRecebidaCom("")).tag).toBeUndefined();
});
@@ -99,6 +123,6 @@ describe("GET /api/v1/conversations — o `tag` da query string chega ao handler
it("os dois juntos convivem — `?status=open&tag=vip`", async () => {
const q = await queryRecebidaCom("?status=open&tag=vip");
expect([q.status, q.tag]).toEqual([["open"], "vip"]);
expect([q.status, q.tag]).toEqual([["open"], ["vip"]]);
});
});
@@ -71,9 +71,11 @@ describe("o que a escrita grava é o que o filtro procura (#1224)", () => {
const criado = contactCreateSchema.parse({ tags: ["VIP"] });
const filtro = contactListQuerySchema.parse({ tag: "VIP" });
// A comparação do handler é `contains("tags", [q.tag])`: é esta igualdade que
// faz o contato marcado como "VIP" aparecer em `?tag=vip`.
expect(criado.tags).toEqual([filtro.tag]);
// A comparação do handler é `contains("tags", q.tag)`: é esta igualdade que
// faz o contato marcado como "VIP" aparecer em `?tag=vip`. O filtro virou
// LISTA com #1274 (para aceitar várias etiquetas), e o que tem de casar
// agora é a lista contra a lista — daí o `[...filtro.tag]`.
expect(criado.tags).toEqual([...(filtro.tag ?? [])]);
});
});
+605
View File
@@ -0,0 +1,605 @@
/**
* O RELATÓRIO POR ETIQUETA — os três cortes que a F1 da #1833 tem de acertar.
*
* ## Por que a fixture é uma tabelinha que APLICA os predicados
*
* O jeito barato de "provar" escopo é gravar os `.eq()` e conferir que
* `organization_id` apareceu na chamada. Isso guarda a CHAMADA, não o efeito: um
* predicado escrito no lugar errado satisfaz a asserção e o relatório do vizinho
* continua saindo no número de quem chamou. Aqui o dublê filtra de verdade — é
* o que sobrou na tabela que se mede (mesma régua de
* `tests/unit/central-avisos-resolver-em-lote.test.ts`).
*
* Ele também emula o `max_rows = 1000` do PostgREST: `range(a, b)` nunca devolve
* mais que o teto do servidor, mesmo que o código peça tudo. Um dublê que
* entregasse a tabela inteira aprovaria uma rota que soma página parcial com
* cara de total — o defeito que o `/reports/financeiro` mediu em dinheiro.
*
* ## Os três casos (seed da entrega)
*
* 1. Etiqueta em uso SEM conversa no período devolve `0` e NÃO some da lista —
* sumir seria a armadilha 1 da issue (etiqueta que aparece e não vira número)
* com outra roupa.
* 2. Período vazio devolve lista vazia com `sem_dados` — nunca uma tabela de
* zeros fingindo que houve relatório.
* 3. A rota não vaza conversa de outra organização — pela TABELA, não pela chamada.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/v1/reports/tags/route";
import { requireRole } from "@/lib/auth/require-role";
import { createClient } from "@/lib/supabase/server";
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
const ORG = "11111111-1111-4111-8111-111111111111";
const OUTRA_ORG = "99999999-9999-4999-8999-999999999999";
const USUARIO = "22222222-2222-4222-8222-222222222222";
interface Conversa {
id: string;
organization_id: string;
tags: string[];
status: string;
created_at: string;
service_started_at?: string | null;
service_closed_at?: string | null;
awaiting_since: string | null;
last_outbound_at: string | null;
}
type Filtro = { col: string; op: "eq" | "gte" | "lt" | "is"; valor: unknown };
let tabela: Conversa[];
let leituras: Array<{
filtros: Filtro[];
alternativas: Filtro[][];
ordens: string[];
de: number;
ate: number;
}>;
let chamadasDeRpc: Array<{ nome: string; p_org: string | undefined }>;
/** Compara como o Postgres: instante, quando a coluna é data; senão texto. */
function casada(valor: unknown, alvo: unknown, op: Filtro["op"]): boolean {
if (op === "is") return (valor ?? null) === alvo;
const a = Date.parse(String(valor));
const b = Date.parse(String(alvo));
const ehData = !Number.isNaN(a) && !Number.isNaN(b);
if (ehData) {
if (op === "eq") return a === b;
if (op === "gte") return a >= b;
return a < b;
}
if (op === "eq") return valor === alvo;
if (op === "gte") return String(valor) >= String(alvo);
return String(valor) < String(alvo);
}
/**
* `.or("and(a.gte.X,a.lt.Y),and(a.is.null,b.gte.X)")` do PostgREST: cada `and(…)`
* é uma alternativa, e o valor pode ter `.` (o `.000Z` do ISO) — por isso só os
* dois primeiros pontos separam coluna, operador e valor.
*/
function alternativasDe(expr: string): Filtro[][] {
return [...expr.matchAll(/and\(([^)]*)\)/g)].map(([, dentro]) =>
dentro!.split(",").map((termo) => {
const [col, op, ...resto] = termo.split(".");
const bruto = resto.join(".");
return { col: col!, op: op as Filtro["op"], valor: bruto === "null" ? null : bruto };
}),
);
}
const linhaCasa = (linha: Conversa, filtros: Filtro[]) =>
filtros.every((f) => casada(linha[f.col as keyof Conversa], f.valor, f.op));
function clientFalso() {
return {
async rpc(nome: string, args: { p_org?: string }) {
chamadasDeRpc.push({ nome, p_org: args.p_org });
// `fn_tags_de_conversa_em_uso` é INVOKER: a RLS de `conversations` decide
// o que a função enxerga, e `p_org` errado devolve ZERO etiquetas.
const usadas = new Set<string>();
for (const conversa of tabela) {
if (conversa.organization_id !== args.p_org) continue;
for (const tag of conversa.tags) usadas.add(tag);
}
return { data: [...usadas].sort().map((tag) => ({ tag })), error: null };
},
from(nome: string) {
expect(nome).toBe("conversations");
const filtros: Filtro[] = [];
const ordens: string[] = [];
let alternativas: Filtro[][] = [];
let de = 0;
let ate = Number.MAX_SAFE_INTEGER;
const cadeia = {
select() {
return cadeia;
},
eq(col: string, valor: unknown) {
filtros.push({ col, op: "eq", valor });
return cadeia;
},
gte(col: string, valor: unknown) {
filtros.push({ col, op: "gte", valor });
return cadeia;
},
lt(col: string, valor: unknown) {
filtros.push({ col, op: "lt", valor });
return cadeia;
},
or(expr: string) {
alternativas = alternativasDe(expr);
return cadeia;
},
order(col: string) {
ordens.push(col);
return cadeia;
},
range(inicio: number, fim: number) {
de = inicio;
ate = fim;
return cadeia;
},
then(resolver: (v: unknown) => unknown): Promise<unknown> {
const alvo = tabela.filter(
(linha) =>
linhaCasa(linha, filtros) &&
(alternativas.length === 0 || alternativas.some((alt) => linhaCasa(linha, alt))),
);
// ORDER BY com a prioridade da CHAMADA (a PRIMEIRA coluna manda), como
// o PostgREST faz com os `.order()` encadeados — e descendo, que é o
// que a rota pede. Sem ordem nenhuma o `range` paginaria lotes
// arbitrários, que mudam com o plano.
const ordenado = [...alvo].sort((a, b) => {
for (const coluna of ordens) {
const col = coluna as keyof Conversa;
const x = a[col] ?? null;
const y = b[col] ?? null;
if (x === y) continue;
return String(x) < String(y) ? 1 : -1;
}
return 0;
});
const pagina = ordenado.slice(de, Math.min(ate + 1, ordenado.length));
leituras.push({ filtros: [...filtros], alternativas, ordens: [...ordens], de, ate });
return Promise.resolve(resolver({ data: pagina, error: null, count: alvo.length }));
},
};
return cadeia;
},
};
}
async function relatorio(query: string) {
const resposta = await GET(new NextRequest(`http://localhost/api/v1/reports/tags${query}`));
const corpo = await resposta.json();
return { status: resposta.status, corpo, data: corpo.data as Payload };
}
interface Payload {
janela: { de: string; ate: string; tz: string };
linhas: Array<{
etiqueta: string;
conversas: number;
abertas: number;
resolvidas: number;
espera_media_segundos: number | null;
fatia: number;
}>;
total_etiquetagens: number;
sem_dados: boolean;
motivo: string | null;
truncado: boolean;
}
const linhaDe = (d: Payload, etiqueta: string) => d.linhas.find((l) => l.etiqueta === etiqueta);
beforeEach(() => {
vi.clearAllMocks();
leituras = [];
chamadasDeRpc = [];
vi.mocked(requireRole).mockResolvedValue({
ok: true,
org: { orgId: ORG, role: "manager", name: "Org" },
user: { id: USUARIO, idioma: "pt-BR" },
} as Awaited<ReturnType<typeof requireRole>>);
vi.mocked(createClient).mockResolvedValue(clientFalso() as never);
tabela = [];
});
describe("1) etiqueta sem conversa no período devolve 0 — e não some da lista", () => {
beforeEach(() => {
tabela = [
{
id: "c1",
organization_id: ORG,
tags: ["dúvida"],
status: "open",
created_at: "2026-09-10T12:00:00Z",
service_started_at: "2026-09-10T12:00:00Z",
awaiting_since: "2026-09-10T12:00:00Z",
last_outbound_at: null,
},
{
id: "c2",
organization_id: ORG,
tags: ["dúvida", "urgente"],
status: "closed",
created_at: "2026-09-12T09:00:00Z",
service_started_at: "2026-09-12T09:00:00Z",
// Respondeu 30 min depois da mensagem do cliente.
awaiting_since: "2026-09-12T09:00:00Z",
last_outbound_at: "2026-09-12T09:30:00Z",
},
// Fora da janela, mas EM USO na organização: é ela que não pode sumir.
{
id: "c0",
organization_id: ORG,
tags: ["reclamação"],
status: "closed",
created_at: "2026-08-01T10:00:00Z",
service_started_at: "2026-08-01T10:00:00Z",
awaiting_since: "2026-08-01T10:00:00Z",
last_outbound_at: "2026-08-01T10:05:00Z",
},
];
});
it("⭐ a etiqueta em uso aparece com conversas 0, não é omitida", async () => {
const { status, data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
expect(status).toBe(200);
expect(data.sem_dados).toBe(false);
const reclamacao = linhaDe(data, "reclamação");
expect(
reclamacao,
"etiqueta em uso sumiu da lista quando não teve conversa no período — o gestor leria 'nunca reclamam' onde a resposta certa é 'zero neste período'",
).toBeDefined();
expect(reclamacao!.conversas).toBe(0);
expect(reclamacao!.abertas).toBe(0);
expect(reclamacao!.resolvidas).toBe(0);
expect(reclamacao!.fatia).toBe(0);
// Sem conversa não há o que medir: `null` é 'não medido', `0` seria
// 'esperou zero' — a distinção que o /metrics/atrito já defende.
expect(reclamacao!.espera_media_segundos).toBeNull();
});
it("a etiqueta com conversa no período aparece com o volume delas", async () => {
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
expect(linhaDe(data, "dúvida")!.conversas).toBe(2);
expect(linhaDe(data, "urgente")!.conversas).toBe(1);
expect(data.total_etiquetagens).toBe(3);
});
it("⭐ as fatias somam 100 no máximo, mesmo com conversa de duas etiquetas", async () => {
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
const soma = data.linhas.reduce((s, l) => s + l.fatia, 0);
expect(soma, "a barra fechou acima de 100% — arredondamento criou porcentagem").toBeLessThanOrEqual(100);
expect(soma).toBeGreaterThan(0);
// Ordem decrescente: a pergunta é qual assunto ocupou mais.
expect(data.linhas[0]!.etiqueta).toBe("dúvida");
});
it("o desfecho é o estado da conversa, e abertas + resolvidas = volume", async () => {
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
const duvida = linhaDe(data, "dúvida")!;
expect(duvida.resolvidas).toBe(1); // c2 está `closed`
expect(duvida.abertas).toBe(1); // c1 está `open`
for (const l of data.linhas) {
expect(l.abertas + l.resolvidas, `${l.etiqueta}: desfecho não fecha o volume`).toBe(l.conversas);
}
});
it("a espera conta o que o cliente esperou da nossa resposta", async () => {
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
// `dúvida`: c1 ainda sem resposta (conta a espera até AGORA — só não conta
// se não houvesse `awaiting_since`), c2 esperou 30 min. A média é ≥ 30 min.
expect(linhaDe(data, "dúvida")!.espera_media_segundos).toBeGreaterThanOrEqual(1800);
expect(linhaDe(data, "urgente")!.espera_media_segundos).toBe(1800);
});
});
describe("2) período vazio devolve lista vazia", () => {
beforeEach(() => {
tabela = [
{
id: "c0",
organization_id: ORG,
tags: ["dúvida"],
status: "closed",
created_at: "2026-08-01T10:00:00Z",
service_started_at: "2026-08-01T10:00:00Z",
awaiting_since: "2026-08-01T10:00:00Z",
last_outbound_at: "2026-08-01T10:05:00Z",
},
];
});
it("⭐ sem nenhuma conversa com etiqueta no período: lista vazia + sem_dados", async () => {
const { status, data } = await relatorio("?de=2026-06-01&ate=2026-06-30");
expect(status).toBe(200);
expect(data.linhas, "período vazio devolveu linha de zeros com cara de relatório").toEqual([]);
expect(data.sem_dados).toBe(true);
expect(data.motivo).toBe("nenhuma_conversa_com_etiqueta_no_periodo");
expect(data.total_etiquetagens).toBe(0);
});
it("organização sem nenhuma etiqueta em uso também diz que não há dados", async () => {
tabela = [];
const { data } = await relatorio("?de=2026-06-01&ate=2026-06-30");
expect(data.linhas).toEqual([]);
expect(data.sem_dados).toBe(true);
expect(data.motivo).toBe("nenhuma_etiqueta_em_uso");
});
it("a lista de etiquetas pedidas que não existe NENHUMA também é sem dados", async () => {
const { data } = await relatorio("?de=2026-06-01&ate=2026-06-30&tags=que-nao-existe");
expect(data.linhas).toEqual([]);
expect(data.sem_dados).toBe(true);
});
});
describe("3) a rota não vaza conversa de outra organização", () => {
beforeEach(() => {
tabela = [
{
id: "a1",
organization_id: ORG,
tags: ["dúvida"],
status: "open",
created_at: "2026-09-10T12:00:00Z",
service_started_at: "2026-09-10T12:00:00Z",
awaiting_since: "2026-09-10T12:00:00Z",
last_outbound_at: null,
},
{
id: "b1",
organization_id: OUTRA_ORG,
tags: ["sigilosa"],
status: "open",
created_at: "2026-09-11T12:00:00Z",
service_started_at: "2026-09-11T12:00:00Z",
awaiting_since: "2026-09-11T12:00:00Z",
last_outbound_at: null,
},
{
id: "b2",
organization_id: OUTRA_ORG,
tags: ["dúvida"],
status: "open",
created_at: "2026-09-13T12:00:00Z",
service_started_at: "2026-09-13T12:00:00Z",
awaiting_since: "2026-09-13T12:00:00Z",
last_outbound_at: null,
},
];
});
it("⭐ nenhuma etiqueta nem volume da organização vizinha chega à resposta", async () => {
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
expect(
data.linhas.map((l) => l.etiqueta),
"etiqueta de outra organização apareceu no relatório",
).not.toContain("sigilosa");
expect(linhaDe(data, "dúvida")!.conversas, "contou conversa do vizinho").toBe(1);
expect(data.total_etiquetagens).toBe(1);
expect(data.linhas).toHaveLength(1);
});
it("a dimensão também vem recortada: a função é chamada com a org de Quem chamou", async () => {
await relatorio("?de=2026-09-01&ate=2026-09-30");
expect(chamadasDeRpc).toEqual([{ nome: "fn_tags_de_conversa_em_uso", p_org: ORG }]);
const orgFilter = leituras[0]!.filtros.find((f) => f.col === "organization_id");
expect(orgFilter, "a leitura não declarou o inquilino em voz alta").toEqual({
col: "organization_id",
op: "eq",
valor: ORG,
});
});
});
describe("a janela é do fuso de quem lê, e o pedido é validado", () => {
beforeEach(() => {
tabela = [
{
id: "c1",
organization_id: ORG,
tags: ["dúvida"],
status: "open",
created_at: "2026-09-10T12:00:00Z",
service_started_at: "2026-09-10T12:00:00Z",
awaiting_since: "2026-09-10T12:00:00Z",
last_outbound_at: null,
},
];
});
it("⭐ com `tz` a janela anda — é a prova de que o fuso é honrado", async () => {
await relatorio("?de=2026-09-01&ate=2026-09-30&tz=UTC");
const inicioDe = (i: number) =>
leituras[i]!.alternativas[0]!.find((f) => f.col === "service_started_at" && f.op === "gte")!;
const utc = inicioDe(0);
await relatorio("?de=2026-09-01&ate=2026-09-30&tz=America/Sao_Paulo");
const brasil = inicioDe(1);
// São Paulo é UTC−3: o mesmo dia começa 3 horas depois em UTC.
expect(Date.parse(String(brasil.valor)) - Date.parse(String(utc.valor))).toBe(3 * 3600_000);
});
it("a resposta declara a régua junto do número (janela + fuso)", async () => {
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30&tz=America/Sao_Paulo");
expect(data.janela).toEqual({
de: "2026-09-01T03:00:00.000Z",
ate: "2026-10-01T03:00:00.000Z",
tz: "America/Sao_Paulo",
});
});
it.each([
["?de=2026-09-30&ate=2026-09-01", 422, "A data inicial é depois da final."],
["?de=2026-01-01&ate=2026-09-30", 422, "no máximo 90"],
["?de=01/09/2026&ate=2026-09-30", 422, "Query inválida."],
["?tz=Marte/Cratera", 422, "Query inválida."],
// Formato certo, calendário impossível: o regex deixava passar, `diasDeJanela`
// dava NaN, o teto de 90 não disparava e a janela ia até 2034.
["?de=2026-09-01&ate=2026-99-99", 422, "Data final inválida."],
// …e aqui `inicioDoDia` lançava RangeError: 500 em vez de 422.
["?de=2026-13-01&ate=2026-13-05", 422, "Data inicial inválida."],
])("%s é recusado (%i)", async (query, status, trecho) => {
const r = await relatorio(query);
expect(r.status).toBe(status);
expect(JSON.stringify(r.corpo)).toContain(trecho);
});
it("pedido sem período nenhum usa o mês corrente (padrão de /reports/financeiro)", async () => {
const { status, data } = await relatorio("");
const hoje = new Date().toISOString().slice(0, 10);
expect(status).toBe(200);
expect(data.janela.de, "o padrão não é o mês corrente").toBe(`${hoje.slice(0, 7)}-01T00:00:00.000Z`);
// Janela semiaberta: o fim é o começo do dia SEGUINTE, para `ate=hoje`
// incluir as conversas de hoje inteiras.
expect(data.janela.ate.slice(0, 10)).toBe(
new Date(Date.parse(`${hoje}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10),
);
});
});
describe("a leitura não soma página parcial como se fosse o total", () => {
it("⭐ o corte da leitura chega à tela como `truncado`, nunca como número exato", async () => {
// 10.500 conversas na janela: o `max_rows = 1000` corta cada página e a rota
// só páginas PAGINAS_MAXIMAS (10) vezes — 10.000 de 10.500 lidas, e isso
// tem de ir à tela, nunca virar "foram 10.000".
tabela = Array.from({ length: 10_500 }, (_, i) => ({
id: `x${String(i).padStart(5, "0")}`,
organization_id: ORG,
tags: ["dúvida"],
status: "open",
created_at: `2026-09-${String((i % 28) + 1).padStart(2, "0")}T10:00:00Z`,
service_started_at: `2026-09-${String((i % 28) + 1).padStart(2, "0")}T10:00:00Z`,
awaiting_since: "2026-09-01T10:00:00Z",
last_outbound_at: null,
}));
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
expect(data.truncado, "a rota varreu tudo e não declarou corte").toBe(true);
expect(data.linhas[0]!.conversas).toBe(10_000);
expect(leituras.length, "não paginou: leu uma página só e parou").toBe(10);
expect(
leituras.every((l) => l.de >= 0 && l.ate - l.de + 1 <= 1000),
"pediu além do teto que o servidor devolve",
).toBe(true);
});
});
describe("a régua de volume é o ATENDIMENTO, e a espera não cresce depois de encerrada", () => {
it("⭐ o cliente que volta conta no período em que voltou, não no da primeira conversa", async () => {
// Um fio por contato: ele falou em junho, voltou em setembro — o mesmo fio
// reaberto, com `service_started_at` novo e o `created_at` de junho.
tabela = [
{
id: "volta",
organization_id: ORG,
tags: ["reclamação"],
status: "open",
created_at: "2026-06-01T10:00:00Z",
service_started_at: "2026-09-10T10:00:00Z",
awaiting_since: "2026-09-10T10:00:00Z",
last_outbound_at: "2026-09-10T10:10:00Z",
},
];
const setembro = await relatorio("?de=2026-09-01&ate=2026-09-30");
const junho = await relatorio("?de=2026-06-01&ate=2026-06-30");
expect(
linhaDe(setembro.data, "reclamação")!.conversas,
"o atendimento de setembro de um contato antigo sumiu do volume de setembro",
).toBe(1);
// Junho não tem atendimento nenhum: é período vazio, não "1 de junho".
expect(junho.data.total_etiquetagens).toBe(0);
});
it("fio sem atendimento carimbado (grupo) cai em created_at, não some", async () => {
tabela = [
{
id: "grupo",
organization_id: ORG,
tags: ["evento"],
status: "open",
created_at: "2026-09-05T10:00:00Z",
service_started_at: null,
awaiting_since: null,
last_outbound_at: null,
},
];
const { data } = await relatorio("?de=2026-09-01&ate=2026-09-30");
expect(linhaDe(data, "evento")!.conversas).toBe(1);
});
it("⭐ encerrada com o 'obrigado' sem resposta termina a espera no encerramento", async () => {
tabela = [
{
id: "fechada",
organization_id: ORG,
tags: ["dúvida"],
status: "closed",
created_at: "2026-09-12T08:00:00Z",
service_started_at: "2026-09-12T08:00:00Z",
last_outbound_at: "2026-09-12T09:00:00Z",
// O cliente escreveu depois da nossa última resposta, e fecharam 1h depois.
awaiting_since: "2026-09-12T09:05:00Z",
service_closed_at: "2026-09-12T10:05:00Z",
},
{
id: "fechada-sem-carimbo",
organization_id: ORG,
tags: ["dúvida"],
status: "closed",
created_at: "2026-09-13T08:00:00Z",
service_started_at: "2026-09-13T08:00:00Z",
last_outbound_at: null,
awaiting_since: "2026-09-13T08:00:00Z",
service_closed_at: null,
},
];
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date("2026-10-01T12:00:00Z"));
const hoje = await relatorio("?de=2026-09-01&ate=2026-09-30");
vi.setSystemTime(new Date("2026-10-02T12:00:00Z"));
const amanha = await relatorio("?de=2026-09-01&ate=2026-09-30");
// 3600 s, só a encerrada com carimbo: a sem carimbo é "não medido".
expect(linhaDe(hoje.data, "dúvida")!.espera_media_segundos).toBe(3600);
expect(
linhaDe(amanha.data, "dúvida")!.espera_media_segundos,
"a espera de uma conversa já encerrada cresceu com o relógio",
).toBe(3600);
} finally {
vi.useRealTimers();
}
});
});
@@ -62,8 +62,17 @@ describe("a rota lê todo filtro que o schema aceita", () => {
// `limit` e `cursor` também entram: eles são filtros do mesmo objeto e a
// rotura seria igual (uma página de tamanho errado é tão silenciosa quanto
// uma lista sem filtro).
//
// ⚠️ `getAll` conta como ler (#1274). O filtro de etiqueta passou a aceitar
// VÁRIAS etiquetas, e a repetição na URL (`?tag=vip&tag=orçamento`) só é
// visível pelo `getAll` — um `get` ali leria a primeira e descartaria as
// outras, que é a mesma rotura silenciosa que este arquivo existe para pegar.
// A RECUSA continua valendo (o caso de baixo), porque `getAll` é a MESMA
// leitura: some a linha inteira e o teste reprova.
expect(
new RegExp(`${chave}:\\s*url\\.searchParams\\.get\\(\\s*["']${chave}["']`).test(src) ||
new RegExp(
`${chave}:\\s*url\\.searchParams\\.get(All)?\\(\\s*["']${chave}["']`,
).test(src) ||
new RegExp(`${chave}:\\s*url\\.searchParams\\.get\\(["']${chave}["']\\)\\s*===`).test(src),
`${ROTA} não lê "${chave}" da URL — o schema aceita, o browser manda, e a rota descarta em silêncio`,
).toBe(true);
@@ -77,8 +86,19 @@ describe("a rota lê todo filtro que o schema aceita", () => {
"",
);
expect(
new RegExp(`comando:\\s*url\\.searchParams\\.get\\(\\s*["']comando["']`).test(sabotado),
new RegExp(`comando:\\s*url\\.searchParams\\.get(All)?\\(\\s*["']comando["']`).test(sabotado),
"a sabotagem não removeu a linha — o regex do teste não casa com o código real",
).toBe(false);
});
it("o filtro de etiqueta é lido com `getAll` — `get` perderia a segunda etiqueta", () => {
// Caso próprio do `tag`, e não genérico: o `get` COMPILARIA e o typecheck
// passaria, porque o schema aceita as duas formas. O defeito só apareceria em
// produção, com `?tag=vip&tag=orçamento` listando por `vip` sozinho.
const src = fonteDaRota();
expect(
new RegExp(`tag:\\s*url\\.searchParams\\.getAll\\(\\s*["']tag["']`).test(src),
`${ROTA} lê "tag" com get — com ?tag=vip&tag=orçamento a segunda etiqueta é descartada em silêncio`,
).toBe(true);
});
});
+11
View File
@@ -46,4 +46,15 @@ describe("isMediaPathOwnedBy", () => {
it("confusão de prefixo (org-1x/...) → false", () => {
expect(isMediaPathOwnedBy(`${orgId}x/${conversationId}/foo.jpg`, orgId, conversationId)).toBe(false);
});
it("recusa caminho que sai da pasta da conversa por `..`, `.`, segmento vazio ou barra invertida", () => {
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}/../conv-2/foo.jpg`, orgId, conversationId)).toBe(false);
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}/../../org-2/conv-9/foo.jpg`, orgId, conversationId)).toBe(false);
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}/./foo.jpg`, orgId, conversationId)).toBe(false);
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}//foo.jpg`, orgId, conversationId)).toBe(false);
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}/..\\org-2\\foo.jpg`, orgId, conversationId)).toBe(false);
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}/`, orgId, conversationId)).toBe(false);
// Controle: subpasta e nome com ponto continuam valendo.
expect(isMediaPathOwnedBy(`${orgId}/${conversationId}/2026/foto..final.jpg`, orgId, conversationId)).toBe(true);
});
});