mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
Merge remote-tracking branch 'origin/main' into fix-1892
# Conflicts: # app/api/v1/contacts/_handler.ts
This commit is contained in:
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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. |
|
||||
|
||||
@@ -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") });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? [])]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user