mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(escalacao): o cartão "por que a IA passou para você", dentro da conversa
O contexto da passagem existia desde a onda 10, mas vivia num aviso da Central — uma lista da organização inteira, que atualiza a cada 60s e leva para a ficha do contato. Quem assume trabalha na CONVERSA, e é lá que o cartão passa a aparecer: motivo em português, resumo, o que a IA tentou (lista numerada), o que o cliente quer, e se o cliente foi avisado — com o porquê quando não foi. Sete estados, e a decisão de qual mostrar mora numa função PURA fora do componente: o gate de vocabulário proíbe código de motivo dentro de `components/`, e com razão — é o mesmo texto que a Central e o export usam. A rota lê com o client de SESSÃO. É isso que faz a regra de visibilidade valer para o TEXTO da passagem, que carrega o que o cliente disse. Cobrador: passagem sem ninguém reconhecer há mais de 24h reabre o aviso, e para na terceira cobrança — sem o contador, o vigia viraria spam diário sobre a mesma linha. A auditoria só registra quando houve cobrança de verdade. Laço de retorno (invariante 7): "o cliente repetiu tudo depois da passagem?" vira número na tela de métricas, com a régua declarada (limiar de semelhança 0,7, janela de 24h, denominador = passagens em que o cliente voltou a falar). Ausência de dado é `null`, nunca `0` — zero é uma afirmação. Consertado de passagem: o merge anterior deixou marcadores de conflito commitados no mapa vivo, e o JSON não parseava. Resolvido pela união dos dois lados, com as arestas colidentes renumeradas. Verificado: suíte completa 1.003 arquivos / 10.252 casos / ZERO falhas; typecheck, lint, lint:channels, lint:role-rank, colisão de migration, test:shell e release:conferir verdes; `test:db` do invariante novo com install + update + 7/7; 9 sabotagens batendo a previsão exata, restauração conferida por md5. PENDENTE: `test:db` completo e em pg17, `pnpm build`, e a jornada em tela — as duas linhas J27.5/J27.6 seguem declaradas como NÃO COBERTAS, porque são da onda 12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqKEskriDoLeY45kmFtCgE
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
---
|
||||
impacto: capacidade_nova
|
||||
secao: adicionado
|
||||
titulo: Quem abre a conversa vê, ali mesmo, por que a IA passou o atendimento
|
||||
---
|
||||
|
||||
O sistema já guardava o contexto de cada passagem do atendimento automático para uma pessoa — o
|
||||
porquê, o que a IA já tinha tentado, o que o cliente pediu com as palavras dele e se ele chegou a
|
||||
ser avisado. Só que ninguém via isso em lugar nenhum.
|
||||
|
||||
Agora vê. Dentro da conversa, logo acima do campo de digitar, aparece um cartão:
|
||||
|
||||
- **Por que a IA passou**, em português, e não um código técnico.
|
||||
- **O que o cliente quer** e **as últimas palavras dele**, entre aspas, separadas do que a IA
|
||||
concluiu — quem vai responder precisa saber o que foi DITO e o que foi INTERPRETADO.
|
||||
- **O que a IA já tentou**, em lista numerada, para ninguém repetir a mesma oferta.
|
||||
- **Se o cliente já foi avisado** de que uma pessoa vai assumir. E, quando não foi, por quê — é isso
|
||||
que muda a primeira frase que você digita.
|
||||
- Um botão **"Assumir e responder"**, que é o mesmo gesto do topo da conversa.
|
||||
|
||||
Três cuidados que valem ser ditos:
|
||||
|
||||
- **Quando o cliente parece ter pedido para parar de receber mensagens**, o cartão muda: ele não
|
||||
convida a responder, e sim a abrir a ficha do contato para confirmar o bloqueio. Um botão que diz
|
||||
"responder" ali empurraria alguém a escrever justamente para quem pediu silêncio.
|
||||
- **Se a conversa já tem dono**, o cartão diz quem está atendendo em vez de oferecer um botão que
|
||||
não funcionaria.
|
||||
- **Se o cliente pediu para ser esquecido**, o cartão mostra só o aviso de anonimização. Nada do que
|
||||
ele disse sobrevive ali.
|
||||
|
||||
**A conversa esquecida volta a pedir.** Antes, se a IA passasse um atendimento e ninguém aparecesse,
|
||||
não havia nada no sistema que cobrasse — o cliente esperava indefinidamente. Agora, passado um dia
|
||||
sem ninguém assumir, o aviso volta para a Central apontando para a conversa. Ele insiste no máximo
|
||||
três vezes: alarme que nunca cala ensina a ignorar o alarme certo.
|
||||
|
||||
**E dá para saber se isso está funcionando.** Em *Métricas*, junto de "Passagens para humano",
|
||||
nasceu **"Clientes que repetiram depois da passagem"**: de cada dez passagens em que o cliente voltou
|
||||
a falar, quantas ele teve de repetir o que já tinha dito. Se o contexto está chegando a quem assume,
|
||||
esse número cai. Se não está, ele não muda — e aí a novidade acima é só enfeite. Quem atende vê o
|
||||
número das conversas dele; quem gerencia vê o da organização inteira.
|
||||
|
||||
Você não precisa fazer nada: a atualização já traz tudo pronto.
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* GET /api/v1/conversations/[id]/passagens — o que a IA deixou para quem assume.
|
||||
*
|
||||
* ═══ Por que o client é o da SESSÃO, e nunca o admin ═══
|
||||
*
|
||||
* A linha de `passagens_de_atendimento` carrega o que a IA concluiu sobre uma
|
||||
* pessoa: o que ela quer, o que já foi tentado e as palavras dela. A policy da
|
||||
* tabela (migration 0291) exige TRÊS condições para ler — organização, papel
|
||||
* `agent`+ e `fn_can_view_conversation`. O service role bypassa RLS, então uma
|
||||
* leitura com admin entregaria o briefing de um atendimento que a política de
|
||||
* visibilidade da organização não deixa a pessoa nem abrir.
|
||||
*
|
||||
* É o defeito que a Central tem — ela lê os avisos com admin e entrega `body` a
|
||||
* qualquer `agent` —, e é por isso que o corpo do aviso ficou CURTO nesta
|
||||
* entrega. Ler aqui com admin desfaria aquilo pelo outro lado.
|
||||
*
|
||||
* O admin entra em UM lugar e só nele: o nome de quem assumiu
|
||||
* (`lib/users/nome-do-atendente.ts`), que fala com o endpoint admin do GoTrue —
|
||||
* o token do usuário recebe 403 `not_admin` lá, e o supabase-js **não lança**
|
||||
* nesse caso, então todo nome viria `null` em silêncio.
|
||||
*
|
||||
* ═══ Sem realtime, de propósito ═══
|
||||
*
|
||||
* `passagens_de_atendimento` não entra na publicação `supabase_realtime`. Abrir
|
||||
* um canal `postgres_changes` numa tabela fora da publicação é o defeito vivo de
|
||||
* `hooks/inbox/useConversationNotes.ts`: o canal assina, responde `SUBSCRIBED` e
|
||||
* não recebe nada — falha muda. A invalidação pega carona no realtime de
|
||||
* `conversations`, que JÁ está na publicação e JÁ muda quando a passagem
|
||||
* acontece (`bot_silenced_until`, `assigned_to_user_id`, `last_handoff_reason`).
|
||||
*
|
||||
* ═══ Read-only ⇒ sem audit ═══
|
||||
*
|
||||
* Mesma regra das rotas irmãs de leitura: `api_audit_log` registra MUTAÇÃO.
|
||||
*
|
||||
* As mensagens de erro passam por `traduzir()` por disciplina — `PASTAS_IGNORADAS`
|
||||
* do gate de i18n inclui `api`, então ninguém as cobra; elas são frase de
|
||||
* produto do mesmo jeito.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { type NextRequest } from "next/server";
|
||||
|
||||
import { fail, ok } from "@/lib/api/wrappers";
|
||||
import { requireRole } from "@/lib/auth/require-role";
|
||||
import { traduzir } from "@/lib/i18n/dicionario";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { nomesDosAtendentes } from "@/lib/users/nome-do-atendente";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Teto de linhas. Uma conversa com dezenas de passagens é patológica (cada uma
|
||||
* é um episódio inteiro de ida e volta), mas "patológica" não é "impossível": um
|
||||
* laço de automação já produziu volume assim neste produto. Sem teto, o cartão
|
||||
* mais caro da tela carregaria a tabela inteira daquela conversa.
|
||||
*
|
||||
* 20 e não 5: o fio mostra as antigas recolhidas, e cortar cedo demais faria a
|
||||
* primeira passagem de um atendimento longo sumir sem ninguém saber que sumiu.
|
||||
*/
|
||||
const TETO_DE_PASSAGENS = 20;
|
||||
|
||||
/** As colunas que a tela lê. Um literal só — o supabase-js infere a linha do TIPO da string. */
|
||||
const COLUNAS =
|
||||
"id, origem, motivo_codigo, title, body, notes, content, tentativas, cliente_avisado, aviso_motivo_codigo, caso_id, criado_em, reconhecido_em, reconhecido_por";
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: RouteParams): Promise<Response> {
|
||||
const requestId = randomUUID();
|
||||
const authz = await requireRole("agent", { requestId, resource: "passagens_de_atendimento" });
|
||||
if (!authz.ok) return authz.response;
|
||||
const t = (texto: string) => traduzir(texto, authz.user.idioma);
|
||||
const { org } = authz;
|
||||
const { id } = await params;
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
// A existência é confirmada ANTES: sem isto, uma conversa de outra organização
|
||||
// devolveria `[]` com 200, que se lê como "esta conversa não teve passagem
|
||||
// nenhuma" — e afirma a existência dela de graça.
|
||||
const { data: conversa } = await supabase
|
||||
.from("conversations")
|
||||
.select("id")
|
||||
.eq("id", id)
|
||||
.eq("organization_id", org.orgId)
|
||||
.maybeSingle();
|
||||
if (!conversa) return fail("not_found", t("Conversa não encontrada."), 404, { requestId });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("passagens_de_atendimento")
|
||||
.select(COLUNAS)
|
||||
.eq("conversation_id", id)
|
||||
.eq("organization_id", org.orgId)
|
||||
.order("criado_em", { ascending: true })
|
||||
.limit(TETO_DE_PASSAGENS);
|
||||
|
||||
if (error) {
|
||||
logger.error("[passagens] leitura falhou", {
|
||||
conversation_id: id,
|
||||
organization_id: org.orgId,
|
||||
error: error.message,
|
||||
requestId,
|
||||
});
|
||||
return fail("internal_error", t("Não foi possível carregar o contexto da passagem."), 500, {
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
const linhas = data ?? [];
|
||||
const nomes = await nomesDosAtendentes(linhas.map((l) => l.reconhecido_por));
|
||||
|
||||
return ok(
|
||||
// `reconhecido_por` continua na linha mesmo sem nome: o dono é a VERDADE, o
|
||||
// nome é a cortesia. Cair para `null` aqui faria o cartão ler uma passagem
|
||||
// assumida como "devolvida ao automático".
|
||||
linhas.map((l) => ({ ...l, reconhecido_por_nome: nomes.get(l.reconhecido_por ?? "") ?? null })),
|
||||
{ requestId },
|
||||
);
|
||||
}
|
||||
@@ -53,11 +53,32 @@ function admin(casos: Array<Record<string, unknown>>, jaTemAviso: boolean, cap:
|
||||
},
|
||||
};
|
||||
}
|
||||
if (tabela === "passagens_de_atendimento") {
|
||||
// O SEGUNDO BRAÇO desta rota (onda 11) varre as passagens que ninguém
|
||||
// assumiu. Este dublê o deixa varrer NADA de propósito: o que os casos
|
||||
// abaixo medem é o braço dos CASOS, e uma fixture de passagens aqui
|
||||
// misturaria as duas contagens na mesma asserção. Quem mede o segundo
|
||||
// braço é `tests/unit/cobrador-de-passagem-nao-reconhecida.test.ts`,
|
||||
// que tem um caso próprio para "o braço dos CASOS continua de pé".
|
||||
const c: Record<string, unknown> = {
|
||||
select: () => c,
|
||||
is: () => c,
|
||||
lt: () => c,
|
||||
order: () => c,
|
||||
limit: async () => ({ data: [], error: null }),
|
||||
update: () => c,
|
||||
eq: () => c,
|
||||
then: (r: (v: unknown) => unknown) => Promise.resolve({ data: [], error: null }).then(r),
|
||||
};
|
||||
return c;
|
||||
}
|
||||
// agent_inbox_items
|
||||
return {
|
||||
select: () => {
|
||||
const c: Record<string, unknown> = {
|
||||
eq: () => c,
|
||||
order: () => c,
|
||||
limit: () => c,
|
||||
maybeSingle: async () => ({ data: jaTemAviso ? { id: "aviso-1" } : null }),
|
||||
};
|
||||
return c;
|
||||
@@ -66,6 +87,14 @@ function admin(casos: Array<Record<string, unknown>>, jaTemAviso: boolean, cap:
|
||||
cap.avisos.push(linha);
|
||||
return { error: null };
|
||||
},
|
||||
update: (patch: Record<string, unknown>) => {
|
||||
cap.updates.push(patch);
|
||||
const c: Record<string, unknown> = {
|
||||
eq: () => c,
|
||||
then: (r: (v: unknown) => unknown) => Promise.resolve({ error: null }).then(r),
|
||||
};
|
||||
return c;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ import type { NextRequest } from "next/server";
|
||||
import { ok, fail } from "@/lib/api/wrappers";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { env } from "@/lib/env";
|
||||
import { traduzir } from "@/lib/i18n/dicionario";
|
||||
import { normalizarIdioma, type Idioma } from "@/lib/i18n/idiomas";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
@@ -79,6 +81,26 @@ function comoFaz(horas: number): string {
|
||||
return `há ${Math.max(1, Math.round(horas))} horas`;
|
||||
}
|
||||
|
||||
/**
|
||||
* O MESMO "há N dias", mas montado a partir de PEDAÇOS TRADUZÍVEIS.
|
||||
*
|
||||
* `comoFaz` devolve a frase inteira já interpolada — `t("há 2 dias")` não casa
|
||||
* chave nenhuma no dicionário e devolveria o português para quem escolheu
|
||||
* espanhol, em silêncio, que é o modo de falha de i18n que esta casa já pagou.
|
||||
* Aqui o número fica fora da tradução e só as palavras passam por `t()`.
|
||||
*
|
||||
* ⚠️ O braço dos CASOS continua usando `comoFaz` e continua saindo em português
|
||||
* para toda organização. É dívida ANTERIOR a esta onda e está declarada, não
|
||||
* consertada de carona: mudar o título daquele aviso mexeria num texto que
|
||||
* `central-avisos-*` já observa, e o lugar de decidir isso é o PR daquele braço.
|
||||
*/
|
||||
function esperaEmPalavras(horas: number, t: (texto: string) => string): string {
|
||||
const dias = Math.floor(horas / 24);
|
||||
if (dias >= 1) return `${t("há")} ${dias} ${dias === 1 ? t("dia") : t("dias")}`;
|
||||
const h = Math.max(1, Math.round(horas));
|
||||
return `${t("há")} ${h} ${h === 1 ? t("hora") : t("horas")}`;
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest): Promise<Response> {
|
||||
const requestId = randomUUID();
|
||||
|
||||
@@ -195,11 +217,202 @@ async function handle(req: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
const passagens = await cobrarPassagensEsquecidas(admin, corte, requestId);
|
||||
|
||||
if (passagens.cobradas > 0) {
|
||||
await audit({
|
||||
action: "ai.passagem_parada_cobrada",
|
||||
resourceType: "conversation",
|
||||
requestId,
|
||||
metadata: { cobradas: passagens.cobradas, examinadas: passagens.examinadas },
|
||||
});
|
||||
}
|
||||
|
||||
return ok(
|
||||
{ examinados: casos.length, avisados, ja_avisados: jaAvisados },
|
||||
{
|
||||
examinados: casos.length,
|
||||
avisados,
|
||||
ja_avisados: jaAvisados,
|
||||
passagens_examinadas: passagens.examinadas,
|
||||
passagens_cobradas: passagens.cobradas,
|
||||
},
|
||||
{ requestId },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────────────────
|
||||
* O SEGUNDO BRAÇO — a passagem que ninguém assumiu
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* O idioma da ORGANIZAÇÃO, com cache por rodada.
|
||||
*
|
||||
* Ninguém está logado quando um cron escreve, e o corpo do aviso é DADO na
|
||||
* Central — ela o mostra cru, de propósito, e há um teste que guarda isso. Então
|
||||
* a tradução acontece aqui, no instante do insert, e não na tela. Nunca lança:
|
||||
* aviso em português é infinitamente melhor que aviso nenhum.
|
||||
*/
|
||||
async function idiomaDaOrganizacao(
|
||||
admin: ReturnType<typeof createAdminClient>,
|
||||
organizationId: string,
|
||||
cache: Map<string, Idioma>,
|
||||
): Promise<Idioma> {
|
||||
const guardado = cache.get(organizationId);
|
||||
if (guardado !== undefined) return guardado;
|
||||
let idioma: Idioma = "pt-BR";
|
||||
try {
|
||||
const { data } = await admin
|
||||
.from("organizations")
|
||||
.select("locale")
|
||||
.eq("id", organizationId)
|
||||
.maybeSingle();
|
||||
idioma = normalizarIdioma((data as { locale?: string | null } | null)?.locale ?? null);
|
||||
} catch {
|
||||
idioma = "pt-BR";
|
||||
}
|
||||
cache.set(organizationId, idioma);
|
||||
return idioma;
|
||||
}
|
||||
|
||||
/**
|
||||
* VARRE AS PASSAGENS QUE NINGUÉM RECONHECEU E TRAZ O AVISO DE VOLTA.
|
||||
*
|
||||
* ═══ Por que este braço precisou existir ═══
|
||||
*
|
||||
* O reconhecimento da passagem (migration 0293) só acontece por GESTO de quem
|
||||
* chegou: alguém assume a conversa, ou a devolve ao automático. Ninguém cobra a
|
||||
* passagem em que ninguém chegou. E o braço de cima não cobre isso nem por
|
||||
* acidente: dos TREZE caminhos que passam conversa para uma pessoa, um único
|
||||
* nasce de caso — os outros doze (pedido explícito, ferramenta do modelo, teto
|
||||
* de gasto, sentimento, MCP, os cinco legados…) não têm `agent_case` nenhum.
|
||||
*
|
||||
* ⚠️ E a população não é hipótese: os 22 pedidos parados citados no topo deste
|
||||
* arquivo eram, em ONZE casos, gente pedindo para falar com uma pessoa.
|
||||
*
|
||||
* ═══ As três decisões, e o que cada uma evita ═══
|
||||
*
|
||||
* · **Reusa o aviso `handoff` daquela conversa** (reabre o resolvido, atualiza
|
||||
* o aberto) em vez de inserir um segundo. Dois avisos sobre o mesmo
|
||||
* atendimento fazem a pessoa resolver um e continuar vendo o outro.
|
||||
* · **Continua `warn`, nunca `critical`.** Há um cliente esperando, mas nada
|
||||
* quebrou. O vermelho é para o que está fora do ar — usá-lo aqui o
|
||||
* desvaloriza, que é o argumento que o braço de cima já faz.
|
||||
* · **Para no terceiro.** `passagens_de_atendimento.cobrancas` (migration
|
||||
* 0294) segura o teto, pelo mesmo motivo de `agent_cases.followup_attempts`:
|
||||
* quem ignorou três vezes não atende no quarto, e alarme que nunca cala
|
||||
* treina a equipe a ignorar o alarme certo.
|
||||
*
|
||||
* Sem tabela nova, sem `kind` novo, sem evento novo.
|
||||
*/
|
||||
async function cobrarPassagensEsquecidas(
|
||||
admin: ReturnType<typeof createAdminClient>,
|
||||
corte: string,
|
||||
requestId: string,
|
||||
): Promise<{ examinadas: number; cobradas: number }> {
|
||||
const { data, error } = await admin
|
||||
.from("passagens_de_atendimento")
|
||||
.select("id, organization_id, conversation_id, criado_em, cobrancas")
|
||||
.is("reconhecido_em", null)
|
||||
.lt("criado_em", corte)
|
||||
.lt("cobrancas", TETO_DE_COBRANCAS)
|
||||
.order("criado_em", { ascending: true })
|
||||
.limit(LIMITE_DA_VARREDURA);
|
||||
|
||||
if (error) {
|
||||
logger.error("[case-stale-watcher] varredura de passagens falhou", {
|
||||
error: error.message,
|
||||
requestId,
|
||||
});
|
||||
return { examinadas: 0, cobradas: 0 };
|
||||
}
|
||||
|
||||
const passagens = data ?? [];
|
||||
const idiomas = new Map<string, Idioma>();
|
||||
const agora = Date.now();
|
||||
let cobradas = 0;
|
||||
|
||||
for (const p of passagens) {
|
||||
const orgId = p.organization_id as string;
|
||||
const conversaId = p.conversation_id as string;
|
||||
const tentativa = (p.cobrancas as number) + 1;
|
||||
const horas = (agora - Date.parse(p.criado_em as string)) / 3_600_000;
|
||||
const idioma = await idiomaDaOrganizacao(admin, orgId, idiomas);
|
||||
const t = (texto: string) => traduzir(texto, idioma);
|
||||
|
||||
const titulo = `${t("Alguém pediu atendimento e ninguém assumiu")} — ${esperaEmPalavras(horas, t)}`;
|
||||
const corpo =
|
||||
t(
|
||||
"A IA passou esta conversa para uma pessoa e ninguém assumiu desde então. " +
|
||||
"Abra a conversa: o contexto do que já foi dito está lá.",
|
||||
) +
|
||||
(tentativa >= TETO_DE_COBRANCAS
|
||||
? ` ${t("Este é o último aviso automático sobre esta conversa.")}`
|
||||
: "");
|
||||
|
||||
// O aviso mais recente daquela conversa, em QUALQUER estado: um resolvido
|
||||
// sem ninguém ter assumido é o caso que mais importa — ele sumiu da tela sem
|
||||
// o problema sumir junto.
|
||||
const { data: existente } = await admin
|
||||
.from("agent_inbox_items")
|
||||
.select("id, status")
|
||||
.eq("organization_id", orgId)
|
||||
.eq("kind", "handoff")
|
||||
.eq("ref_kind", "conversation")
|
||||
.eq("ref_id", conversaId)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
const erroDoAviso = existente
|
||||
? (
|
||||
await admin
|
||||
.from("agent_inbox_items")
|
||||
.update({ status: "open", resolved_at: null, severity: "warn", title: titulo, body: corpo })
|
||||
.eq("id", (existente as { id: string }).id)
|
||||
.eq("organization_id", orgId)
|
||||
).error
|
||||
: (
|
||||
await admin.from("agent_inbox_items").insert({
|
||||
organization_id: orgId,
|
||||
kind: "handoff",
|
||||
severity: "warn",
|
||||
title: titulo,
|
||||
body: corpo,
|
||||
ref_kind: "conversation",
|
||||
ref_id: conversaId,
|
||||
})
|
||||
).error;
|
||||
|
||||
if (erroDoAviso) {
|
||||
logger.error("[case-stale-watcher] cobrança da passagem não foi aberta", {
|
||||
passagem_id: p.id,
|
||||
organization_id: orgId,
|
||||
error: erroDoAviso.message,
|
||||
requestId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// O contador sobe DEPOIS do aviso: subir antes faria uma falha de insert
|
||||
// gastar uma das três tentativas sem ninguém ter sido avisado de nada.
|
||||
const { error: erroContador } = await admin
|
||||
.from("passagens_de_atendimento")
|
||||
.update({ cobrancas: tentativa })
|
||||
.eq("id", p.id as string)
|
||||
.eq("organization_id", orgId);
|
||||
|
||||
if (erroContador) {
|
||||
logger.error("[case-stale-watcher] contador da passagem não subiu", {
|
||||
passagem_id: p.id,
|
||||
error: erroContador.message,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
cobradas += 1;
|
||||
}
|
||||
|
||||
return { examinadas: passagens.length, cobradas };
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
|
||||
@@ -70,6 +70,11 @@ const VAZIO: Omit<AtritoRaw, "escopo"> = {
|
||||
esperas_caladas: 0,
|
||||
esperas_medidas: 0,
|
||||
espera_resposta_p90_s: null,
|
||||
// Zero nos DOIS: a razão é calculada em `montarPares`, e denominador zero
|
||||
// devolve `null` lá. Pôr `null` aqui seria a segunda representação da mesma
|
||||
// ausência, e a primeira a divergir.
|
||||
repeticao_pos_passagem: 0,
|
||||
passagens_medidas: 0,
|
||||
},
|
||||
empresa: {
|
||||
intervencoes_por_demanda: null,
|
||||
|
||||
@@ -10,33 +10,63 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { PassagemCard } from "./PassagemCard";
|
||||
import { useMessagesRealtime } from "@/hooks/inbox/useMessagesRealtime";
|
||||
import { useConversationNotes } from "@/hooks/inbox/useConversationNotes";
|
||||
import { usePassagensDaConversa } from "@/hooks/inbox/usePassagensDaConversa";
|
||||
import { useClaimConversation } from "@/hooks/inbox/useClaimConversation";
|
||||
import { useDeleteNote } from "@/hooks/inbox/useDeleteNote";
|
||||
import { useDebugToggle } from "@/hooks/ai/useDebugToggle";
|
||||
import { useActiveOrg, useUser } from "@/hooks/auth/AuthProvider";
|
||||
import { ROLE_RANK } from "@/lib/auth/types";
|
||||
import { montarCartoesDaPassagem, type CartaoDaPassagem } from "@/lib/escalacao/cartao-da-passagem";
|
||||
import type { Message, Note } from "@/lib/types/messaging";
|
||||
|
||||
interface Props {
|
||||
conversationId: string | null;
|
||||
/** Escolher uma mensagem para responder. Sobe até o composer. */
|
||||
onResponder?: (m: Message) => void;
|
||||
/**
|
||||
* Quem é o dono da conversa HOJE, e quem é o contato.
|
||||
*
|
||||
* O cartão da passagem precisa dos dois para escolher o gesto: sem dono ele
|
||||
* convida a assumir; com outro dono ele diz quem atende (oferecer "assumir"
|
||||
* ali seria oferecer um gesto que a rota recusa); e o caminho de opt-out leva
|
||||
* à ficha do contato, que é onde mora o bloqueio.
|
||||
*
|
||||
* Opcional para o fio continuar renderizável sem a conversa em mãos — o
|
||||
* cartão então cai no estado mais conservador (nenhum convite).
|
||||
*/
|
||||
dono?: { userId: string | null; nome: string | null } | null;
|
||||
contatoId?: string | null;
|
||||
}
|
||||
|
||||
/** Onda 5.2: union de item do thread — mensagem real ou nota interna (nunca vai ao cliente). */
|
||||
/**
|
||||
* Onda 5.2: union de item do thread — mensagem real ou nota interna (nunca vai
|
||||
* ao cliente). A passagem é o TERCEIRO tipo: também não é mensagem, também não
|
||||
* vai ao cliente, e entra no fio pelo mesmo mecanismo.
|
||||
*/
|
||||
export type ThreadItem =
|
||||
| { kind: "message"; ts: string; data: Message }
|
||||
| { kind: "note"; ts: string; data: Note };
|
||||
| { kind: "note"; ts: string; data: Note }
|
||||
| { kind: "passagem"; ts: string; data: CartaoDaPassagem };
|
||||
|
||||
/** Intercala mensagens e notas por timestamp asc (puro, sem I/O — testado em thread-merge.test.ts). */
|
||||
export function mergeThreadItems(messages: Message[], notes: Note[]): ThreadItem[] {
|
||||
/** Intercala mensagens, notas e passagens por timestamp asc (puro, sem I/O — testado em thread-merge.test.ts). */
|
||||
export function mergeThreadItems(
|
||||
messages: Message[],
|
||||
notes: Note[],
|
||||
// Opcional porque o fio existe desde antes da passagem, e uma conversa que
|
||||
// nunca saiu do automático não tem nenhuma.
|
||||
passagens: CartaoDaPassagem[] = [],
|
||||
): ThreadItem[] {
|
||||
const items: ThreadItem[] = [
|
||||
...messages.map((data): ThreadItem => ({ kind: "message", ts: data.sent_at, data })),
|
||||
...notes.map((data): ThreadItem => ({ kind: "note", ts: data.created_at, data })),
|
||||
...passagens.map((data): ThreadItem => ({ kind: "passagem", ts: data.criadoEm, data })),
|
||||
];
|
||||
// Sort estável (Array#sort é estável no V8/Node): empate mantém a ordem de
|
||||
// inserção acima — mensagens antes de notas no mesmo instante.
|
||||
// inserção acima — mensagens antes de notas no mesmo instante, e a passagem
|
||||
// DEPOIS das duas, que é o que aconteceu: ela é consequência da última fala.
|
||||
items.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
|
||||
return items;
|
||||
}
|
||||
@@ -47,11 +77,13 @@ function dayLabel(d: Date, t: (texto: string) => string = (texto) => texto, loca
|
||||
return format(d, "dd/MM/yyyy", { locale: locale });
|
||||
}
|
||||
|
||||
export function ChatThread({ conversationId, onResponder }: Props) {
|
||||
export function ChatThread({ conversationId, onResponder, dono, contatoId }: Props) {
|
||||
const localeDaData = useLocaleDeData();
|
||||
const t = useT();
|
||||
const q = useMessagesRealtime(conversationId);
|
||||
const notes = useConversationNotes(conversationId);
|
||||
const passagens = usePassagensDaConversa(conversationId);
|
||||
const claim = useClaimConversation();
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const paginasVistas = useRef(0);
|
||||
@@ -76,9 +108,19 @@ export function ChatThread({ conversationId, onResponder }: Props) {
|
||||
*/
|
||||
const porId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
||||
|
||||
const cartoes: CartaoDaPassagem[] = useMemo(
|
||||
() =>
|
||||
montarCartoesDaPassagem(passagens, {
|
||||
usuarioId: currentUser.id,
|
||||
donoId: dono?.userId ?? null,
|
||||
donoNome: dono?.nome ?? null,
|
||||
}),
|
||||
[passagens, currentUser.id, dono?.userId, dono?.nome],
|
||||
);
|
||||
|
||||
const items: ThreadItem[] = useMemo(
|
||||
() => mergeThreadItems(messages, notes),
|
||||
[messages, notes],
|
||||
() => mergeThreadItems(messages, notes, cartoes),
|
||||
[messages, notes, cartoes],
|
||||
);
|
||||
|
||||
const paginas = q.data?.pages.length ?? 0;
|
||||
@@ -224,7 +266,24 @@ export function ChatThread({ conversationId, onResponder }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
{g.items.map((item) =>
|
||||
item.kind === "note" ? (
|
||||
item.kind === "passagem" ? (
|
||||
<PassagemCard
|
||||
key={`passagem-${item.data.id}`}
|
||||
cartao={item.data}
|
||||
contatoId={contatoId ?? null}
|
||||
assumindo={claim.isPending}
|
||||
// O MESMO gesto do cabeçalho — uma rota, um efeito. Uma
|
||||
// segunda maneira de assumir seria uma segunda chance de os
|
||||
// dois caminhos divergirem sobre o que "assumir" faz.
|
||||
onAssumir={() =>
|
||||
conversationId &&
|
||||
claim.mutate({
|
||||
conversation_id: conversationId,
|
||||
expected_assignee: dono?.userId ?? null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : item.kind === "note" ? (
|
||||
<NoteCard
|
||||
key={`note-${item.data.id}`}
|
||||
note={item.data}
|
||||
|
||||
@@ -486,7 +486,19 @@ export function InboxLayout({ initialSelectedId = null }: InboxLayoutProps = {})
|
||||
<>
|
||||
<ConversationHeader conversation={selectedConversation} />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ChatThread conversationId={selectedConversation.id} onResponder={setRespondendo} />
|
||||
<ChatThread
|
||||
conversationId={selectedConversation.id}
|
||||
onResponder={setRespondendo}
|
||||
// O cartão da passagem escolhe o gesto a partir de quem é o dono
|
||||
// da conversa: sem dono convida a assumir, com outro dono diz
|
||||
// quem atende. Sem estes dois campos ele cairia no estado mais
|
||||
// conservador e ficaria mudo justamente para quem mais precisa.
|
||||
dono={{
|
||||
userId: selectedConversation.assigned_to_user_id ?? null,
|
||||
nome: selectedConversation.assigned_to_user_name ?? null,
|
||||
}}
|
||||
contatoId={selectedConversation.contacts?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
<RetentionNotice conversationId={selectedConversation.id} />
|
||||
{motivoDaJanela && (
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useId } from "react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocaleDeData } from "@/hooks/i18n/useLocaleDeData";
|
||||
import { useT } from "@/hooks/i18n/useT";
|
||||
import type { CartaoDaPassagem } from "@/lib/escalacao/cartao-da-passagem";
|
||||
import { Robot, Warning } from "@/lib/ui/icons";
|
||||
|
||||
interface Props {
|
||||
cartao: CartaoDaPassagem;
|
||||
/** Para o gesto de opt-out: a ficha do contato é onde mora o bloqueio. */
|
||||
contatoId: string | null;
|
||||
/** Assumir a conversa. É o MESMO gesto do cabeçalho — uma rota, um efeito. */
|
||||
onAssumir: () => void;
|
||||
assumindo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* O CARTÃO "POR QUE A IA PASSOU PARA VOCÊ", dentro do fio da conversa.
|
||||
*
|
||||
* ═══ Por que ele mora aqui, e não no cabeçalho nem no painel lateral ═══
|
||||
*
|
||||
* O cabeçalho já travou a largura da tela inteira uma vez (707px de
|
||||
* `min-content`, empurrando o painel de CRM 311px para fora da viewport em
|
||||
* 1280px) — o comentário no topo de `ConversationHeader.tsx` conta a história.
|
||||
* Ele é uma barra de selos de 10px; um cartão de seis linhas ali reintroduz o
|
||||
* defeito que o `flex-wrap` acabou de consertar.
|
||||
*
|
||||
* O painel lateral é coluna de CONSULTA: a pessoa olha para lá depois, e ele
|
||||
* some em largura apertada.
|
||||
*
|
||||
* O fio é o eixo de leitura que termina no composer. Ele já intercala mensagens
|
||||
* e notas por timestamp, já tem `NoteCard` como cartão não-mensagem, e o
|
||||
* auto-scroll traz o fim para a viewport. Como a passagem CALA a IA, ela é quase
|
||||
* sempre o último evento quando a pessoa chega — o cartão aparece exatamente
|
||||
* onde o olho está antes de o dedo digitar.
|
||||
*
|
||||
* ═══ O que NÃO pode virar link ═══
|
||||
*
|
||||
* `falaDoCliente` e `textoDeQuemPassou` são texto de FORA (o cliente, o modelo,
|
||||
* um agente MCP). Eles já passaram por `sanitizarTextoDoLead` na escrita, mas a
|
||||
* regra na tela é mais simples e mais segura: **nada aqui vira `<a>`**. Um link
|
||||
* renderizado a partir do que o cliente digitou é phishing dentro do CRM, de
|
||||
* graça e com a autoridade da nossa interface.
|
||||
*
|
||||
* ═══ Acessibilidade ═══
|
||||
*
|
||||
* `<article aria-labelledby>` com `<h3>` — o cartão é um marco, não um parágrafo
|
||||
* solto no fio. O ⚠ tem `aria-hidden` e a severidade viaja no TEXTO ("O cliente
|
||||
* NÃO foi avisado"), nunca só na cor: cor não sobrevive ao daltonismo nem ao
|
||||
* teste do metro. As tentativas são `<ol>` de verdade, e as passagens antigas
|
||||
* usam `<details>` nativo — teclado e leitor de tela de graça.
|
||||
*
|
||||
* ⚠️ **GUARDA DE LOCALIZADOR.** O convite se chama "Assumir e responder", e o
|
||||
* cabeçalho tem um botão "Assumir". As duas specs que clicam o do cabeçalho usam
|
||||
* localizador ANCORADO (`{ name: "Assumir", exact: true }` em
|
||||
* `encerramento-atendimento.spec.ts` e `/^Assumir$/i` em
|
||||
* `inbox-quem-manda.spec.ts`), então elas passam — **mas a margem é de uma
|
||||
* palavra**. Encurtar este rótulo para "Assumir" faz as duas virarem *strict
|
||||
* mode violation*, e o vermelho aparece longe daqui.
|
||||
*/
|
||||
export function PassagemCard({ cartao, contatoId, onAssumir, assumindo }: Props) {
|
||||
const t = useT();
|
||||
const localeDaData = useLocaleDeData();
|
||||
const tituloId = useId();
|
||||
const hora = format(new Date(cartao.criadoEm), "dd/MM HH:mm", { locale: localeDaData });
|
||||
|
||||
if (cartao.recolhido) {
|
||||
return (
|
||||
<div className="flex w-full justify-center px-4 py-1">
|
||||
<details
|
||||
className="w-full max-w-[85%] rounded-xl border border-border bg-muted/40 px-3 py-2 text-sm"
|
||||
data-testid="cartao-passagem"
|
||||
data-passagem-recolhido="true"
|
||||
>
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
{t(cartao.motivo)} · {hora}
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<Corpo cartao={cartao} tituloId={tituloId} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emAberto = cartao.estado === "aberta";
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center px-4 py-2">
|
||||
<article
|
||||
aria-labelledby={tituloId}
|
||||
data-testid="cartao-passagem"
|
||||
data-passagem-estado={cartao.estado}
|
||||
className={
|
||||
emAberto
|
||||
? "w-full max-w-[85%] rounded-xl border border-warning/50 bg-warning-bg px-3 py-2.5 text-sm shadow-sm"
|
||||
: "w-full max-w-[85%] rounded-xl border border-border bg-muted/40 px-3 py-2.5 text-sm shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 id={tituloId} className="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<Robot size={14} weight="fill" aria-hidden />
|
||||
{t(cartao.titulo)}
|
||||
</h3>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{hora}</span>
|
||||
</div>
|
||||
|
||||
<Corpo cartao={cartao} tituloId={tituloId} />
|
||||
|
||||
<Rodape
|
||||
cartao={cartao}
|
||||
contatoId={contatoId}
|
||||
onAssumir={onAssumir}
|
||||
assumindo={assumindo}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** As seções. Separado do invólucro porque o estado recolhido reusa exatamente isto. */
|
||||
function Corpo({ cartao, tituloId }: { cartao: CartaoDaPassagem; tituloId: string }) {
|
||||
const t = useT();
|
||||
|
||||
if (cartao.anonimizada) {
|
||||
return (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t("Este contato foi anonimizado a pedido dele. O contexto desta passagem foi apagado.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-1.5 font-medium" data-testid="passagem-motivo">
|
||||
{t(cartao.motivo)}
|
||||
</p>
|
||||
|
||||
{cartao.clienteQuer !== null && (
|
||||
<Secao rotulo={t("O cliente quer")}>
|
||||
<p className="whitespace-pre-wrap break-words">{cartao.clienteQuer}</p>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{cartao.tentativas.length > 0 && (
|
||||
<Secao rotulo={t("A IA já tentou")}>
|
||||
<ol className="list-decimal space-y-0.5 pl-4" data-testid="passagem-tentativas">
|
||||
{cartao.tentativas.map((tentativa, i) => (
|
||||
<li key={`${tituloId}-t${i}`} className="whitespace-pre-wrap break-words">
|
||||
{tentativa.o_que}
|
||||
{tentativa.desfecho !== undefined && ` → ${tentativa.desfecho}`}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{/* Aspas e itálico separam a PALAVRA DO CLIENTE da conclusão da IA. Quem lê
|
||||
precisa saber o que foi dito de quem interpretou — é a mitigação de
|
||||
injeção pelo histórico levada para a tela, não só para o prompt. */}
|
||||
{cartao.falaDoCliente !== null && (
|
||||
<Secao rotulo={t("Últimas palavras do cliente")}>
|
||||
<blockquote className="whitespace-pre-wrap break-words border-l-2 border-border pl-2 italic">
|
||||
{`“${cartao.falaDoCliente}”`}
|
||||
</blockquote>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{/* "(confira)" no rótulo, e não numa nota de rodapé: é a IA resumindo, e
|
||||
quem vai responder assume o que disser. Esconder a seção quando ela é o
|
||||
piso evita o cabeçalho órfão — três linhas para dizer nada. */}
|
||||
{cartao.resumo !== null && (
|
||||
<Secao rotulo={t("Resumo da IA (confira)")}>
|
||||
<p className="whitespace-pre-wrap break-words" data-testid="passagem-resumo">
|
||||
{cartao.resumo}
|
||||
</p>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{cartao.textoDeQuemPassou !== null && (
|
||||
<Secao rotulo={t("Escrito por quem passou")}>
|
||||
<p className="whitespace-pre-wrap break-words">{cartao.textoDeQuemPassou}</p>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{cartao.semContexto && !cartao.anonimizada && (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t("Sem resumo acumulado ainda — a conversa é recente. Role para cima para ver tudo o que foi dito.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{cartao.aviso !== null && (
|
||||
<p
|
||||
className="mt-2 flex items-start gap-1.5 text-xs"
|
||||
data-testid="passagem-aviso-ao-cliente"
|
||||
>
|
||||
{cartao.aviso.avisado ? (
|
||||
<span>{t("O cliente já foi avisado de que uma pessoa vai assumir.")}</span>
|
||||
) : (
|
||||
<>
|
||||
<Warning size={13} weight="fill" aria-hidden className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{t("O cliente NÃO foi avisado — ele está esperando sem saber.")}
|
||||
{cartao.aviso.frase !== null && ` (${t(cartao.aviso.frase)})`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Secao({ rotulo, children }: { rotulo: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{rotulo}
|
||||
</p>
|
||||
<div className="mt-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* O gesto. QUAL gesto é decisão de `montarCartoesDaPassagem` — aqui só se
|
||||
* desenha, porque o vocabulário do banco que distingue os casos é proibido em
|
||||
* `components/` (`tests/unit/passagem-motivo-em-portugues.test.ts`).
|
||||
*/
|
||||
function Rodape({
|
||||
cartao,
|
||||
contatoId,
|
||||
onAssumir,
|
||||
assumindo,
|
||||
}: {
|
||||
cartao: CartaoDaPassagem;
|
||||
contatoId: string | null;
|
||||
onAssumir: () => void;
|
||||
assumindo: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
if (cartao.estado === "reconhecida") {
|
||||
return (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{cartao.assumidaPor === null
|
||||
? t("Alguém da equipe já assumiu este atendimento.")
|
||||
: `${t("Assumida por")} ${cartao.assumidaPor}`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (cartao.estado === "devolvida") {
|
||||
return (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{t("Atendimento devolvido ao automático — ninguém assumiu.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
switch (cartao.acao.tipo) {
|
||||
case "assumir_e_responder":
|
||||
return (
|
||||
<div className="mt-2.5 flex justify-end">
|
||||
<Button size="sm" onClick={onAssumir} disabled={assumindo} data-testid="passagem-assumir">
|
||||
{assumindo ? t("Assumindo...") : t("Assumir e responder")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
case "abrir_contato":
|
||||
// Sem convite de responder, de propósito: um botão que diz "assumir e
|
||||
// responder" empurra alguém a escrever para quem acabou de pedir para
|
||||
// parar de receber mensagens.
|
||||
return (
|
||||
<div className="mt-2.5 flex justify-end">
|
||||
{contatoId !== null ? (
|
||||
<Button size="sm" variant="outline" asChild data-testid="passagem-abrir-contato">
|
||||
<Link href={`/app/contacts/${contatoId}`}>
|
||||
{t("Abrir o contato para confirmar o bloqueio")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("Confirme na ficha do contato se ele pediu para não receber mais mensagens.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "avisa_quem_atende":
|
||||
// O gesto que existe é transferir, e ele mora no cabeçalho. Duplicá-lo
|
||||
// aqui seria dois botões para um ato; ficar mudo deixaria o cartão mais
|
||||
// caro da entrega sem nada a dizer para metade dos leitores.
|
||||
return (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{cartao.acao.donoNome === null
|
||||
? t("Outra pessoa está atendendo. Se precisar assumir, use Transferir no topo da conversa.")
|
||||
: `${cartao.acao.donoNome} ${t("está atendendo. Se precisar assumir, use Transferir no topo da conversa.")}`}
|
||||
</p>
|
||||
);
|
||||
case "nenhuma":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,6 @@
|
||||
"sublabel": "o número do plantão não vira contato, conversa nem despacho"
|
||||
},
|
||||
{
|
||||
<<<<<<< HEAD
|
||||
"id": "avisoSuporteTela",
|
||||
"lane": "humano",
|
||||
"col": 5,
|
||||
@@ -434,7 +433,8 @@
|
||||
"type": "route",
|
||||
"label": "/api/v1/ai/cases/alerta (+ /teste)",
|
||||
"sublabel": "escreve só pelo RPC; o teste manda de verdade e paga no pacing_ledger"
|
||||
=======
|
||||
},
|
||||
{
|
||||
"id": "passagemMontagem",
|
||||
"lane": "regra",
|
||||
"col": 3,
|
||||
@@ -457,7 +457,38 @@
|
||||
"type": "db",
|
||||
"label": "trg_passagem_reconhecida + fn_passagem_devolvida (0293)",
|
||||
"sublabel": "assumir fecha a passagem e resolve o aviso; devolver ao automático fecha sem dono"
|
||||
>>>>>>> feat/casos-vivos
|
||||
},
|
||||
{
|
||||
"id": "passagemCartao",
|
||||
"lane": "humano",
|
||||
"col": 6,
|
||||
"type": "ui",
|
||||
"label": "components/inbox/PassagemCard.tsx",
|
||||
"sublabel": "o cartao DENTRO do fio, acima do composer: motivo em portugues, o que a IA tentou, a fala LITERAL do cliente e se ele foi avisado"
|
||||
},
|
||||
{
|
||||
"id": "passagemRota",
|
||||
"lane": "borda",
|
||||
"col": 6,
|
||||
"type": "route",
|
||||
"label": "/api/v1/conversations/[id]/passagens",
|
||||
"sublabel": "client de SESSAO, nunca admin — e o que faz visibility_mode valer para o TEXTO, e nao so para o link"
|
||||
},
|
||||
{
|
||||
"id": "passagemCobranca",
|
||||
"lane": "motor",
|
||||
"col": 6,
|
||||
"type": "worker",
|
||||
"label": "cron case-stale-watcher (2o braco)",
|
||||
"sublabel": "a passagem que ninguem assumiu reabre o aviso; teto de 3 em passagens_de_atendimento.cobrancas"
|
||||
},
|
||||
{
|
||||
"id": "passagemLaco",
|
||||
"lane": "dados",
|
||||
"col": 6,
|
||||
"type": "db",
|
||||
"label": "fn_atrito_metrics: repeticao_pos_passagem (0294)",
|
||||
"sublabel": "o cliente repetiu depois da passagem? limiar 0,7 e janela de 24h — se o briefing chegou, a repeticao cai"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
@@ -907,7 +938,6 @@
|
||||
},
|
||||
{
|
||||
"id": "e75",
|
||||
<<<<<<< HEAD
|
||||
"from": "avisoSuporteTela",
|
||||
"to": "avisoSuporteRota",
|
||||
"label": "a tela nunca escreve no banco: ela fala com a rota"
|
||||
@@ -941,59 +971,114 @@
|
||||
"from": "numeroInterno",
|
||||
"to": "avisoSuporteTela",
|
||||
"label": "os contadores do descarte viram a frase que impede o silêncio de parecer defeito"
|
||||
=======
|
||||
},
|
||||
{
|
||||
"id": "e81",
|
||||
"from": "passagemMontagem",
|
||||
"to": "passagemRegistro",
|
||||
"label": "body/title/notes/content + tentativas — as quatro colunas saem da montagem única"
|
||||
},
|
||||
{
|
||||
"id": "e76",
|
||||
"id": "e82",
|
||||
"from": "checkpoints",
|
||||
"to": "passagemMontagem",
|
||||
"label": "o resumo acumulado e a declaração do turno entram no briefing"
|
||||
},
|
||||
{
|
||||
"id": "e77",
|
||||
"id": "e83",
|
||||
"from": "avisoMotor",
|
||||
"to": "passagemMontagem",
|
||||
"label": "o desfecho do aviso vira cliente_avisado + aviso_motivo_codigo — 'queued' deixou de contar como entrega"
|
||||
},
|
||||
{
|
||||
"id": "e78",
|
||||
"id": "e84",
|
||||
"from": "orquestrador",
|
||||
"to": "passagemRegistro",
|
||||
"label": "Step 5.5 — o motor do CRM passou a gravar o que nunca gravou"
|
||||
},
|
||||
{
|
||||
"id": "e79",
|
||||
"id": "e85",
|
||||
"from": "passagemRegistro",
|
||||
"to": "central",
|
||||
"label": "o corpo do aviso é CURTO e sem conversa: a Central entrega body a qualquer agent"
|
||||
},
|
||||
{
|
||||
"id": "e80",
|
||||
"id": "e86",
|
||||
"from": "atribuir",
|
||||
"to": "passagemReconhece",
|
||||
"label": "o INSERT em conversation_assignment_events dispara o gatilho na MESMA transação"
|
||||
},
|
||||
{
|
||||
"id": "e81",
|
||||
"id": "e87",
|
||||
"from": "passagemReconhece",
|
||||
"to": "passagemRegistro",
|
||||
"label": "reconhecido_por/reconhecido_em — só quando a conversa está MESMO com aquele dono"
|
||||
},
|
||||
{
|
||||
"id": "e82",
|
||||
"id": "e88",
|
||||
"from": "passagemReconhece",
|
||||
"to": "central",
|
||||
"label": "resolve o aviso handoff; sem isto o dedup impede a PRÓXIMA passagem de nascer"
|
||||
},
|
||||
{
|
||||
"id": "e83",
|
||||
"id": "e89",
|
||||
"from": "retomada",
|
||||
"to": "passagemReconhece",
|
||||
"label": "fn_passagem_devolvida: episódio fechado sem ninguém ter assumido"
|
||||
>>>>>>> feat/casos-vivos
|
||||
},
|
||||
{
|
||||
"id": "e90",
|
||||
"from": "passagemRegistro",
|
||||
"to": "passagemRota",
|
||||
"label": "a policy de tres condicoes e lida pelo client da sessao: organizacao + papel agent + fn_can_view_conversation"
|
||||
},
|
||||
{
|
||||
"id": "e91",
|
||||
"from": "passagemRota",
|
||||
"to": "passagemCartao",
|
||||
"label": "o hook usePassagensDaConversa; sem realtime proprio — a tabela nao esta na publicacao, e canal fora dela assina e nao recebe"
|
||||
},
|
||||
{
|
||||
"id": "e92",
|
||||
"from": "passagemCartao",
|
||||
"to": "chatthread",
|
||||
"label": "terceiro tipo de item do fio, irmao do NoteCard: o cabecalho nao ganha texto e o painel lateral nao ganha bloco"
|
||||
},
|
||||
{
|
||||
"id": "e93",
|
||||
"from": "passagemCartao",
|
||||
"to": "atribuir",
|
||||
"label": "o convite Assumir e responder chama o MESMO claim do cabecalho — uma rota, um efeito"
|
||||
},
|
||||
{
|
||||
"id": "e94",
|
||||
"from": "passagemCobranca",
|
||||
"to": "passagemRegistro",
|
||||
"label": "varre reconhecido_em is null com mais de 24h e sobe cobrancas; passagem aberta e demanda viva"
|
||||
},
|
||||
{
|
||||
"id": "e95",
|
||||
"from": "passagemCobranca",
|
||||
"to": "central",
|
||||
"label": "reabre o aviso handoff resolvido sem ninguem ter assumido — marcar como resolvido nao e assumir"
|
||||
},
|
||||
{
|
||||
"id": "e96",
|
||||
"from": "passagemRegistro",
|
||||
"to": "passagemLaco",
|
||||
"label": "o denominador sao as passagens em que o cliente VOLTOU a falar; sem fala nova nao ha repeticao a medir"
|
||||
},
|
||||
{
|
||||
"id": "e97",
|
||||
"from": "passagemLaco",
|
||||
"to": "casos",
|
||||
"label": "o laco de retorno publicado no par Automacao, encostado em Passagens para humano"
|
||||
},
|
||||
{
|
||||
"id": "e98",
|
||||
"from": "passagemReconhece",
|
||||
"to": "passagemCartao",
|
||||
"label": "reconhecido_por/em viram o rodape Assumida por, e o convite some"
|
||||
}
|
||||
],
|
||||
"cards": [
|
||||
|
||||
@@ -245,6 +245,14 @@
|
||||
"type": "route",
|
||||
"label": "PATCH /v1/demandas/:id",
|
||||
"sublabel": "piso agent — quem atende é quem sabe o que vem a seguir"
|
||||
},
|
||||
{
|
||||
"id": "passagens",
|
||||
"lane": "entrada",
|
||||
"col": 1,
|
||||
"type": "table",
|
||||
"label": "passagens_de_atendimento",
|
||||
"sublabel": "uma linha por passagem IA→humano (0291): e o marco a partir do qual se mede se o cliente repetiu"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
@@ -451,6 +459,18 @@
|
||||
"from": "rotalead",
|
||||
"to": "crmleads",
|
||||
"label": "PATCH · audit + lead_edited só do que mudou"
|
||||
},
|
||||
{
|
||||
"id": "e35",
|
||||
"from": "passagens",
|
||||
"to": "fnatrito",
|
||||
"label": "denominador = passagens em que o cliente VOLTOU a falar em 24h; sem fala nova nao ha repeticao a medir"
|
||||
},
|
||||
{
|
||||
"id": "e36",
|
||||
"from": "fnjaccard",
|
||||
"to": "passagens",
|
||||
"label": "o MESMO limiar 0,7 da repergunta compara a fala de depois com a de antes da passagem"
|
||||
}
|
||||
],
|
||||
"cards": [
|
||||
|
||||
@@ -82,9 +82,17 @@ Regra 3.3 da doutrina: toda métrica de eficiência é publicada ao lado da cont
|
||||
| `won` / taxa de conversão | Turnos até desfecho · opt-outs · insistência média | ✅ derivável |
|
||||
| `conversations_handled` | Abandono · reabertura | ◐ definição |
|
||||
| `avg_first_response_seconds` | Repetição da mesma pergunta | ✗ instrumentação |
|
||||
| Automação (% sem humano) | Pedidos de humano · **taxa de contorno** | ✅ derivável |
|
||||
| Automação (% sem humano) | Pedidos de humano · **taxa de contorno** · **repetição depois da passagem** | ✅ derivável |
|
||||
| Custo por conversa | Tempo humano por desfecho | ◐ proxy |
|
||||
|
||||
**A repetição depois da passagem** (migration 0294) é o laço de retorno da entrega de contexto na
|
||||
passagem para humano: das passagens em que o cliente VOLTOU a falar nas 24 h seguintes, em quantas
|
||||
ele teve de repetir o que já tinha dito (limiar 0,7, o mesmo do índice de repergunta). Se o
|
||||
briefing chega a quem assume, ela cai; se não chega, não muda. Numerador e denominador viajam
|
||||
separados (`repeticao_pos_passagem` / `passagens_medidas`) porque é a borda que transforma
|
||||
denominador zero em `—`, e não em `0%`. Para ver a régua em vigor sem confiar nesta linha:
|
||||
`grep -n "repeticao_pos_passagem" -B24 supabase/baseline.sql | head -40`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Decisões pendentes
|
||||
|
||||
@@ -2524,10 +2524,11 @@ alguma coisa e digitando a primeira frase — e é essa frase que o cliente rece
|
||||
|
||||
### O que a onda entregou, e o que ela NÃO provou
|
||||
|
||||
Esta onda é de MOTOR: ela faz as treze passagens gravarem o contexto, corrige a verdade
|
||||
do "cliente já foi avisado", troca o dedup do aviso por adendo e faz o aviso se resolver
|
||||
sozinho quando alguém assume. **O cartão na conversa é da onda seguinte** — então a
|
||||
jornada em tela ainda não existe, e dizer que ela passou seria afirmar o que não se mediu.
|
||||
A onda de MOTOR fez as treze passagens gravarem o contexto, corrigiu a verdade do
|
||||
"cliente já foi avisado", trocou o dedup do aviso por adendo e fez o aviso se resolver
|
||||
sozinho quando alguém assume. **A onda seguinte trouxe o cartão, a rota, o cobrador e o
|
||||
laço de retorno** — e mesmo assim a jornada EM TELA ainda não existe: nada aqui foi
|
||||
dirigido por um browser, e dizer que passou seria afirmar o que não se mediu.
|
||||
|
||||
| caso | estado |
|
||||
|---|---|
|
||||
@@ -2535,8 +2536,35 @@ jornada em tela ainda não existe, e dizer que ela passou seria afirmar o que n
|
||||
| J27.2 · a segunda passagem da mesma conversa vira ADENDO, não descarte | **PASS por unidade** — mesmo arquivo, nos dois motores |
|
||||
| J27.3 · "o cliente JÁ FOI avisado" só quando a mensagem saiu | **PASS por unidade** — `tests/unit/passagem-verdade-do-aviso.test.ts`, nos dois emissores |
|
||||
| J27.4 · assumir a conversa fecha a passagem e resolve o aviso | **PENDENTE POR EXECUÇÃO** — `tests/invariants/passagem-se-reconhece-sozinha.test.ts` existe e precisa de `pnpm test:db` |
|
||||
| J27.5 · **pela TELA**, quem assume lê o porquê, o que a IA tentou e a fala do cliente | **NÃO COBERTO** — o cartão é da onda seguinte; o e2e `passagem-com-contexto.spec.ts` nasce com ele |
|
||||
| J27.6 · **pela TELA**, o aviso da Central leva a "Abrir conversa" e some ao assumir | **NÃO COBERTO** — mesma onda |
|
||||
| J27.5 · **pela TELA**, quem assume lê o porquê, o que a IA tentou e a fala do cliente | **NÃO COBERTO** — o cartão JÁ EXISTE (`components/inbox/PassagemCard.tsx`), mas ninguém o dirigiu por um browser; o e2e `passagem-com-contexto.spec.ts` é da onda da prova em tela |
|
||||
| J27.6 · **pela TELA**, o aviso da Central leva a "Abrir conversa" e some ao assumir | **NÃO COBERTO** — a projeção e o rótulo existem (`lib/ai/inbox-destino.ts`); o que falta é a prova em tela |
|
||||
| J27.7 · o cartão decide os SETE estados (nova, reconhecida, devolvida, recolhida, sem resumo, opt-out, anonimizada) | **PASS por unidade** — `tests/unit/cartao-da-passagem.test.ts` (25 casos), sobre a função pura que o JSX consome |
|
||||
| J27.8 · a rota das passagens lê com o client da SESSÃO, e não com o admin | **PASS por unidade** — `tests/unit/passagens-da-conversa-rota.test.ts`; a RLS em si é do `test:db` |
|
||||
| J27.9 · a passagem que ninguém assumiu volta a pedir, e para no terceiro aviso | **PASS por unidade** — `tests/unit/cobrador-de-passagem-nao-reconhecida.test.ts` (10 casos) |
|
||||
| J27.10 · o laço de retorno: o cliente repetiu depois da passagem? | **PENDENTE POR EXECUÇÃO** — `tests/invariants/atrito-repeticao-pos-passagem.test.ts` existe e precisa de `pnpm test:db` |
|
||||
|
||||
### O que a onda do CARTÃO entregou — e a linha que continua NÃO COBERTA
|
||||
|
||||
O cartão existe, dentro do fio da conversa, e a decisão dos sete estados está provada por
|
||||
unidade. **J27.5 e J27.6 continuam NÃO COBERTOS**, e a distinção importa: o que foi provado
|
||||
é que a função decide certo e que a rota entrega a leitura ao client que tem RLS. Que a
|
||||
TELA renderiza aquilo, que o botão "Assumir e responder" muda o estado do cartão e que o
|
||||
aviso sai da lista de abertos é jornada em tela — DoD 12 —, e é da onda 12. Declarar PASS
|
||||
aqui seria inventar uma medição.
|
||||
|
||||
**Por que o cartão mora no fio, e não no cabeçalho:** o `ConversationHeader.tsx` carrega um
|
||||
comentário de 11 linhas contando que ele já travou a largura da tela inteira em 707px e
|
||||
empurrou o painel de CRM 311px para fora da viewport em 1280px. Um cartão de seis linhas
|
||||
ali reintroduz o defeito que o `flex-wrap` acabou de consertar. O fio já intercala
|
||||
mensagens e notas por timestamp e o auto-scroll traz o fim para a viewport — e como a
|
||||
passagem CALA a IA, ela é quase sempre o último evento quando a pessoa chega.
|
||||
|
||||
**Achado desta onda, e não é do produto:** `docs/architecture/escalacao-ciclo-humano.architecture.json`
|
||||
estava na `main` da branch **com marcadores de conflito de merge commitados** (`<<<<<<< HEAD`
|
||||
nas linhas 422 e 910, do merge `f7523adc5`). O arquivo não era JSON válido e
|
||||
`tests/unit/mapas-de-arquitetura.test.ts` estava **vermelho em 5 casos** desde então.
|
||||
Resolvido pela UNIÃO dos dois lados, com as arestas do lado `feat/casos-vivos` renumeradas
|
||||
(`e75`–`e83` → `e81`–`e89`) porque os ids colidiam. 121/121 depois.
|
||||
|
||||
### O achado que mudou o desenho, e que a tela não teria encontrado
|
||||
|
||||
|
||||
@@ -127,6 +127,11 @@ const ACTION_MIN_ROLE: Record<string, Role> = {
|
||||
"ai.automatico.view": "agent",
|
||||
"ai.inbox.view": "agent",
|
||||
"inbox.notes.view": "agent",
|
||||
// O cartão da passagem, dentro da conversa. `agent` e não `viewer` porque é o
|
||||
// piso que a policy de `passagens_de_atendimento` exige (migration 0291): o
|
||||
// briefing diz MAIS que a conversa — diz o que a IA concluiu sobre a pessoa.
|
||||
// Um `viewer` que sondasse esta rota levaria 403 em toda abertura de conversa.
|
||||
"inbox.passagens.view": "agent",
|
||||
"message-templates.view": "agent",
|
||||
"ai.agents.view": "manager",
|
||||
"ai.agents.write": "admin",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { showApiError } from "@/components/feedback/ApiErrorToast";
|
||||
import { usePermission } from "@/hooks/auth/AuthProvider";
|
||||
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { PassagemDaConversa } from "@/lib/escalacao/cartao-da-passagem";
|
||||
|
||||
/**
|
||||
* AS PASSAGENS DAQUELA CONVERSA — poucas por conversa, query simples.
|
||||
*
|
||||
* ═══ Por que o canal escuta `conversations`, e não a tabela das passagens ═══
|
||||
*
|
||||
* Porque `passagens_de_atendimento` **não** está na publicação
|
||||
* `supabase_realtime`, e assinar `postgres_changes` numa tabela de fora é uma
|
||||
* falha MUDA: o canal conecta, responde `SUBSCRIBED` e nunca recebe nada. Esse
|
||||
* defeito está vivo neste repositório — `useConversationNotes` faz exatamente
|
||||
* isso com `conversation_notes` — e repeti-lo aqui seria plantar um bug já
|
||||
* conhecido num caminho novo.
|
||||
*
|
||||
* `conversations` ESTÁ na publicação (o laço do baseline a inclui junto de
|
||||
* `messages` e `crm_leads`), e ela muda no MESMO instante em que a passagem
|
||||
* acontece: o motor grava `bot_silenced_until`/`last_handoff_reason`, e quem
|
||||
* assume muda `assigned_to_user_id`. É a carona certa — o cartão aparece e sai
|
||||
* do estado "esperando alguém assumir" sem que ninguém recarregue a página.
|
||||
*
|
||||
* ⚠️ O que essa escolha NÃO dá: reatividade a uma mudança que toque SÓ a
|
||||
* passagem. Hoje não existe uma — todo escritor da tabela mexe na conversa no
|
||||
* mesmo caminho —, e no dia em que existir, o conserto é pôr a tabela na
|
||||
* publicação, não abrir um canal que não recebe.
|
||||
*/
|
||||
export function usePassagensDaConversa(conversationId: string | null): PassagemDaConversa[] {
|
||||
const podeConsultar = usePermission("inbox.passagens.view");
|
||||
const qc = useQueryClient();
|
||||
const queryKey = ["passagens", conversationId] as const;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: !!conversationId && podeConsultar,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiClient.get<{ data: PassagemDaConversa[] }>(
|
||||
`/api/v1/conversations/${conversationId}/passagens`,
|
||||
);
|
||||
} catch (err) {
|
||||
showApiError(err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
select: (res) => res.data,
|
||||
});
|
||||
|
||||
const onChange = useCallback(() => {
|
||||
if (conversationId) qc.invalidateQueries({ queryKey: ["passagens", conversationId] });
|
||||
}, [qc, conversationId]);
|
||||
|
||||
useRealtimeChannel({
|
||||
name: conversationId ? `conversation-passagens-${conversationId}` : "conversation-passagens-disabled",
|
||||
postgresChanges: conversationId
|
||||
? {
|
||||
event: "UPDATE",
|
||||
schema: "public",
|
||||
table: "conversations",
|
||||
filter: `id=eq.${conversationId}`,
|
||||
}
|
||||
: undefined,
|
||||
onChange,
|
||||
enabled: !!conversationId && podeConsultar,
|
||||
});
|
||||
|
||||
return query.data ?? [];
|
||||
}
|
||||
@@ -44,7 +44,11 @@ export const POLITICAS_DE_AVISO = {
|
||||
event_dead: { refs: [], orientacao: "Peça a quem administra para conferir o processamento descrito neste aviso." },
|
||||
budget_exceeded: { refs: ["ai_budget"], orientacao: "Peça ao gestor para revisar o limite e o uso de IA." },
|
||||
budget_warning: { refs: ["ai_budget"], orientacao: "Peça ao gestor para revisar o limite e o uso de IA." },
|
||||
handoff: { refs: ["contact", "conversation"], orientacao: "Confira o atendimento descrito e combine quem assume o próximo passo." },
|
||||
// `conversation` primeiro porque é o que o produtor grava hoje (o corpo do
|
||||
// aviso ficou CURTO e o contexto foi para dentro da conversa, onde a RLS o
|
||||
// protege). `contact` continua na lista por causa dos itens de clone antigo,
|
||||
// gravados antes da troca — tirá-lo faria aqueles avisos perderem o destino.
|
||||
handoff: { refs: ["conversation", "contact"], orientacao: "Abra a conversa: o cartão no fim do fio diz por que a IA passou, o que ela já tentou e se o cliente foi avisado." },
|
||||
promotion_review: { refs: [], orientacao: "Na evolução do assistente, confira as propostas disponíveis. Este aviso não identifica uma proposta específica.", geral: EVOLUCAO },
|
||||
judge_unaligned: { refs: [], orientacao: "Na evolução do assistente, confira a avaliação de qualidade. Este aviso não identifica uma avaliação específica.", geral: EVOLUCAO },
|
||||
followup_dead: { refs: ["followup_enrollment"], orientacao: "Peça ao gestor para revisar o acompanhamento que parou." },
|
||||
|
||||
@@ -627,6 +627,22 @@ export const AUDIT_ACTIONS = [
|
||||
* fato, e a razão da recusa é o que responde depois "por que não sai".
|
||||
*/
|
||||
"ai.case_alert_test_sent",
|
||||
/**
|
||||
* A cobrança da PASSAGEM que ninguém assumiu (onda 11).
|
||||
*
|
||||
* Código próprio, e não `ai.caso_parado_cobrado` com um campo no metadata:
|
||||
* são duas populações diferentes e a pergunta que se faz depois é diferente.
|
||||
* O caso parado é a IA esperando uma DECISÃO; a passagem esquecida é um
|
||||
* cliente esperando uma RESPOSTA, e ninguém sabe que ele existe. Dos treze
|
||||
* caminhos que passam conversa para uma pessoa, só um nasce de caso — o vigia
|
||||
* de casos não alcançava os outros doze nem por acidente, e um metadata
|
||||
* compartilhado esconderia justamente essa diferença (o painel de auditoria
|
||||
* filtra por `action`, nunca por metadata).
|
||||
*
|
||||
* Audita a RODADA que cobrou, nunca a que varreu e não achou ninguém: rodada
|
||||
* sem efeito não é mutação (`tests/unit/cron-audita-so-quando-ha-efeito.test.ts`).
|
||||
*/
|
||||
"ai.passagem_parada_cobrada",
|
||||
] as const;
|
||||
|
||||
/** Um código de auditoria. Derivado de `AUDIT_ACTIONS` — não redigite a lista. */
|
||||
|
||||
@@ -6712,6 +6712,7 @@ export type Database = {
|
||||
body: string
|
||||
caso_id: string | null
|
||||
cliente_avisado: boolean | null
|
||||
cobrancas: number
|
||||
contact_id: string
|
||||
content: string | null
|
||||
conversation_id: string
|
||||
@@ -6732,6 +6733,7 @@ export type Database = {
|
||||
body: string
|
||||
caso_id?: string | null
|
||||
cliente_avisado?: boolean | null
|
||||
cobrancas?: number
|
||||
contact_id: string
|
||||
content?: string | null
|
||||
conversation_id: string
|
||||
@@ -6752,6 +6754,7 @@ export type Database = {
|
||||
body?: string
|
||||
caso_id?: string | null
|
||||
cliente_avisado?: boolean | null
|
||||
cobrancas?: number
|
||||
contact_id?: string
|
||||
content?: string | null
|
||||
conversation_id?: string
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* O CARTÃO "POR QUE A IA PASSOU PARA VOCÊ" — a decisão, fora do JSX.
|
||||
*
|
||||
* ═══ O que este módulo é ═══
|
||||
*
|
||||
* Uma função pura que recebe as linhas de `passagens_de_atendimento` daquela
|
||||
* conversa mais QUEM está olhando, e devolve o que a tela desenha: o estado do
|
||||
* episódio, as seções que existem, a frase do motivo em português e — o mais
|
||||
* importante — QUAL gesto oferecer. Nenhum I/O, nenhum React.
|
||||
*
|
||||
* ═══ Por que a decisão não mora no componente ═══
|
||||
*
|
||||
* 1. **Vocabulário de banco é proibido em tela.**
|
||||
* `tests/unit/passagem-motivo-em-portugues.test.ts` varre `app/`,
|
||||
* `components/` e `hooks/` procurando os códigos de `MOTIVOS_DA_PASSAGEM` e
|
||||
* `MOTIVOS_DO_AVISO`. Um `motivo_codigo === "suspected_optout"` dentro do
|
||||
* JSX reprova aquele gate, e ele está certo em reprovar: o código existe
|
||||
* para a constraint recusar lixo, não para alguém ler. Aqui, em `lib/`, a
|
||||
* comparação é legítima — e fica num lugar só.
|
||||
* 2. **A matriz é grande.** Sete estados × três posições de quem olha. Medir
|
||||
* isso por render é caro e dá falso vermelho de layout; medir por `expect`
|
||||
* numa função pura é uma linha por caso.
|
||||
*
|
||||
* ═══ O gesto: a decisão que mais custa se sair errada ═══
|
||||
*
|
||||
* Três armadilhas, todas com custo real:
|
||||
*
|
||||
* · **Opt-out.** Um cartão que convida a "assumir e responder" empurra alguém
|
||||
* a escrever para quem acabou de pedir para parar de receber mensagens. Por
|
||||
* isso `suspected_optout` NUNCA oferece o convite de responder, em nenhuma
|
||||
* combinação de dono — o gesto vira conferir o bloqueio na ficha.
|
||||
* · **Conversa que já tem dono.** "Assumir e responder" ali oferece um gesto
|
||||
* que a rota recusa. O cartão passa a dizer quem atende; o gesto que existe
|
||||
* (transferir) mora no cabeçalho, e apontar para ele é mais honesto que
|
||||
* duplicá-lo.
|
||||
* · **Episódio fechado.** Passagem reconhecida (ou devolvida ao automático) é
|
||||
* história: ela informa e não pede nada.
|
||||
*
|
||||
* ═══ O que este módulo NÃO decide ═══
|
||||
*
|
||||
* Como aquilo vira pixels. Isso é de `components/inbox/PassagemCard.tsx`, e a
|
||||
* prova de que a tela mostra o que a função decidiu é a prova em tela (DoD 12).
|
||||
*/
|
||||
import {
|
||||
FRASE_DO_MOTIVO,
|
||||
FRASE_DO_MOTIVO_DO_AVISO,
|
||||
PISO_DO_BRIEFING,
|
||||
tentativaDaPassagemSchema,
|
||||
type MotivoDaPassagem,
|
||||
type MotivoDoAviso,
|
||||
type OrigemDaPassagem,
|
||||
type TentativaDaPassagem,
|
||||
} from "./passagem";
|
||||
import { ROTULO_DE_ANONIMIZADO } from "./texto-do-aviso";
|
||||
|
||||
/** A linha como a rota da conversa a devolve. As chaves são as colunas. */
|
||||
export interface PassagemDaConversa {
|
||||
id: string;
|
||||
origem: OrigemDaPassagem;
|
||||
motivo_codigo: MotivoDaPassagem;
|
||||
title: string | null;
|
||||
body: string;
|
||||
notes: string | null;
|
||||
content: string | null;
|
||||
tentativas: unknown;
|
||||
cliente_avisado: boolean | null;
|
||||
aviso_motivo_codigo: MotivoDoAviso | null;
|
||||
caso_id: string | null;
|
||||
criado_em: string;
|
||||
reconhecido_em: string | null;
|
||||
reconhecido_por: string | null;
|
||||
/** Resolvido pelo servidor. `null` é estado DECLARADO (self-host sem service role). */
|
||||
reconhecido_por_nome?: string | null;
|
||||
}
|
||||
|
||||
/** Quem abriu a conversa, e com quem ela está. */
|
||||
export interface QuemOlha {
|
||||
usuarioId: string;
|
||||
/** `conversations.assigned_to_user_id` — a VERDADE sobre haver dono. */
|
||||
donoId: string | null;
|
||||
/** `conversations.assigned_to_user_name` — cortesia, pode ser `null`. */
|
||||
donoNome: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* O gesto que o cartão oferece. União discriminada de propósito: cada braço
|
||||
* carrega o que a tela precisa para desenhá-lo, e acrescentar um novo obriga o
|
||||
* componente a tratá-lo.
|
||||
*/
|
||||
export type AcaoDoCartao =
|
||||
| { tipo: "assumir_e_responder" }
|
||||
| { tipo: "abrir_contato" }
|
||||
| { tipo: "avisa_quem_atende"; donoNome: string | null }
|
||||
| { tipo: "nenhuma" };
|
||||
|
||||
export interface CartaoDaPassagem {
|
||||
id: string;
|
||||
/** ISO-8601. A tela formata; o módulo não sabe de fuso nem de idioma. */
|
||||
criadoEm: string;
|
||||
estado: "aberta" | "reconhecida" | "devolvida";
|
||||
/** `true` quando a cascata de LGPD já passou por aqui. */
|
||||
anonimizada: boolean;
|
||||
/** `true` quando a passagem nasceu de suspeita de pedido de descadastro. */
|
||||
optOut: boolean;
|
||||
/** Recolhido no fio: as passagens anteriores à mais recente. */
|
||||
recolhido: boolean;
|
||||
/** Em português. A tela passa por `t()`; o código do banco não chega lá. */
|
||||
titulo: string;
|
||||
motivo: string;
|
||||
/** `title` — o que a IA entendeu que o cliente quer. */
|
||||
clienteQuer: string | null;
|
||||
/** `body` — a narrativa. `null` quando é o piso (a seção some). */
|
||||
resumo: string | null;
|
||||
/** `true` quando a montagem não recebeu contexto nenhum. */
|
||||
semContexto: boolean;
|
||||
/** `notes` — as palavras LITERAIS do cliente. Citação, nunca conclusão. */
|
||||
falaDoCliente: string | null;
|
||||
/** `content` — o texto livre de quem passou (modelo, MCP, pessoa do caso). */
|
||||
textoDeQuemPassou: string | null;
|
||||
tentativas: TentativaDaPassagem[];
|
||||
/** `null` = ninguém TENTOU avisar, que é diferente de tentou e não conseguiu. */
|
||||
aviso: { avisado: boolean; frase: string | null } | null;
|
||||
/** Quem assumiu, quando o servidor resolveu o nome. */
|
||||
assumidaPor: string | null;
|
||||
acao: AcaoDoCartao;
|
||||
}
|
||||
|
||||
/** Texto em branco vira ausência: uma seção com corpo vazio afirma que o dado é nada. */
|
||||
function texto(valor: string | null | undefined): string | null {
|
||||
const limpo = (valor ?? "").trim();
|
||||
return limpo === "" ? null : limpo;
|
||||
}
|
||||
|
||||
/**
|
||||
* As tentativas, item a item, DESCARTANDO o que não casa com o schema.
|
||||
*
|
||||
* `tentativas` é `jsonb` gravado a partir do que o modelo (ou um agente MCP
|
||||
* externo) escreveu, e o CHECK do banco garante só que é um array. Um
|
||||
* `parse` do array inteiro derrubaria o cartão por causa de um item torto — e o
|
||||
* motivo e a fala do cliente, que é o que a pessoa precisa ler, iriam junto.
|
||||
* Falhar fechado na AÇÃO, aberto na INFORMAÇÃO.
|
||||
*/
|
||||
function tentativasLegiveis(cru: unknown): TentativaDaPassagem[] {
|
||||
if (!Array.isArray(cru)) return [];
|
||||
const boas: TentativaDaPassagem[] = [];
|
||||
for (const item of cru) {
|
||||
const r = tentativaDaPassagemSchema.safeParse(item);
|
||||
if (r.success) boas.push(r.data);
|
||||
}
|
||||
return boas;
|
||||
}
|
||||
|
||||
function estadoDe(p: PassagemDaConversa): CartaoDaPassagem["estado"] {
|
||||
if (p.reconhecido_em === null) return "aberta";
|
||||
return p.reconhecido_por === null ? "devolvida" : "reconhecida";
|
||||
}
|
||||
|
||||
/**
|
||||
* A cascata de LGPD grava o rótulo em `body`, que é a coluna `not null` e a
|
||||
* única que ela garante ter reescrito. Um clone antigo pode ter sobra nas
|
||||
* outras — e sobra não pode ressuscitar na tela de quem atende.
|
||||
*/
|
||||
function foiAnonimizada(p: PassagemDaConversa): boolean {
|
||||
return p.body.trimStart().startsWith(ROTULO_DE_ANONIMIZADO);
|
||||
}
|
||||
|
||||
function acaoDe(input: {
|
||||
estado: CartaoDaPassagem["estado"];
|
||||
anonimizada: boolean;
|
||||
optOut: boolean;
|
||||
ultima: boolean;
|
||||
quem: QuemOlha;
|
||||
}): AcaoDoCartao {
|
||||
const { estado, anonimizada, optOut, ultima, quem } = input;
|
||||
// Episódio fechado, contato esquecido ou cartão recolhido: o cartão informa e
|
||||
// não pede nada. Três convites na mesma conversa são um convite só.
|
||||
if (anonimizada || estado !== "aberta" || !ultima) return { tipo: "nenhuma" };
|
||||
if (optOut) return { tipo: "abrir_contato" };
|
||||
if (quem.donoId === null) return { tipo: "assumir_e_responder" };
|
||||
if (quem.donoId === quem.usuarioId) return { tipo: "nenhuma" };
|
||||
return { tipo: "avisa_quem_atende", donoNome: quem.donoNome };
|
||||
}
|
||||
|
||||
const TITULO_PADRAO = "Por que a IA passou para você";
|
||||
const TITULO_OPT_OUT = "O cliente pode ter pedido para parar de receber mensagens";
|
||||
|
||||
/**
|
||||
* Monta os cartões de UMA conversa, em ordem cronológica.
|
||||
*
|
||||
* A ordenação é feita aqui e não confiada à rota: o cartão entra no fio da
|
||||
* conversa, que é cronológico, e um cartão fora de ordem diria que a IA passou a
|
||||
* conversa depois de já ter passado.
|
||||
*/
|
||||
export function montarCartoesDaPassagem(
|
||||
passagens: readonly PassagemDaConversa[],
|
||||
quem: QuemOlha,
|
||||
): CartaoDaPassagem[] {
|
||||
const ordenadas = [...passagens].sort(
|
||||
(a, b) => new Date(a.criado_em).getTime() - new Date(b.criado_em).getTime(),
|
||||
);
|
||||
|
||||
return ordenadas.map((p, i) => {
|
||||
const ultima = i === ordenadas.length - 1;
|
||||
const anonimizada = foiAnonimizada(p);
|
||||
const optOut = p.motivo_codigo === "suspected_optout";
|
||||
const estado = estadoDe(p);
|
||||
const corpo = texto(p.body);
|
||||
const semContexto = anonimizada || corpo === null || corpo === PISO_DO_BRIEFING.trim();
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
criadoEm: p.criado_em,
|
||||
estado,
|
||||
anonimizada,
|
||||
optOut,
|
||||
recolhido: !ultima,
|
||||
titulo: optOut ? TITULO_OPT_OUT : TITULO_PADRAO,
|
||||
motivo: FRASE_DO_MOTIVO[p.motivo_codigo],
|
||||
clienteQuer: anonimizada ? null : texto(p.title),
|
||||
resumo: semContexto ? null : corpo,
|
||||
semContexto,
|
||||
falaDoCliente: anonimizada ? null : texto(p.notes),
|
||||
textoDeQuemPassou: anonimizada ? null : texto(p.content),
|
||||
tentativas: anonimizada ? [] : tentativasLegiveis(p.tentativas),
|
||||
aviso:
|
||||
anonimizada || p.cliente_avisado === null
|
||||
? null
|
||||
: {
|
||||
avisado: p.cliente_avisado,
|
||||
frase: p.cliente_avisado
|
||||
? null
|
||||
: p.aviso_motivo_codigo === null
|
||||
? null
|
||||
: FRASE_DO_MOTIVO_DO_AVISO[p.aviso_motivo_codigo],
|
||||
},
|
||||
assumidaPor: estado === "reconhecida" ? (p.reconhecido_por_nome ?? null) : null,
|
||||
acao: acaoDe({ estado, anonimizada, optOut, ultima, quem }),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -54,7 +54,7 @@ import type { Idioma } from "@/lib/i18n/idiomas";
|
||||
import { TETOS_DO_TEXTO_DO_LEAD, sanitizarTextoDoLead } from "./sanitizar-texto-do-lead";
|
||||
|
||||
/** O rótulo que a cascata de LGPD grava no nome do contato anonimizado. */
|
||||
const ROTULO_DE_ANONIMIZADO = "Cliente Anonimizado";
|
||||
export const ROTULO_DE_ANONIMIZADO = "Cliente Anonimizado";
|
||||
|
||||
export interface AvisoDeCaso {
|
||||
/** O nome da marca já resolvido por `marcaDaSaida(orgId)`. */
|
||||
|
||||
@@ -9501,6 +9501,58 @@ export const DICIONARIO: Traducoes = {
|
||||
"O cliente NÃO foi avisado": { es: "El cliente NO fue avisado" },
|
||||
"ele está esperando sem saber.": { es: "está esperando sin saberlo." },
|
||||
"motivo desconhecido": { es: "motivo desconocido" },
|
||||
// O CARTÃO da passagem, dentro da conversa (`components/inbox/PassagemCard.tsx`).
|
||||
// Os dois títulos são resolvidos por `montarCartoesDaPassagem` e chegam à tela
|
||||
// como variável — o gate de i18n só enxerga literal, então quem os cobra é
|
||||
// `tests/unit/cartao-da-passagem.test.ts`. Acrescentadas no FIM do bloco.
|
||||
"Por que a IA passou para você": { es: "Por qué la IA te pasó la conversación" },
|
||||
"O cliente pode ter pedido para parar de receber mensagens":
|
||||
{ es: "El cliente puede haber pedido dejar de recibir mensajes" },
|
||||
"Este contato foi anonimizado a pedido dele. O contexto desta passagem foi apagado.":
|
||||
{ es: "Este contacto fue anonimizado a pedido suyo. El contexto de este traspaso fue borrado." },
|
||||
"O cliente quer": { es: "El cliente quiere" },
|
||||
"A IA já tentou": { es: "La IA ya intentó" },
|
||||
"Últimas palavras do cliente": { es: "Últimas palabras del cliente" },
|
||||
"Resumo da IA (confira)": { es: "Resumen de la IA (verifique)" },
|
||||
"Escrito por quem passou": { es: "Escrito por quien la pasó" },
|
||||
"Sem resumo acumulado ainda — a conversa é recente. Role para cima para ver tudo o que foi dito.":
|
||||
{ es: "Aún no hay resumen acumulado — la conversación es reciente. Desplácese hacia arriba para ver todo lo que se dijo." },
|
||||
"O cliente já foi avisado de que uma pessoa vai assumir.":
|
||||
{ es: "El cliente ya fue avisado de que una persona va a asumir." },
|
||||
"O cliente NÃO foi avisado — ele está esperando sem saber.":
|
||||
{ es: "El cliente NO fue avisado — está esperando sin saberlo." },
|
||||
"Alguém da equipe já assumiu este atendimento.":
|
||||
{ es: "Alguien del equipo ya asumió esta atención." },
|
||||
"Assumida por": { es: "Asumida por" },
|
||||
"Atendimento devolvido ao automático — ninguém assumiu.":
|
||||
{ es: "Atención devuelta al automático — nadie la asumió." },
|
||||
"Assumindo...": { es: "Asumiendo..." },
|
||||
"Assumir e responder": { es: "Asumir y responder" },
|
||||
"Abrir o contato para confirmar o bloqueio":
|
||||
{ es: "Abrir el contacto para confirmar el bloqueo" },
|
||||
"Confirme na ficha do contato se ele pediu para não receber mais mensagens.":
|
||||
{ es: "Confirme en la ficha del contacto si pidió no recibir más mensajes." },
|
||||
"Outra pessoa está atendendo. Se precisar assumir, use Transferir no topo da conversa.":
|
||||
{ es: "Otra persona está atendiendo. Si necesita asumir, use Transferir en la parte superior de la conversación." },
|
||||
"está atendendo. Se precisar assumir, use Transferir no topo da conversa.":
|
||||
{ es: "está atendiendo. Si necesita asumir, use Transferir en la parte superior de la conversación." },
|
||||
// A cobrança da passagem esquecida (`app/api/v1/cron/case-stale-watcher`).
|
||||
// Traduzida no SERVIDOR, no insert, pela mesma razão do corpo do aviso acima.
|
||||
"Alguém pediu atendimento e ninguém assumiu":
|
||||
{ es: "Alguien pidió atención y nadie la asumió" },
|
||||
"A IA passou esta conversa para uma pessoa e ninguém assumiu desde então. Abra a conversa: o contexto do que já foi dito está lá.":
|
||||
{ es: "La IA pasó esta conversación a una persona y nadie la asumió desde entonces. Abra la conversación: el contexto de lo que ya se dijo está allí." },
|
||||
"Este é o último aviso automático sobre esta conversa.":
|
||||
{ es: "Este es el último aviso automático sobre esta conversación." },
|
||||
"Clientes que repetiram depois da passagem":
|
||||
{ es: "Clientes que repitieron después del traspaso" },
|
||||
"passagens em que o cliente voltou a falar: ele teve de repetir o que já tinha dito.":
|
||||
{ es: "traspasos en que el cliente volvió a hablar: tuvo que repetir lo que ya había dicho." },
|
||||
hora: { es: "hora" },
|
||||
"Abra a conversa: o cartão no fim do fio diz por que a IA passou, o que ela já tentou e se o cliente foi avisado.":
|
||||
{ es: "Abre la conversación: la tarjeta al final del hilo dice por qué la IA la pasó, qué ya intentó y si el cliente fue avisado." },
|
||||
"Limiar de 0,7 e janela de 24h. Quem atende em `visibility_mode='own'` vê só as conversas dele.":
|
||||
{ es: "Umbral de 0,7 y ventana de 24h. Quien atiende en `visibility_mode='own'` ve solo sus conversaciones." },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,14 @@ export interface AtritoRaw {
|
||||
esperas_caladas: number;
|
||||
esperas_medidas: number;
|
||||
espera_resposta_p90_s: number | null;
|
||||
/**
|
||||
* O LAÇO DE RETORNO DA PASSAGEM (migration 0294). Numerador e denominador
|
||||
* SEPARADOS, e não uma razão pronta: só assim a borda consegue distinguir
|
||||
* "ninguém repetiu" (0 de 10) de "ninguém voltou a falar" (0 de 0, que é
|
||||
* ausência de dado e sai como `—`).
|
||||
*/
|
||||
repeticao_pos_passagem: number;
|
||||
passagens_medidas: number;
|
||||
};
|
||||
empresa: {
|
||||
intervencoes_por_demanda: number | null;
|
||||
@@ -254,6 +262,22 @@ export function montarPares(
|
||||
unidade: "razao",
|
||||
nota: t("O time respondeu pelo celular, contornando a ferramenta."),
|
||||
},
|
||||
// ─── O LAÇO DE RETORNO DA PASSAGEM (invariante 7) ─────────────────
|
||||
// Encostada em `pedidos_de_humano` de propósito: aquela medida conta
|
||||
// QUANTAS vezes a IA desistiu; esta diz se a desistência custou caro à
|
||||
// pessoa do outro lado. A pergunta que ela responde é a única que mede
|
||||
// se o cartão da passagem serviu para alguma coisa — se o briefing
|
||||
// chegou a quem assumiu, a repetição cai; se não chegou, ela não muda.
|
||||
{
|
||||
chave: "repeticao_pos_passagem",
|
||||
rotulo: t("Clientes que repetiram depois da passagem"),
|
||||
valor: razao(cliente.repeticao_pos_passagem, cliente.passagens_medidas),
|
||||
unidade: "razao",
|
||||
// A régua viaja com o número (doutrina §3.4 regra 4), e junto vai a
|
||||
// ressalva de escopo: `fn_atrito_metrics` é SECURITY INVOKER, então
|
||||
// dois papéis veem números diferentes de boa-fé.
|
||||
nota: `${cliente.repeticao_pos_passagem} ${t("de")} ${cliente.passagens_medidas} ${t("passagens em que o cliente voltou a falar: ele teve de repetir o que já tinha dito.")} ${t("Limiar de 0,7 e janela de 24h. Quem atende em `visibility_mode='own'` vê só as conversas dele.")}`,
|
||||
},
|
||||
// O agente respondeu — e a pessoa teve de perguntar de novo. É o dano
|
||||
// direto da automação: responder não é resolver.
|
||||
{
|
||||
|
||||
@@ -202,9 +202,25 @@ async function main(): Promise<void> {
|
||||
orgId,
|
||||
contatoId,
|
||||
]);
|
||||
// OS DOIS `ref_kind`, e não só o novo. O aviso de passagem passou a nascer com
|
||||
// `ref_kind='conversation'` (é o que dá o botão "Abrir conversa" na Central);
|
||||
// um `delete` que só olhasse `'contact'` viraria NO-OP e o item sobreviveria
|
||||
// entre corridas — e a asserção "a segunda passagem nasce" passaria POR SOBRA,
|
||||
// que é exatamente o defeito que este bloco de reset existe para impedir.
|
||||
// Trocar em vez de somar teria o mesmo problema ao contrário: item de clone
|
||||
// antigo continua gravado com `'contact'`.
|
||||
await pool.query(
|
||||
`delete from agent_inbox_items where organization_id = $1 and ref_kind = 'contact' and ref_id = $2`,
|
||||
[orgId, contatoId],
|
||||
`delete from agent_inbox_items
|
||||
where organization_id = $1
|
||||
and ((ref_kind = 'contact' and ref_id = $2) or (ref_kind = 'conversation' and ref_id = $3))`,
|
||||
[orgId, contatoId, conversaId],
|
||||
);
|
||||
// A passagem em si também entra no reset: ela é o FATO, e um fato de ontem na
|
||||
// tela de hoje faria o cartão aparecer antes de esta corrida ter produzido
|
||||
// passagem nenhuma.
|
||||
await pool.query(
|
||||
`delete from passagens_de_atendimento where organization_id = $1 and conversation_id = $2`,
|
||||
[orgId, conversaId],
|
||||
);
|
||||
// As atividades TAMBÉM entram no reset, e antes de `performHumanHandoff`.
|
||||
// Sem isto o E2E passaria com sobra da corrida anterior: a asserção "a volta
|
||||
|
||||
@@ -29995,6 +29995,373 @@ $$;
|
||||
revoke all on function public.fn_passagem_devolvida(uuid, uuid) from public, anon;
|
||||
grant execute on function public.fn_passagem_devolvida(uuid, uuid) to authenticated, service_role;
|
||||
|
||||
-- ---- o cliente repetiu depois da passagem + o contador do cobrador (migration 0294) ----
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 0294 — O LAÇO DE RETORNO DA PASSAGEM, e o contador que cala o cobrador.
|
||||
--
|
||||
-- ─── O que esta migration responde ────────────────────────────────────────
|
||||
--
|
||||
-- A entrega da passagem (0291, 0293) fez a IA gravar POR QUE parou, o que já
|
||||
-- tentou e o que o cliente quer, e pôs esse texto na frente de quem assume. A
|
||||
-- pergunta que ela ainda não respondia é a única que diz se aquilo serviu para
|
||||
-- alguma coisa: **depois de a IA passar a conversa, o cliente precisou repetir
|
||||
-- o que já tinha dito?**
|
||||
--
|
||||
-- Se o briefing chegou, a repetição cai. Se não chegou, ela não muda — e a
|
||||
-- feature é decoração cara. Sem este número, a única evidência de sucesso seria
|
||||
-- alguém achar o cartão bonito.
|
||||
--
|
||||
-- ─── POR QUE NÃO UMA TABELA DE MÉTRICA, NEM UM PAINEL NOVO ────────────────
|
||||
--
|
||||
-- O instrumento já existe e está calibrado: `fn_atrito_jaccard(a,b)` (0135),
|
||||
-- `immutable`, com limiar `p_repeticao_min` default 0.7 escolhido para zero
|
||||
-- falso positivo. `fn_atrito_metrics` já o usa para a repergunta dentro do
|
||||
-- Índice de Atrito, já roda SECURITY INVOKER (logo a RLS da tabela nova vale) e
|
||||
-- já devolve `jsonb` — onde acrescentar chave não quebra leitor antigo. Uma
|
||||
-- tabela de agregado seria um número sincronizado por cron onde cabe uma
|
||||
-- consulta, que é o anti-pattern nº 5 do CLAUDE.md.
|
||||
--
|
||||
-- ─── AS DUAS CHAVES, E POR QUE SÃO DUAS ──────────────────────────────────
|
||||
--
|
||||
-- `repeticao_pos_passagem` — numerador: passagens em que o cliente repetiu;
|
||||
-- `passagens_medidas` — denominador: passagens em que ele voltou a falar.
|
||||
--
|
||||
-- A razão NÃO é calculada aqui. Ela é montada em `lib/metrics/atrito.ts`, que é
|
||||
-- onde mora a regra "denominador zero devolve null, nunca 0". Uma razão
|
||||
-- calculada no SQL devolveria `0/0` como `null` por acaso e `0/1` como `0` por
|
||||
-- acidente — e a tela não teria como distinguir "ninguém repetiu" de "ninguém
|
||||
-- voltou a falar". Publicar os dois números é o que torna a régua auditável por
|
||||
-- quem lê a tela, não só por quem lê este arquivo.
|
||||
--
|
||||
-- ⚠️ A RESSALVA QUE VIAJA COM O NÚMERO (e que a tela publica na `nota`):
|
||||
-- `fn_atrito_metrics` é **SECURITY INVOKER**. Um `agent` numa organização em
|
||||
-- `visibility_mode='own'` enxerga o número só das conversas dele; `manager` e
|
||||
-- `admin` enxergam o da organização. Dois papéis veem números diferentes de
|
||||
-- boa-fé, e quem comparar sem saber disso vai achar que um deles está errado.
|
||||
--
|
||||
-- ─── A SEGUNDA COISA: `cobrancas` ────────────────────────────────────────
|
||||
--
|
||||
-- O reconhecimento da passagem (0293) só acontece por gesto de quem CHEGOU.
|
||||
-- Ninguém cobra a passagem em que ninguém chegou — e dos treze caminhos que
|
||||
-- passam conversa para uma pessoa, UM nasce de caso, então o
|
||||
-- `case-stale-watcher` não alcança os outros doze nem por acidente. A população
|
||||
-- já foi medida num CRM em produção com o mesmo desenho de fila (2026-09-14):
|
||||
-- 22 pedidos parados, o mais antigo há 17,6 dias, e ONZE deles eram gente
|
||||
-- pedindo para falar com uma pessoa.
|
||||
--
|
||||
-- O conserto é um segundo braço no MESMO cron, e ele precisa de um lugar para
|
||||
-- guardar quantas vezes já cobrou — senão o alarme nunca cala, e alarme que
|
||||
-- nunca cala treina a equipe a ignorar o alarme certo. `agent_cases` tem
|
||||
-- `followup_attempts` para isso; `passagens_de_atendimento` não tinha nada.
|
||||
--
|
||||
-- DIRC, respondida: Duplicar — não, o contador é desta linha e de mais nada;
|
||||
-- Integrar — não há de onde vir (o aviso da Central não conta tentativa);
|
||||
-- Referenciar — não é ponteiro; Calcular — não dá: a cobrança não deixa rastro
|
||||
-- próprio em lugar nenhum, e inferi-la por idade cobraria de novo o que já foi
|
||||
-- cobrado três vezes.
|
||||
--
|
||||
-- ─── REAPLICAÇÃO ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- `add column if not exists` com default (aplicável a tabela COM linhas) e
|
||||
-- `create or replace function` com a MESMA assinatura. O `update.sh` de um
|
||||
-- clone reaplica sem erro e sem duplicar efeito. Nenhuma constraint nova sobre
|
||||
-- dados existentes ⇒ não há deduplicação prévia a fazer (regra 8 da doutrina).
|
||||
--
|
||||
-- O corpo de `fn_atrito_metrics` abaixo é DERIVADO do corpo vigente por script
|
||||
-- (`scratchpad/onda11/montar-0294.py`), nunca redigitado: duas edições, as duas
|
||||
-- provadas reversíveis ao byte antes de o arquivo ser escrito.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Quantas vezes o vigia já cobrou ESTA passagem. Teto de 3 no chamador
|
||||
-- (`app/api/v1/cron/case-stale-watcher/route.ts`), pelo mesmo argumento de
|
||||
-- `agent_cases.followup_attempts`: quem ignorou três vezes não atende no quarto.
|
||||
alter table public.passagens_de_atendimento
|
||||
add column if not exists cobrancas int not null default 0;
|
||||
|
||||
|
||||
drop function if exists public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int);
|
||||
|
||||
create or replace function public.fn_atrito_metrics(
|
||||
p_org uuid,
|
||||
p_from timestamptz,
|
||||
p_to timestamptz,
|
||||
p_abandono_horas int default 72,
|
||||
p_repeticao_min float8 default 0.7,
|
||||
p_espera_horas int default 4
|
||||
) returns jsonb
|
||||
language sql stable
|
||||
set search_path = public
|
||||
as $$
|
||||
with
|
||||
-- DENOMINADOR DEFINITIVO: demandas encerradas na janela. Não mais os casos.
|
||||
demandas_j as (
|
||||
select d.id, d.agent_case_id, d.aberta_em, d.fechada_em, d.desfecho
|
||||
from public.demandas d
|
||||
where d.organization_id = p_org
|
||||
and d.fechada_em is not null
|
||||
and d.fechada_em >= p_from
|
||||
and d.fechada_em < p_to
|
||||
),
|
||||
-- Turnos: mensagens de TODAS as conversas da demanda (N:N), dentro da vida
|
||||
-- dela. Uma demanda que atravessou dois canais soma os dois.
|
||||
turnos as (
|
||||
select d.id,
|
||||
(select count(*)
|
||||
from public.demanda_conversas dc
|
||||
join public.messages m
|
||||
on m.conversation_id = dc.conversation_id
|
||||
and m.organization_id = p_org
|
||||
and m.sent_at >= d.aberta_em
|
||||
and m.sent_at < d.fechada_em
|
||||
where dc.demanda_id = d.id) as n
|
||||
from demandas_j d
|
||||
),
|
||||
-- Insistência: só existe onde houve caso. O payload declara o denominador
|
||||
-- próprio (`demandas_com_caso`) para o número não ser lido como se fosse
|
||||
-- sobre o total.
|
||||
insistencia as (
|
||||
select avg(c.followup_attempts)::float8 as media,
|
||||
max(c.followup_attempts) as maximo,
|
||||
count(*) as base
|
||||
from demandas_j d
|
||||
join public.agent_cases c on c.id = d.agent_case_id
|
||||
),
|
||||
humano as (
|
||||
select e.case_id, count(*) as intervencoes, min(e.created_at) as primeiro_toque
|
||||
from public.agent_case_events e
|
||||
join demandas_j d on d.agent_case_id = e.case_id
|
||||
where e.organization_id = p_org and e.actor_kind = 'human'
|
||||
group by e.case_id
|
||||
),
|
||||
espera_fila as (
|
||||
select extract(epoch from (h.primeiro_toque - d.aberta_em)) as segundos
|
||||
from demandas_j d join humano h on h.case_id = d.agent_case_id
|
||||
where h.primeiro_toque > d.aberta_em
|
||||
),
|
||||
retrabalho as (
|
||||
select count(distinct e.case_id) as n
|
||||
from public.agent_case_events e
|
||||
join demandas_j d on d.agent_case_id = e.case_id
|
||||
where e.organization_id = p_org
|
||||
and (e.kind = 'escalated' or e.human_action = 'escalate')
|
||||
),
|
||||
abandono as (
|
||||
select
|
||||
count(*) filter (
|
||||
where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to
|
||||
and (cv.last_inbound_at is null or cv.last_outbound_at > cv.last_inbound_at)
|
||||
and cv.last_outbound_at < now() - make_interval(hours => p_abandono_horas)
|
||||
and cv.status not in ('resolved', 'closed')
|
||||
) as abandonadas,
|
||||
count(*) filter (
|
||||
where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to
|
||||
) as com_fala_nossa
|
||||
from public.conversations cv
|
||||
where cv.organization_id = p_org and cv.last_outbound_at is not null
|
||||
),
|
||||
-- INVARIANTE 4, agora VERIFICÁVEL: demanda aberta sem próximo passo é o
|
||||
-- vazamento que a doutrina proíbe. Antes da 0119 isto não era enumerável.
|
||||
sem_proximo_passo as (
|
||||
select count(*) as n
|
||||
from public.demandas d
|
||||
where d.organization_id = p_org
|
||||
and d.fechada_em is null
|
||||
and d.proximo_passo is null
|
||||
),
|
||||
demandas_abertas as (
|
||||
select count(*) as n from public.demandas d
|
||||
where d.organization_id = p_org and d.fechada_em is null
|
||||
),
|
||||
inbounds as (
|
||||
select m.conversation_id, m.sent_at, m.body,
|
||||
lag(m.body) over (partition by m.conversation_id order by m.sent_at) as body_anterior,
|
||||
lag(m.sent_at) over (partition by m.conversation_id order by m.sent_at) as sent_at_anterior
|
||||
from public.messages m
|
||||
where m.organization_id = p_org and m.direction = 'inbound' and m.body is not null
|
||||
and m.sent_at >= p_from and m.sent_at < p_to
|
||||
),
|
||||
repeticao as (
|
||||
select
|
||||
count(*) filter (
|
||||
where i.body_anterior is not null
|
||||
and exists (select 1 from public.messages o
|
||||
where o.organization_id = p_org and o.conversation_id = i.conversation_id
|
||||
and o.direction = 'outbound'
|
||||
and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at)
|
||||
and public.fn_atrito_jaccard(i.body, i.body_anterior) >= p_repeticao_min
|
||||
) as repetidas,
|
||||
count(*) filter (
|
||||
where i.body_anterior is not null
|
||||
and exists (select 1 from public.messages o
|
||||
where o.organization_id = p_org and o.conversation_id = i.conversation_id
|
||||
and o.direction = 'outbound'
|
||||
and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at)
|
||||
) as com_resposta_no_meio
|
||||
from inbounds i
|
||||
),
|
||||
espera_calada as (
|
||||
select count(*) filter (where prox.espera_s > p_espera_horas * 3600) as caladas,
|
||||
count(*) as com_resposta,
|
||||
percentile_cont(0.9) within group (order by prox.espera_s) as p90_s
|
||||
from (
|
||||
select extract(epoch from (
|
||||
(select min(o.sent_at) from public.messages o
|
||||
where o.organization_id = p_org and o.conversation_id = m.conversation_id
|
||||
and o.direction = 'outbound' and o.sent_at > m.sent_at) - m.sent_at)) as espera_s
|
||||
from public.messages m
|
||||
where m.organization_id = p_org and m.direction = 'inbound'
|
||||
and m.sent_at >= p_from and m.sent_at < p_to
|
||||
) prox
|
||||
where prox.espera_s is not null
|
||||
),
|
||||
envios as (
|
||||
select count(*) filter (where m.sent_via = 'ai') as por_ia,
|
||||
count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema,
|
||||
count(*) filter (where m.sent_via = 'external_device') as por_humano_fora
|
||||
from public.messages m
|
||||
where m.organization_id = p_org and m.direction = 'outbound'
|
||||
and m.sent_at >= p_from and m.sent_at < p_to
|
||||
),
|
||||
vetos as (
|
||||
select count(*) filter (where t.vetoed_gate is not null) as vetados,
|
||||
count(distinct t.job_id) as execucoes
|
||||
from public.before_send_traces t
|
||||
where t.organization_id = p_org and t.created_at >= p_from and t.created_at < p_to
|
||||
),
|
||||
descadastros as (
|
||||
select count(*) as n from public.contacts c
|
||||
where c.organization_id = p_org and c.blocked_at is not null
|
||||
and c.blocked_at >= p_from and c.blocked_at < p_to
|
||||
),
|
||||
pedidos_humano as (
|
||||
select count(*) as n from public.crm_lead_activities a
|
||||
where a.organization_id = p_org and a.type = 'handoff_triggered'
|
||||
and a.performed_at >= p_from and a.performed_at < p_to
|
||||
),
|
||||
-- ─── O LAÇO DE RETORNO DA PASSAGEM (migration 0294) ──────────────────────
|
||||
-- A pergunta que mede se o briefing serviu para alguma coisa: DEPOIS de a IA
|
||||
-- passar a conversa, o cliente precisou repetir o que já tinha dito? Se o
|
||||
-- contexto chegou a quem assumiu, a repetição cai; se não chegou, ela não
|
||||
-- muda — e a feature é decoração.
|
||||
--
|
||||
-- A RÉGUA, escrita para o número não envelhecer:
|
||||
-- · limiar = `p_repeticao_min` (0.7), o MESMO do índice de
|
||||
-- repergunta — dois limiares para o mesmo fenômeno
|
||||
-- fariam dois números incomparáveis na mesma tela;
|
||||
-- · janela = 24 h depois da passagem. Mais que isso já é outra
|
||||
-- conversa; menos deixaria de fora o atendente que
|
||||
-- assumiu no dia seguinte;
|
||||
-- · denominador = passagens em que o cliente VOLTOU A FALAR. Sem fala
|
||||
-- nova não há repetição a medir, e contá-las como "não
|
||||
-- repetiu" inflaria o número para o lado bonito. É a
|
||||
-- mesma regra de `lib/metrics/atrito.ts`: ausência de
|
||||
-- dado é `null`, nunca `0` — e é a razão de as DUAS
|
||||
-- chaves saírem daqui (numerador e denominador), em vez
|
||||
-- de uma razão já calculada.
|
||||
repeticao_pos_passagem as (
|
||||
select count(*) filter (where r.repetiu) as repetidas,
|
||||
count(*) as medidas
|
||||
from (
|
||||
select p.id,
|
||||
exists (
|
||||
select 1
|
||||
from public.messages depois
|
||||
join public.messages antes
|
||||
on antes.organization_id = depois.organization_id
|
||||
and antes.conversation_id = depois.conversation_id
|
||||
and antes.direction = 'inbound'
|
||||
and antes.body is not null
|
||||
and antes.sent_at < p.criado_em
|
||||
where depois.organization_id = p.organization_id
|
||||
and depois.conversation_id = p.conversation_id
|
||||
and depois.direction = 'inbound'
|
||||
and depois.body is not null
|
||||
and depois.sent_at > p.criado_em
|
||||
and depois.sent_at < p.criado_em + interval '24 hours'
|
||||
and public.fn_atrito_jaccard(depois.body, antes.body) >= p_repeticao_min
|
||||
) as repetiu
|
||||
from public.passagens_de_atendimento p
|
||||
where p.organization_id = p_org
|
||||
and p.criado_em >= p_from and p.criado_em < p_to
|
||||
and exists (
|
||||
select 1 from public.messages m
|
||||
where m.organization_id = p.organization_id
|
||||
and m.conversation_id = p.conversation_id
|
||||
and m.direction = 'inbound'
|
||||
and m.body is not null
|
||||
and m.sent_at > p.criado_em
|
||||
and m.sent_at < p.criado_em + interval '24 hours'
|
||||
)
|
||||
) r
|
||||
),
|
||||
eficiencia as (
|
||||
select count(*) filter (where status = 'won') as ganhos,
|
||||
count(*) filter (where status = 'lost') as perdidos
|
||||
from public.crm_leads
|
||||
where organization_id = p_org and status in ('won', 'lost')
|
||||
and closed_at >= p_from and closed_at < p_to
|
||||
)
|
||||
select jsonb_build_object(
|
||||
'escopo', jsonb_build_object(
|
||||
'demandas', (select count(*) from demandas_j),
|
||||
'demandas_com_caso', (select base from insistencia),
|
||||
'demandas_abertas', (select n from demandas_abertas),
|
||||
'de', p_from, 'ate', p_to,
|
||||
'abandono_horas', p_abandono_horas,
|
||||
'repeticao_min', p_repeticao_min,
|
||||
'espera_horas', p_espera_horas,
|
||||
-- Marca a régua do denominador: quem comparar dois períodos precisa saber
|
||||
-- se foram medidos sobre casos ou sobre demandas.
|
||||
'denominador', 'demandas'
|
||||
),
|
||||
'cliente', jsonb_build_object(
|
||||
'turnos_p50', (select percentile_cont(0.5) within group (order by n) from turnos),
|
||||
'turnos_p90', (select percentile_cont(0.9) within group (order by n) from turnos),
|
||||
'insistencia_media', (select media from insistencia),
|
||||
'insistencia_max', (select maximo from insistencia),
|
||||
'pedidos_de_humano', (select n from pedidos_humano),
|
||||
'descadastros', (select n from descadastros),
|
||||
'abandonos', (select abandonadas from abandono),
|
||||
'conversas_com_fala_nossa', (select com_fala_nossa from abandono),
|
||||
'reperguntas', (select repetidas from repeticao),
|
||||
'perguntas_com_resposta', (select com_resposta_no_meio from repeticao),
|
||||
'esperas_caladas', (select caladas from espera_calada),
|
||||
'esperas_medidas', (select com_resposta from espera_calada),
|
||||
'espera_resposta_p90_s', (select p90_s from espera_calada),
|
||||
-- As duas chaves do laço da passagem (0294). Numerador e denominador
|
||||
-- SEPARADOS de propósito: a razão é calculada na borda, que é onde
|
||||
-- mora a regra de devolver `null` quando o denominador é zero.
|
||||
'repeticao_pos_passagem', (select repetidas from repeticao_pos_passagem),
|
||||
'passagens_medidas', (select medidas from repeticao_pos_passagem)
|
||||
),
|
||||
'empresa', jsonb_build_object(
|
||||
'intervencoes_por_demanda', (select avg(coalesce(h.intervencoes, 0))::float8
|
||||
from demandas_j d left join humano h on h.case_id = d.agent_case_id),
|
||||
'espera_humana_p50_s', (select percentile_cont(0.5) within group (order by segundos) from espera_fila),
|
||||
'espera_humana_p90_s', (select percentile_cont(0.9) within group (order by segundos) from espera_fila),
|
||||
'retrabalho', (select n from retrabalho),
|
||||
'vetos', (select vetados from vetos),
|
||||
'execucoes_medidas', (select execucoes from vetos),
|
||||
'envios_por_ia', (select por_ia from envios),
|
||||
'envios_humano_no_sistema', (select por_humano_no_sistema from envios),
|
||||
'envios_humano_fora', (select por_humano_fora from envios),
|
||||
-- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é
|
||||
-- vazamento, e vazamento invisível é o que a doutrina inteira combate.
|
||||
'demandas_sem_proximo_passo', (select n from sem_proximo_passo)
|
||||
),
|
||||
'eficiencia', jsonb_build_object(
|
||||
'ganhos', (select ganhos from eficiencia),
|
||||
'perdidos', (select perdidos from eficiencia)
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
revoke all on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from public;
|
||||
revoke execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from anon;
|
||||
grant execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int)
|
||||
to authenticated, service_role;
|
||||
|
||||
|
||||
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
|
||||
--
|
||||
-- ⚠️ DE PROPÓSITO, NENHUMA FUNÇÃO É CRIADA DEPOIS DESTE BLOCO. Apêndice que cria
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 0294 — O LAÇO DE RETORNO DA PASSAGEM, e o contador que cala o cobrador.
|
||||
--
|
||||
-- ─── O que esta migration responde ────────────────────────────────────────
|
||||
--
|
||||
-- A entrega da passagem (0291, 0293) fez a IA gravar POR QUE parou, o que já
|
||||
-- tentou e o que o cliente quer, e pôs esse texto na frente de quem assume. A
|
||||
-- pergunta que ela ainda não respondia é a única que diz se aquilo serviu para
|
||||
-- alguma coisa: **depois de a IA passar a conversa, o cliente precisou repetir
|
||||
-- o que já tinha dito?**
|
||||
--
|
||||
-- Se o briefing chegou, a repetição cai. Se não chegou, ela não muda — e a
|
||||
-- feature é decoração cara. Sem este número, a única evidência de sucesso seria
|
||||
-- alguém achar o cartão bonito.
|
||||
--
|
||||
-- ─── POR QUE NÃO UMA TABELA DE MÉTRICA, NEM UM PAINEL NOVO ────────────────
|
||||
--
|
||||
-- O instrumento já existe e está calibrado: `fn_atrito_jaccard(a,b)` (0135),
|
||||
-- `immutable`, com limiar `p_repeticao_min` default 0.7 escolhido para zero
|
||||
-- falso positivo. `fn_atrito_metrics` já o usa para a repergunta dentro do
|
||||
-- Índice de Atrito, já roda SECURITY INVOKER (logo a RLS da tabela nova vale) e
|
||||
-- já devolve `jsonb` — onde acrescentar chave não quebra leitor antigo. Uma
|
||||
-- tabela de agregado seria um número sincronizado por cron onde cabe uma
|
||||
-- consulta, que é o anti-pattern nº 5 do CLAUDE.md.
|
||||
--
|
||||
-- ─── AS DUAS CHAVES, E POR QUE SÃO DUAS ──────────────────────────────────
|
||||
--
|
||||
-- `repeticao_pos_passagem` — numerador: passagens em que o cliente repetiu;
|
||||
-- `passagens_medidas` — denominador: passagens em que ele voltou a falar.
|
||||
--
|
||||
-- A razão NÃO é calculada aqui. Ela é montada em `lib/metrics/atrito.ts`, que é
|
||||
-- onde mora a regra "denominador zero devolve null, nunca 0". Uma razão
|
||||
-- calculada no SQL devolveria `0/0` como `null` por acaso e `0/1` como `0` por
|
||||
-- acidente — e a tela não teria como distinguir "ninguém repetiu" de "ninguém
|
||||
-- voltou a falar". Publicar os dois números é o que torna a régua auditável por
|
||||
-- quem lê a tela, não só por quem lê este arquivo.
|
||||
--
|
||||
-- ⚠️ A RESSALVA QUE VIAJA COM O NÚMERO (e que a tela publica na `nota`):
|
||||
-- `fn_atrito_metrics` é **SECURITY INVOKER**. Um `agent` numa organização em
|
||||
-- `visibility_mode='own'` enxerga o número só das conversas dele; `manager` e
|
||||
-- `admin` enxergam o da organização. Dois papéis veem números diferentes de
|
||||
-- boa-fé, e quem comparar sem saber disso vai achar que um deles está errado.
|
||||
--
|
||||
-- ─── A SEGUNDA COISA: `cobrancas` ────────────────────────────────────────
|
||||
--
|
||||
-- O reconhecimento da passagem (0293) só acontece por gesto de quem CHEGOU.
|
||||
-- Ninguém cobra a passagem em que ninguém chegou — e dos treze caminhos que
|
||||
-- passam conversa para uma pessoa, UM nasce de caso, então o
|
||||
-- `case-stale-watcher` não alcança os outros doze nem por acidente. A população
|
||||
-- já foi medida num CRM em produção com o mesmo desenho de fila (2026-09-14):
|
||||
-- 22 pedidos parados, o mais antigo há 17,6 dias, e ONZE deles eram gente
|
||||
-- pedindo para falar com uma pessoa.
|
||||
--
|
||||
-- O conserto é um segundo braço no MESMO cron, e ele precisa de um lugar para
|
||||
-- guardar quantas vezes já cobrou — senão o alarme nunca cala, e alarme que
|
||||
-- nunca cala treina a equipe a ignorar o alarme certo. `agent_cases` tem
|
||||
-- `followup_attempts` para isso; `passagens_de_atendimento` não tinha nada.
|
||||
--
|
||||
-- DIRC, respondida: Duplicar — não, o contador é desta linha e de mais nada;
|
||||
-- Integrar — não há de onde vir (o aviso da Central não conta tentativa);
|
||||
-- Referenciar — não é ponteiro; Calcular — não dá: a cobrança não deixa rastro
|
||||
-- próprio em lugar nenhum, e inferi-la por idade cobraria de novo o que já foi
|
||||
-- cobrado três vezes.
|
||||
--
|
||||
-- ─── REAPLICAÇÃO ─────────────────────────────────────────────────────────
|
||||
--
|
||||
-- `add column if not exists` com default (aplicável a tabela COM linhas) e
|
||||
-- `create or replace function` com a MESMA assinatura. O `update.sh` de um
|
||||
-- clone reaplica sem erro e sem duplicar efeito. Nenhuma constraint nova sobre
|
||||
-- dados existentes ⇒ não há deduplicação prévia a fazer (regra 8 da doutrina).
|
||||
--
|
||||
-- O corpo de `fn_atrito_metrics` abaixo é DERIVADO do corpo vigente por script
|
||||
-- (`scratchpad/onda11/montar-0294.py`), nunca redigitado: duas edições, as duas
|
||||
-- provadas reversíveis ao byte antes de o arquivo ser escrito.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Quantas vezes o vigia já cobrou ESTA passagem. Teto de 3 no chamador
|
||||
-- (`app/api/v1/cron/case-stale-watcher/route.ts`), pelo mesmo argumento de
|
||||
-- `agent_cases.followup_attempts`: quem ignorou três vezes não atende no quarto.
|
||||
alter table public.passagens_de_atendimento
|
||||
add column if not exists cobrancas int not null default 0;
|
||||
|
||||
|
||||
drop function if exists public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int);
|
||||
|
||||
create or replace function public.fn_atrito_metrics(
|
||||
p_org uuid,
|
||||
p_from timestamptz,
|
||||
p_to timestamptz,
|
||||
p_abandono_horas int default 72,
|
||||
p_repeticao_min float8 default 0.7,
|
||||
p_espera_horas int default 4
|
||||
) returns jsonb
|
||||
language sql stable
|
||||
set search_path = public
|
||||
as $$
|
||||
with
|
||||
-- DENOMINADOR DEFINITIVO: demandas encerradas na janela. Não mais os casos.
|
||||
demandas_j as (
|
||||
select d.id, d.agent_case_id, d.aberta_em, d.fechada_em, d.desfecho
|
||||
from public.demandas d
|
||||
where d.organization_id = p_org
|
||||
and d.fechada_em is not null
|
||||
and d.fechada_em >= p_from
|
||||
and d.fechada_em < p_to
|
||||
),
|
||||
-- Turnos: mensagens de TODAS as conversas da demanda (N:N), dentro da vida
|
||||
-- dela. Uma demanda que atravessou dois canais soma os dois.
|
||||
turnos as (
|
||||
select d.id,
|
||||
(select count(*)
|
||||
from public.demanda_conversas dc
|
||||
join public.messages m
|
||||
on m.conversation_id = dc.conversation_id
|
||||
and m.organization_id = p_org
|
||||
and m.sent_at >= d.aberta_em
|
||||
and m.sent_at < d.fechada_em
|
||||
where dc.demanda_id = d.id) as n
|
||||
from demandas_j d
|
||||
),
|
||||
-- Insistência: só existe onde houve caso. O payload declara o denominador
|
||||
-- próprio (`demandas_com_caso`) para o número não ser lido como se fosse
|
||||
-- sobre o total.
|
||||
insistencia as (
|
||||
select avg(c.followup_attempts)::float8 as media,
|
||||
max(c.followup_attempts) as maximo,
|
||||
count(*) as base
|
||||
from demandas_j d
|
||||
join public.agent_cases c on c.id = d.agent_case_id
|
||||
),
|
||||
humano as (
|
||||
select e.case_id, count(*) as intervencoes, min(e.created_at) as primeiro_toque
|
||||
from public.agent_case_events e
|
||||
join demandas_j d on d.agent_case_id = e.case_id
|
||||
where e.organization_id = p_org and e.actor_kind = 'human'
|
||||
group by e.case_id
|
||||
),
|
||||
espera_fila as (
|
||||
select extract(epoch from (h.primeiro_toque - d.aberta_em)) as segundos
|
||||
from demandas_j d join humano h on h.case_id = d.agent_case_id
|
||||
where h.primeiro_toque > d.aberta_em
|
||||
),
|
||||
retrabalho as (
|
||||
select count(distinct e.case_id) as n
|
||||
from public.agent_case_events e
|
||||
join demandas_j d on d.agent_case_id = e.case_id
|
||||
where e.organization_id = p_org
|
||||
and (e.kind = 'escalated' or e.human_action = 'escalate')
|
||||
),
|
||||
abandono as (
|
||||
select
|
||||
count(*) filter (
|
||||
where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to
|
||||
and (cv.last_inbound_at is null or cv.last_outbound_at > cv.last_inbound_at)
|
||||
and cv.last_outbound_at < now() - make_interval(hours => p_abandono_horas)
|
||||
and cv.status not in ('resolved', 'closed')
|
||||
) as abandonadas,
|
||||
count(*) filter (
|
||||
where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to
|
||||
) as com_fala_nossa
|
||||
from public.conversations cv
|
||||
where cv.organization_id = p_org and cv.last_outbound_at is not null
|
||||
),
|
||||
-- INVARIANTE 4, agora VERIFICÁVEL: demanda aberta sem próximo passo é o
|
||||
-- vazamento que a doutrina proíbe. Antes da 0119 isto não era enumerável.
|
||||
sem_proximo_passo as (
|
||||
select count(*) as n
|
||||
from public.demandas d
|
||||
where d.organization_id = p_org
|
||||
and d.fechada_em is null
|
||||
and d.proximo_passo is null
|
||||
),
|
||||
demandas_abertas as (
|
||||
select count(*) as n from public.demandas d
|
||||
where d.organization_id = p_org and d.fechada_em is null
|
||||
),
|
||||
inbounds as (
|
||||
select m.conversation_id, m.sent_at, m.body,
|
||||
lag(m.body) over (partition by m.conversation_id order by m.sent_at) as body_anterior,
|
||||
lag(m.sent_at) over (partition by m.conversation_id order by m.sent_at) as sent_at_anterior
|
||||
from public.messages m
|
||||
where m.organization_id = p_org and m.direction = 'inbound' and m.body is not null
|
||||
and m.sent_at >= p_from and m.sent_at < p_to
|
||||
),
|
||||
repeticao as (
|
||||
select
|
||||
count(*) filter (
|
||||
where i.body_anterior is not null
|
||||
and exists (select 1 from public.messages o
|
||||
where o.organization_id = p_org and o.conversation_id = i.conversation_id
|
||||
and o.direction = 'outbound'
|
||||
and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at)
|
||||
and public.fn_atrito_jaccard(i.body, i.body_anterior) >= p_repeticao_min
|
||||
) as repetidas,
|
||||
count(*) filter (
|
||||
where i.body_anterior is not null
|
||||
and exists (select 1 from public.messages o
|
||||
where o.organization_id = p_org and o.conversation_id = i.conversation_id
|
||||
and o.direction = 'outbound'
|
||||
and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at)
|
||||
) as com_resposta_no_meio
|
||||
from inbounds i
|
||||
),
|
||||
espera_calada as (
|
||||
select count(*) filter (where prox.espera_s > p_espera_horas * 3600) as caladas,
|
||||
count(*) as com_resposta,
|
||||
percentile_cont(0.9) within group (order by prox.espera_s) as p90_s
|
||||
from (
|
||||
select extract(epoch from (
|
||||
(select min(o.sent_at) from public.messages o
|
||||
where o.organization_id = p_org and o.conversation_id = m.conversation_id
|
||||
and o.direction = 'outbound' and o.sent_at > m.sent_at) - m.sent_at)) as espera_s
|
||||
from public.messages m
|
||||
where m.organization_id = p_org and m.direction = 'inbound'
|
||||
and m.sent_at >= p_from and m.sent_at < p_to
|
||||
) prox
|
||||
where prox.espera_s is not null
|
||||
),
|
||||
envios as (
|
||||
select count(*) filter (where m.sent_via = 'ai') as por_ia,
|
||||
count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema,
|
||||
count(*) filter (where m.sent_via = 'external_device') as por_humano_fora
|
||||
from public.messages m
|
||||
where m.organization_id = p_org and m.direction = 'outbound'
|
||||
and m.sent_at >= p_from and m.sent_at < p_to
|
||||
),
|
||||
vetos as (
|
||||
select count(*) filter (where t.vetoed_gate is not null) as vetados,
|
||||
count(distinct t.job_id) as execucoes
|
||||
from public.before_send_traces t
|
||||
where t.organization_id = p_org and t.created_at >= p_from and t.created_at < p_to
|
||||
),
|
||||
descadastros as (
|
||||
select count(*) as n from public.contacts c
|
||||
where c.organization_id = p_org and c.blocked_at is not null
|
||||
and c.blocked_at >= p_from and c.blocked_at < p_to
|
||||
),
|
||||
pedidos_humano as (
|
||||
select count(*) as n from public.crm_lead_activities a
|
||||
where a.organization_id = p_org and a.type = 'handoff_triggered'
|
||||
and a.performed_at >= p_from and a.performed_at < p_to
|
||||
),
|
||||
-- ─── O LAÇO DE RETORNO DA PASSAGEM (migration 0294) ──────────────────────
|
||||
-- A pergunta que mede se o briefing serviu para alguma coisa: DEPOIS de a IA
|
||||
-- passar a conversa, o cliente precisou repetir o que já tinha dito? Se o
|
||||
-- contexto chegou a quem assumiu, a repetição cai; se não chegou, ela não
|
||||
-- muda — e a feature é decoração.
|
||||
--
|
||||
-- A RÉGUA, escrita para o número não envelhecer:
|
||||
-- · limiar = `p_repeticao_min` (0.7), o MESMO do índice de
|
||||
-- repergunta — dois limiares para o mesmo fenômeno
|
||||
-- fariam dois números incomparáveis na mesma tela;
|
||||
-- · janela = 24 h depois da passagem. Mais que isso já é outra
|
||||
-- conversa; menos deixaria de fora o atendente que
|
||||
-- assumiu no dia seguinte;
|
||||
-- · denominador = passagens em que o cliente VOLTOU A FALAR. Sem fala
|
||||
-- nova não há repetição a medir, e contá-las como "não
|
||||
-- repetiu" inflaria o número para o lado bonito. É a
|
||||
-- mesma regra de `lib/metrics/atrito.ts`: ausência de
|
||||
-- dado é `null`, nunca `0` — e é a razão de as DUAS
|
||||
-- chaves saírem daqui (numerador e denominador), em vez
|
||||
-- de uma razão já calculada.
|
||||
repeticao_pos_passagem as (
|
||||
select count(*) filter (where r.repetiu) as repetidas,
|
||||
count(*) as medidas
|
||||
from (
|
||||
select p.id,
|
||||
exists (
|
||||
select 1
|
||||
from public.messages depois
|
||||
join public.messages antes
|
||||
on antes.organization_id = depois.organization_id
|
||||
and antes.conversation_id = depois.conversation_id
|
||||
and antes.direction = 'inbound'
|
||||
and antes.body is not null
|
||||
and antes.sent_at < p.criado_em
|
||||
where depois.organization_id = p.organization_id
|
||||
and depois.conversation_id = p.conversation_id
|
||||
and depois.direction = 'inbound'
|
||||
and depois.body is not null
|
||||
and depois.sent_at > p.criado_em
|
||||
and depois.sent_at < p.criado_em + interval '24 hours'
|
||||
and public.fn_atrito_jaccard(depois.body, antes.body) >= p_repeticao_min
|
||||
) as repetiu
|
||||
from public.passagens_de_atendimento p
|
||||
where p.organization_id = p_org
|
||||
and p.criado_em >= p_from and p.criado_em < p_to
|
||||
and exists (
|
||||
select 1 from public.messages m
|
||||
where m.organization_id = p.organization_id
|
||||
and m.conversation_id = p.conversation_id
|
||||
and m.direction = 'inbound'
|
||||
and m.body is not null
|
||||
and m.sent_at > p.criado_em
|
||||
and m.sent_at < p.criado_em + interval '24 hours'
|
||||
)
|
||||
) r
|
||||
),
|
||||
eficiencia as (
|
||||
select count(*) filter (where status = 'won') as ganhos,
|
||||
count(*) filter (where status = 'lost') as perdidos
|
||||
from public.crm_leads
|
||||
where organization_id = p_org and status in ('won', 'lost')
|
||||
and closed_at >= p_from and closed_at < p_to
|
||||
)
|
||||
select jsonb_build_object(
|
||||
'escopo', jsonb_build_object(
|
||||
'demandas', (select count(*) from demandas_j),
|
||||
'demandas_com_caso', (select base from insistencia),
|
||||
'demandas_abertas', (select n from demandas_abertas),
|
||||
'de', p_from, 'ate', p_to,
|
||||
'abandono_horas', p_abandono_horas,
|
||||
'repeticao_min', p_repeticao_min,
|
||||
'espera_horas', p_espera_horas,
|
||||
-- Marca a régua do denominador: quem comparar dois períodos precisa saber
|
||||
-- se foram medidos sobre casos ou sobre demandas.
|
||||
'denominador', 'demandas'
|
||||
),
|
||||
'cliente', jsonb_build_object(
|
||||
'turnos_p50', (select percentile_cont(0.5) within group (order by n) from turnos),
|
||||
'turnos_p90', (select percentile_cont(0.9) within group (order by n) from turnos),
|
||||
'insistencia_media', (select media from insistencia),
|
||||
'insistencia_max', (select maximo from insistencia),
|
||||
'pedidos_de_humano', (select n from pedidos_humano),
|
||||
'descadastros', (select n from descadastros),
|
||||
'abandonos', (select abandonadas from abandono),
|
||||
'conversas_com_fala_nossa', (select com_fala_nossa from abandono),
|
||||
'reperguntas', (select repetidas from repeticao),
|
||||
'perguntas_com_resposta', (select com_resposta_no_meio from repeticao),
|
||||
'esperas_caladas', (select caladas from espera_calada),
|
||||
'esperas_medidas', (select com_resposta from espera_calada),
|
||||
'espera_resposta_p90_s', (select p90_s from espera_calada),
|
||||
-- As duas chaves do laço da passagem (0294). Numerador e denominador
|
||||
-- SEPARADOS de propósito: a razão é calculada na borda, que é onde
|
||||
-- mora a regra de devolver `null` quando o denominador é zero.
|
||||
'repeticao_pos_passagem', (select repetidas from repeticao_pos_passagem),
|
||||
'passagens_medidas', (select medidas from repeticao_pos_passagem)
|
||||
),
|
||||
'empresa', jsonb_build_object(
|
||||
'intervencoes_por_demanda', (select avg(coalesce(h.intervencoes, 0))::float8
|
||||
from demandas_j d left join humano h on h.case_id = d.agent_case_id),
|
||||
'espera_humana_p50_s', (select percentile_cont(0.5) within group (order by segundos) from espera_fila),
|
||||
'espera_humana_p90_s', (select percentile_cont(0.9) within group (order by segundos) from espera_fila),
|
||||
'retrabalho', (select n from retrabalho),
|
||||
'vetos', (select vetados from vetos),
|
||||
'execucoes_medidas', (select execucoes from vetos),
|
||||
'envios_por_ia', (select por_ia from envios),
|
||||
'envios_humano_no_sistema', (select por_humano_no_sistema from envios),
|
||||
'envios_humano_fora', (select por_humano_fora from envios),
|
||||
-- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é
|
||||
-- vazamento, e vazamento invisível é o que a doutrina inteira combate.
|
||||
'demandas_sem_proximo_passo', (select n from sem_proximo_passo)
|
||||
),
|
||||
'eficiencia', jsonb_build_object(
|
||||
'ganhos', (select ganhos from eficiencia),
|
||||
'perdidos', (select perdidos from eficiencia)
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
revoke all on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from public;
|
||||
revoke execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from anon;
|
||||
grant execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int)
|
||||
to authenticated, service_role;
|
||||
@@ -329,3 +329,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
|
||||
| `20260918110000` | `0291_passagem_para_humano_tem_registro` | **Toda passagem do atendimento automático para uma pessoa vira uma linha de fato.** Cria `passagens_de_atendimento` — uma linha por episódio, com o motivo (vocabulário fechado, traduzido na tela por `lib/escalacao/passagem.ts` → `FRASE_DO_MOTIVO`), o que a IA já tentou (`tentativas` jsonb, validado por Zod ANTES do insert), o que ela entendeu que o cliente quer (`title`), a narrativa que quem assume lê (`body`), as PALAVRAS LITERAIS do cliente (`notes`), o texto livre de quem passou (`content`), a VERDADE sobre o aviso ao cliente (`cliente_avisado` + `aviso_motivo_codigo`, também fechado) e o par de reconhecimento. Hoje nada disso sobrevive ao turno: o motor A monta um resumo que morre no corpo do aviso da Central e o motor B abre o aviso SEM resumo nenhum — quem assume relê a conversa inteira e o cliente repete o que já disse. **As quatro colunas de texto se chamam `title`/`body`/`notes`/`content` DE PROPÓSITO**, e há `contact_id` com FK: são as duas condições que `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` exige para enxergar a tabela — com `resumo`/`motivo_texto`/`cliente_quer` (o desenho anterior) ela nasceria INVISÍVEL ao gate e a cobertura dependeria de um invariante comportamental que uma sessão futura pode apagar com o gate de classe verde. `caso_id` é `on delete set null` e não `cascade`: apagar o caso não pode apagar o fato de a conversa ter ido para uma pessoa. RLS `for select` com TRÊS condições — organização, `fn_role_at_least('agent')` e `fn_can_view_conversation` (é por isso que `conversation_id` mora dentro) —, `revoke all from anon, authenticated` **antes** do `grant select`, e ZERO caminho de escrita pelo PostgREST: uma passagem forjada por um membro diria que a IA desistiu de um atendimento que ela nunca tocou. Os CHECK são criados inline E redeclarados por `drop constraint if exists` + `add` com o MESMO nome que o Postgres dá ao inline — auto-cura sem duplicar, porque duas constraints definindo o mesmo vocabulário fazem `vocabulario-banco-x-typescript.test.ts` se RECUSAR a medir. `fn_expurgar_passagens_vencidas` (1825 dias, piso de 90 **no corpo**) entra no cron diário de retenção como sexta poda e **só apaga passagem JÁ RECONHECIDA**: passagem aberta é alguém esperando resposta, e apagá-la por idade seria o expurgo virando esquecedor de pendência. A cascata de LGPD ganha o passo da tabela (`body` é `not null` e recebe o RÓTULO, como `voice_calls.peer_phone`) e `conversations.last_handoff_reason = null` dentro do passo 2 que já visita as mesmas linhas — o corpo é o VIGENTE derivado por script, com as duas edições provadas reversíveis byte a byte. `lib/lgpd/export-collector.ts` ganha o bloco pareado, porque `tests/unit/lgpd-exporta-o-que-redige.test.ts` reprova quem redige e não exporta. **Ninguém escreve nesta tabela ainda**: os 13 call sites dos dois motores, o reconhecimento automático por `fn_conversation_assign` e o cartão na conversa são das ondas seguintes. Gates: `tests/invariants/passagem-isolamento-e-visibilidade.test.ts`, `lgpd-cascata-alcanca-a-passagem.test.ts`, `passagem-retencao.test.ts`, e as três listas nomeadas (`rls-isolation` → `TABLES`, `vocabulario-banco-x-typescript` → quatro pares, `cascata-lgpd-nao-encolhe` → a catraca). Número `0291` e não `0281`: as ondas anteriores consumiram `0279`–`0281` — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*'`. |
|
||||
| `20260918120000` | `0292_aviso_de_caso_no_whatsapp` | **O WhatsApp da equipe passa a ser avisado quando a IA abre um caso — e o aviso tem registro de entrega.** Cria `config_aviso_de_caso` (UMA linha por organização: para onde mandar, por qual conexão, ligado ou não, mais os dois contadores do descarte) e `entregas_de_aviso_de_caso` (UMA linha por organização+caso+destino). É a unique dessa segunda que dá a IDEMPOTÊNCIA: o dreno do `event_log` reentrega o mesmo evento em retry e há três drenos em processos diferentes — sem ela a equipe receberia o mesmo aviso uma vez por tentativa. **O texto do aviso NUNCA é guardado** (só `corpo_hash`, precedente `send_ledger.body_hash`): guardá-lo seria uma segunda cópia do relato do cliente numa tabela que a cascata teria de aprender a redigir. A única coluna capaz de ecoar um dado pessoal é `erro_detalhe` (o texto cru do transporte), e é ela que a cascata zera. **NÃO existe `check (ligado = false or channel_session_id is not null)`**, e a ausência é deliberada: `on delete set null` é um UPDATE, o CHECK seria reavaliado e VIOLARIA com `ligado=true`, abortando o DELETE INTEIRO da conexão — a rota de exclusão de canal devolveria 500 com mensagem de constraint, sem pista de que a causa está em outra tela. A coerência é do trigger `trg_aviso_de_caso_coerente`, que se autocura (canal nulo ⇒ `ligado` cai para `false`). Escrita da configuração só por `fn_definir_aviso_de_caso` (`security definer`, quatro guardas na mesma transação: `admin`, escrita de suporte liberada, MFA comprovada quando há fator, e o canal sendo da própria organização), que ainda recusa o número da PRÓPRIA organização (o laço robô-com-robô) e, sem `p_confirma_contato`, o número que já é de um CLIENTE — porque configurá-lo faz as mensagens daquela pessoa pararem de chegar ao CRM. O casamento usa as DUAS grafias do nono dígito, a mesma regra de `lib/channels/phone-variants.ts`. Mais duas funções só para o servidor: `fn_registrar_jid_do_aviso` (o handler grava o JID que o transporte resolveu — é o que faz o corte da ingestão valer para destinatário em modo privacidade) e `fn_contar_mensagem_ignorada` (os contadores do descarte). Nenhuma das duas toca `updated_at`, que responde 'alguém MEXEU na configuração': uma resposta do suporte não é alguém mexendo na configuração. Leitura por RLS `for select`: configuração é `admin` (quem configura conexão é admin), histórico é `manager` ('o aviso está saindo?' é pergunta de quem opera). Nenhuma policy `for all` ⇒ as duas nascem fora da consulta `cmd='ALL'` do gate de RBAC. `fn_expurgar_avisos_de_caso_vencidos` (180 dias, piso de 30 **no corpo**) entra no cron diário de retenção como sétima poda. Dois vocabulários crescem: `agent_case_events.kind` += `alert_sent` e `agent_inbox_items.kind` += `aviso_de_caso_nao_entregue`. Na CADEIA as duas constraints são reconstruídas com a lista INTEIRA (`tests/unit/kind-check-migration-x-baseline.test.ts` exige igualdade valor a valor com o baseline); no `baseline.sql` o valor entra no bloco ÚNICO já existente, porque um segundo bloco é o defeito da issue #159. A cascata de LGPD ganha o passo de `entregas_de_aviso_de_caso` (`erro_detalhe = null`) e o kind novo dentro do `in (...)` do passo de `agent_inbox_items` — corpo VIGENTE derivado por script, com as duas edições provadas reversíveis byte a byte. **Ponto cego declarado:** `tests/invariants/lgpd-cascata-alcanca-quem-guarda-pessoa.test.ts` só cobra tabela com FK para `contacts` E coluna de nome-de-PII; nenhuma das duas satisfaz, então o gate ficaria VERDE sem aquele passo. Ele entra porque é certo, e quem o vigia é a catraca `tests/invariants/cascata-lgpd-nao-encolhe.test.ts`. Para ver o vocabulário em vigor sem acreditar nesta linha: `grep -n "agent_inbox_items_kind_check check" -A40 supabase/baseline.sql`. Número `0292` e não o `0280` do plano: `0279`–`0282`, `0290` e `0291` estão tomados — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail`. |
|
||||
| `20260918130000` | `0293_a_passagem_se_reconhece_sozinha` | **O aviso de passagem para humano se resolve sozinho quando alguém assume — e a passagem fica marcada como reconhecida.** A `0291` criou `passagens_de_atendimento.reconhecido_por/_em` e NINGUÉM os escrevia; sem escritor, o cartão da conversa nunca sai de "esperando alguém assumir", o aviso da Central fica aberto para sempre e — o pior — como o aviso deduplica por episódio ABERTO, a PRÓXIMA passagem daquela conversa não abre aviso nenhum: o cliente pede um atendente de novo e ninguém é avisado. Além disso `fn_expurgar_passagens_vencidas` só apaga linha reconhecida (é o certo: passagem aberta é demanda viva), então a tabela nunca seria podada. Cria `fn_passagem_reconhecida()` + `trg_passagem_reconhecida` em `conversation_assignment_events`: **UM gatilho cobre os cinco caminhos** que trocam o dono de uma conversa (assumir, transferir, liberar, devolver, rodízio), porque todos passam por `fn_conversation_assign`, que insere a linha de auditoria na MESMA transação — e cobre o sexto que alguém escrever amanhã, sem tocar em rota nenhuma. SQL puro: **nenhum HTTP dentro de trigger** (anti-pattern nº 9). Ele traz uma SEGUNDA camada de guarda além do `revoke insert` que a `0279` já pôs na tabela de eventos: só reconhece quando `conversations.assigned_to_user_id is not distinct from new.to_user_id`, ou seja, quando a conversa REALMENTE está com aquele dono — uma linha de auditoria incoerente escrita com a service key marcaria como assumida uma passagem que ninguém assumiu, e o aviso sumiria da lista de quem precisa agir. `to_user_id is null` (release/devolução) NÃO marca: quem fecha esse episódio é `fn_passagem_devolvida(uuid,uuid)`, também criada aqui, que grava `reconhecido_em` SEM `reconhecido_por` — o par que a `0291` documentou como "devolvida ao automático: ninguém assumiu, mas o episódio fechou" e que o CHECK `passagens_reconhecimento_coerente` permite só nesse sentido. Ela é `security definer` porque a policy da tabela é `for select` apenas (`authenticated` não tem `update`, de propósito: ninguém reescreve um fato) e porque abrir o client de serviço dentro de uma rota quando há molde de definer no repositório é privilégio a mais sem necessidade — a autorização mora NO CORPO, no padrão de `fn_conversation_assign` (`auth.uid() is not null and not fn_role_at_least(org,'agent')` ⇒ `raise`). As duas funções revogam de `public, anon` (as DUAS origens de EXECUTE) e a devolvida concede a `authenticated, service_role`. Chamada por `lib/escalacao/retomada.ts`, que serve a rota "devolver ao automático" E a tool MCP — um lugar só. Gate: `tests/invariants/passagem-se-reconhece-sozinha.test.ts`. Número `0293` e não o `0282` do plano: `0279`–`0283` e `0290`–`0292` estão tomados — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail`. |
|
||||
| `20260918140000` | `0294_o_cliente_repetiu_depois_da_passagem` | **O laço de retorno da passagem vira número, e o cobrador ganha onde contar.** A entrega de 0291/0293 pôs o contexto da passagem na frente de quem assume; faltava a medida que diz se aquilo serviu: *depois de a IA passar a conversa, o cliente precisou repetir o que já tinha dito?* `fn_atrito_metrics` ganha DUAS chaves em `cliente` — `repeticao_pos_passagem` (numerador: passagens em que o cliente repetiu) e `passagens_medidas` (denominador: passagens em que ele VOLTOU A FALAR). A razão NÃO é calculada no SQL: ela é montada em `lib/metrics/atrito.ts`, que é onde mora a regra *ausência de dado é `null`, nunca `0`* — publicar os dois números separados é o que permite distinguir "ninguém repetiu" de "ninguém voltou a falar", e o que torna a régua auditável por quem lê a tela. **A régua é declarada e fixa**: limiar `p_repeticao_min` (0.7, o MESMO do índice de repergunta — dois limiares para o mesmo fenômeno dariam dois números incomparáveis na mesma tela) e janela de 24 h depois da passagem. O instrumento já existia (`fn_atrito_jaccard`, 0135, `immutable`); nenhuma tabela de agregado nasce, porque número sincronizado por cron onde cabe uma consulta é o anti-pattern nº 5. ⚠️ `fn_atrito_metrics` é **SECURITY INVOKER**: um `agent` em `visibility_mode='own'` vê o número só das conversas dele, `manager`/`admin` veem o da organização — a ressalva viaja na `nota` da medida, junto do número. A segunda coisa é `passagens_de_atendimento.cobrancas int not null default 0`: o reconhecimento de 0293 só acontece por gesto de quem CHEGOU, e ninguém cobrava a passagem em que ninguém chegou (dos treze caminhos, UM nasce de caso — o `case-stale-watcher` não alcançava os outros doze nem por acidente; a população foi medida num CRM em produção com o mesmo desenho de fila: 22 pedidos parados, o mais antigo há 17,6 dias, onze deles gente pedindo para falar com uma pessoa). O segundo braço do MESMO cron reabre/eleva o aviso `handoff` da conversa e para no terceiro aviso, e é `cobrancas` que segura esse teto — sem contador o alarme nunca cala, e alarme que nunca cala treina a equipe a ignorar o alarme certo. Sem tabela nova, sem `kind` novo, sem evento novo. Corpo de `fn_atrito_metrics` DERIVADO do vigente por script, com as duas edições provadas reversíveis byte a byte. Gates: `tests/invariants/atrito-repeticao-pos-passagem.test.ts`, `tests/unit/cobrador-de-passagem-nao-reconhecida.test.ts`, `tests/unit/atrito-par-eficiencia-dano.test.ts`. Número `0294` e não o `0282` do plano: `0279`–`0283` e `0290`–`0293` estão tomados — medido sobre todas as refs com `git log --all --full-history --name-only --pretty=format: -- 'supabase/migrations/*' | grep -oE '_0[0-9]{3}_' | tr -d _ | sort -u | tail`. |
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { GOV_ORG, GOV_MANAGER, GOV_SESSION, lastLine, seedGov, sql } from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* O LAÇO DE RETORNO DA PASSAGEM — invariante 7 do sistema vivo (migration 0294).
|
||||
*
|
||||
* ## A pergunta que este arquivo guarda
|
||||
*
|
||||
* *Depois de a IA passar a conversa, o cliente precisou repetir o que já tinha
|
||||
* dito?* É a única medida que diz se o cartão da passagem serviu para alguma
|
||||
* coisa: se o briefing chegou a quem assumiu, a repetição cai; se não chegou,
|
||||
* ela não muda — e a feature é decoração cara.
|
||||
*
|
||||
* ## Por que aqui e não num unitário
|
||||
*
|
||||
* O número sai de uma agregação SQL que cruza `passagens_de_atendimento` com
|
||||
* `messages` duas vezes (antes e depois) e chama `fn_atrito_jaccard`. Um dublê
|
||||
* provaria que a rota chama a função; só o Postgres prova que a agregação
|
||||
* agrega — e o `tsc` não vigia nome de RPC (medido: um `rpc("fn_inexistente")`
|
||||
* passa no `--noEmit` sem uma linha de erro).
|
||||
*
|
||||
* ## A RÉGUA, que é fixa e é o que torna o número comparável
|
||||
*
|
||||
* · limiar = `p_repeticao_min` (0.7), o MESMO do índice de repergunta;
|
||||
* · janela = 24 h depois da passagem;
|
||||
* · denominador = passagens em que o cliente VOLTOU A FALAR.
|
||||
*
|
||||
* O último item é o que impede o número bonito: contar como "não repetiu" a
|
||||
* passagem em que ninguém voltou a falar infla a medida para o lado que agrada.
|
||||
* Ausência de dado é `null` (calculado na borda, a partir do denominador zero),
|
||||
* nunca `0`.
|
||||
*
|
||||
* ⚠️ Sem crase nesta prosa nem nos comentários SQL abaixo: o bloco inteiro é um
|
||||
* template literal de JS, e uma crase dentro dele derruba o `tsc` com TS1005 —
|
||||
* lição já paga duas vezes nesta entrega.
|
||||
*/
|
||||
|
||||
const ORG_VIZ = "b1b10000-0000-4000-8000-000000000001";
|
||||
const SESSION_VIZ = "b1b10000-0000-4000-8000-0000000000ff";
|
||||
|
||||
/** Um contato por conversa: conversations tem unique (org, contato, sessao). */
|
||||
const CT_REPETIU = "b1b11111-0000-4000-8000-000000000001";
|
||||
const CT_MUDOU = "b1b11111-0000-4000-8000-000000000002";
|
||||
const CT_CALADO = "b1b11111-0000-4000-8000-000000000003";
|
||||
const CT_TARDE = "b1b11111-0000-4000-8000-000000000004";
|
||||
const CT_VIZ = "b1b11111-0000-4000-8000-000000000005";
|
||||
|
||||
const CV_REPETIU = "b1b12222-0000-4000-8000-000000000001";
|
||||
const CV_MUDOU = "b1b12222-0000-4000-8000-000000000002";
|
||||
const CV_CALADO = "b1b12222-0000-4000-8000-000000000003";
|
||||
const CV_TARDE = "b1b12222-0000-4000-8000-000000000004";
|
||||
const CV_VIZ = "b1b12222-0000-4000-8000-000000000005";
|
||||
|
||||
/** Janela FIXA — nada depende de now(), então a fixture não envelhece. */
|
||||
const DE = "2026-05-01T00:00:00Z";
|
||||
const ATE = "2026-06-01T00:00:00Z";
|
||||
const PASSAGEM_EM = "2026-05-10T12:00:00Z";
|
||||
|
||||
const FALA_ORIGINAL = "preciso trocar o produto que comprou com defeito na semana passada";
|
||||
/** Quase igual: Jaccard acima de 0.7. */
|
||||
const FALA_REPETIDA = "preciso trocar o produto que comprou com defeito na semana passada mesmo";
|
||||
/** Outro vocabulário sobre o mesmo tema: abaixo do limiar, de propósito. */
|
||||
const FALA_DIFERENTE = "qual horario voces abrem amanha";
|
||||
|
||||
function atritoComo(userId: string, org: string): Record<string, Record<string, number | null>> {
|
||||
const out = sql(`
|
||||
set role authenticated;
|
||||
select set_config('request.jwt.claims', '{"sub":"${userId}"}', false);
|
||||
select public.fn_atrito_metrics('${org}'::uuid, '${DE}'::timestamptz, '${ATE}'::timestamptz);
|
||||
`);
|
||||
return JSON.parse(lastLine(out));
|
||||
}
|
||||
|
||||
/**
|
||||
* Semeia UMA conversa com fala antes da passagem, a passagem, e (talvez) uma
|
||||
* fala depois. Tudo em texto, para a semente ser lida como a história que ela é.
|
||||
*/
|
||||
function cenario(input: {
|
||||
contato: string;
|
||||
conversa: string;
|
||||
nome: string;
|
||||
falaDepois: string | null;
|
||||
depoisEm?: string;
|
||||
}): string {
|
||||
const depois =
|
||||
input.falaDepois === null
|
||||
? ""
|
||||
: `insert into public.messages
|
||||
(organization_id, conversation_id, channel_session_id, contact_id, type,
|
||||
direction, status, sent_via, body, sent_at)
|
||||
values ('${GOV_ORG}', '${input.conversa}', '${GOV_SESSION}', '${input.contato}', 'text',
|
||||
'inbound', 'received', 'ai', '${input.falaDepois}',
|
||||
'${input.depoisEm ?? "2026-05-10T13:00:00Z"}');`;
|
||||
return `
|
||||
insert into public.contacts (id, organization_id, display_name)
|
||||
values ('${input.contato}', '${GOV_ORG}', '${input.nome}');
|
||||
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
|
||||
values ('${input.conversa}', '${GOV_ORG}', '${input.contato}', '${GOV_SESSION}', 'open');
|
||||
-- A fala ANTES: e o cliente teve resposta no meio, senao ela nao seria o que
|
||||
-- a passagem deixou para tras.
|
||||
insert into public.messages
|
||||
(organization_id, conversation_id, channel_session_id, contact_id, type,
|
||||
direction, status, sent_via, body, sent_at)
|
||||
values ('${GOV_ORG}', '${input.conversa}', '${GOV_SESSION}', '${input.contato}', 'text',
|
||||
'inbound', 'received', 'ai', '${FALA_ORIGINAL}', '2026-05-10T11:00:00Z');
|
||||
insert into public.passagens_de_atendimento
|
||||
(organization_id, contact_id, conversation_id, motor, origem, motivo_codigo, body, criado_em)
|
||||
values ('${GOV_ORG}', '${input.contato}', '${input.conversa}', 'engine', 'pedido_explicito',
|
||||
'requested_human', 'a narrativa da passagem', '${PASSAGEM_EM}');
|
||||
${depois}
|
||||
`;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
// Limpeza antes, e na ordem inversa das FKs: fixture antiga de outra corrida
|
||||
// mascara o que esta mede.
|
||||
sql(`
|
||||
delete from public.passagens_de_atendimento
|
||||
where conversation_id in ('${CV_REPETIU}','${CV_MUDOU}','${CV_CALADO}','${CV_TARDE}','${CV_VIZ}');
|
||||
delete from public.messages
|
||||
where conversation_id in ('${CV_REPETIU}','${CV_MUDOU}','${CV_CALADO}','${CV_TARDE}','${CV_VIZ}');
|
||||
delete from public.conversations
|
||||
where id in ('${CV_REPETIU}','${CV_MUDOU}','${CV_CALADO}','${CV_TARDE}','${CV_VIZ}');
|
||||
delete from public.contacts
|
||||
where id in ('${CT_REPETIU}','${CT_MUDOU}','${CT_CALADO}','${CT_TARDE}','${CT_VIZ}');
|
||||
delete from public.channel_sessions where id = '${SESSION_VIZ}';
|
||||
delete from public.organizations where id = '${ORG_VIZ}';
|
||||
`);
|
||||
|
||||
sql(
|
||||
cenario({
|
||||
contato: CT_REPETIU,
|
||||
conversa: CV_REPETIU,
|
||||
nome: "Repetiu",
|
||||
falaDepois: FALA_REPETIDA,
|
||||
}),
|
||||
);
|
||||
sql(
|
||||
cenario({
|
||||
contato: CT_MUDOU,
|
||||
conversa: CV_MUDOU,
|
||||
nome: "Mudou de assunto",
|
||||
falaDepois: FALA_DIFERENTE,
|
||||
}),
|
||||
);
|
||||
sql(cenario({ contato: CT_CALADO, conversa: CV_CALADO, nome: "Calado", falaDepois: null }));
|
||||
// Fala 30 h depois: fora da janela de 24 h. Sem este cenario, alargar a janela
|
||||
// no SQL passaria despercebido.
|
||||
sql(
|
||||
cenario({
|
||||
contato: CT_TARDE,
|
||||
conversa: CV_TARDE,
|
||||
nome: "Voltou tarde",
|
||||
falaDepois: FALA_REPETIDA,
|
||||
depoisEm: "2026-05-11T18:00:00Z",
|
||||
}),
|
||||
);
|
||||
|
||||
// A VIZINHA, com o cenario que MAIS conta: se o escopo vazar, o numero dela
|
||||
// aparece no painel desta organizacao.
|
||||
sql(`
|
||||
insert into public.organizations (id, slug, legal_name, display_name)
|
||||
values ('${ORG_VIZ}', 'atrito-passagem-viz', 'Vizinha Passagem', 'Vizinha');
|
||||
insert into public.channel_sessions (id, organization_id, waha_session_name, webhook_secret_encrypted)
|
||||
values ('${SESSION_VIZ}', '${ORG_VIZ}', 'atrito-passagem-viz', '\\x00'::bytea);
|
||||
insert into public.contacts (id, organization_id, display_name)
|
||||
values ('${CT_VIZ}', '${ORG_VIZ}', 'Contato Vizinho');
|
||||
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)
|
||||
values ('${CV_VIZ}', '${ORG_VIZ}', '${CT_VIZ}', '${SESSION_VIZ}', 'open');
|
||||
insert into public.messages
|
||||
(organization_id, conversation_id, channel_session_id, contact_id, type,
|
||||
direction, status, sent_via, body, sent_at)
|
||||
values ('${ORG_VIZ}', '${CV_VIZ}', '${SESSION_VIZ}', '${CT_VIZ}', 'text',
|
||||
'inbound', 'received', 'ai', '${FALA_ORIGINAL}', '2026-05-10T11:00:00Z');
|
||||
insert into public.messages
|
||||
(organization_id, conversation_id, channel_session_id, contact_id, type,
|
||||
direction, status, sent_via, body, sent_at)
|
||||
values ('${ORG_VIZ}', '${CV_VIZ}', '${SESSION_VIZ}', '${CT_VIZ}', 'text',
|
||||
'inbound', 'received', 'ai', '${FALA_REPETIDA}', '2026-05-10T13:00:00Z');
|
||||
insert into public.passagens_de_atendimento
|
||||
(organization_id, contact_id, conversation_id, motor, origem, motivo_codigo, body, criado_em)
|
||||
values ('${ORG_VIZ}', '${CT_VIZ}', '${CV_VIZ}', 'engine', 'pedido_explicito',
|
||||
'requested_human', 'a narrativa da vizinha', '${PASSAGEM_EM}');
|
||||
`);
|
||||
|
||||
// CONTROLE DA SEMENTE: sem isto, um insert engolido por unique parcial (o
|
||||
// defeito que custou um diagnostico errado na onda 10) faria os casos abaixo
|
||||
// medirem uma fixture menor do que a escrita, em silencio.
|
||||
const plantadas = lastLine(
|
||||
sql(
|
||||
`select count(*) from public.passagens_de_atendimento
|
||||
where conversation_id in ('${CV_REPETIU}','${CV_MUDOU}','${CV_CALADO}','${CV_TARDE}','${CV_VIZ}');`,
|
||||
),
|
||||
).trim();
|
||||
if (plantadas !== "5") {
|
||||
throw new Error(`a semente plantou ${plantadas} passagens, e deveriam ser 5`);
|
||||
}
|
||||
});
|
||||
|
||||
describe("fn_atrito_metrics — repetição depois da passagem", () => {
|
||||
const metrics = () => atritoComo(GOV_MANAGER, GOV_ORG).cliente!;
|
||||
|
||||
it("CONTROLE DE VACUIDADE: as duas chaves existem no payload", () => {
|
||||
// Sem isto, uma função sem as chaves devolveria `undefined` e todo
|
||||
// `toBe(...)` abaixo falharia por motivo errado — ou, pior, um `toBeNull`
|
||||
// passaria por ausência.
|
||||
const c = metrics();
|
||||
expect(Object.keys(c)).toContain("repeticao_pos_passagem");
|
||||
expect(Object.keys(c)).toContain("passagens_medidas");
|
||||
});
|
||||
|
||||
it("o cliente que repetiu quase a mesma frase CONTA", () => {
|
||||
expect(metrics().repeticao_pos_passagem).toBe(1);
|
||||
});
|
||||
|
||||
it("quem voltou com OUTRO assunto não conta como repetição", () => {
|
||||
// O falso positivo é o risco caro: acusar repetição onde a pessoa mudou de
|
||||
// tema faria o painel dizer que o briefing não serviu quando ele serviu.
|
||||
// Três conversas entram no denominador (repetiu, mudou, voltou tarde? não —
|
||||
// a tardia fica FORA) e só uma no numerador.
|
||||
const c = metrics();
|
||||
expect(c.repeticao_pos_passagem).toBe(1);
|
||||
expect(c.passagens_medidas).toBe(2);
|
||||
});
|
||||
|
||||
it("sem fala nova depois, a passagem fica FORA do denominador", () => {
|
||||
// Contá-la como "não repetiu" inflaria o número para o lado bonito: ninguém
|
||||
// repetiu porque ninguém falou, não porque o briefing funcionou.
|
||||
const c = metrics();
|
||||
expect(c.passagens_medidas).toBe(2);
|
||||
});
|
||||
|
||||
it("fala 30h depois está FORA da janela de 24h — a régua é fixa", () => {
|
||||
// O cenário "voltou tarde" repete a MESMA frase do cenário que conta. Se a
|
||||
// janela for alargada, o denominador vai a 3 e o numerador a 2 — este caso
|
||||
// é o que denuncia.
|
||||
const c = metrics();
|
||||
expect(c.passagens_medidas).toBe(2);
|
||||
expect(c.repeticao_pos_passagem).toBe(1);
|
||||
});
|
||||
|
||||
it("a passagem da organização VIZINHA não entra na conta desta", () => {
|
||||
// `fn_atrito_metrics` é SECURITY INVOKER, e a policy da tabela exige
|
||||
// organização + papel + visibilidade da conversa. Promovê-la a DEFINER
|
||||
// "para simplificar" faria este caso vermelhar antes de virar vazamento.
|
||||
const c = metrics();
|
||||
expect(c.passagens_medidas).toBe(2);
|
||||
});
|
||||
|
||||
it("organização sem passagem nenhuma devolve ZERO no denominador — e a borda o traduz em `—`", () => {
|
||||
// Zero aqui é a contagem honesta; quem transforma em ausência é
|
||||
// `razao()` em `lib/metrics/atrito.ts`, que devolve `null` para denominador
|
||||
// zero. Os dois lados precisam existir: contar `null` no SQL impediria
|
||||
// distinguir "ninguém repetiu" de "ninguém voltou a falar".
|
||||
const out = sql(`
|
||||
set role authenticated;
|
||||
select set_config('request.jwt.claims', '{"sub":"${GOV_MANAGER}"}', false);
|
||||
select public.fn_atrito_metrics('${GOV_ORG}'::uuid,
|
||||
'2026-01-01T00:00:00Z'::timestamptz, '2026-02-01T00:00:00Z'::timestamptz);
|
||||
`);
|
||||
const c = (JSON.parse(lastLine(out)) as Record<string, Record<string, number>>).cliente!;
|
||||
expect(c.passagens_medidas).toBe(0);
|
||||
expect(c.repeticao_pos_passagem).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ const RAW: AtritoRaw = {
|
||||
esperas_caladas: 4,
|
||||
esperas_medidas: 80,
|
||||
espera_resposta_p90_s: 1800,
|
||||
repeticao_pos_passagem: 4,
|
||||
passagens_medidas: 10,
|
||||
},
|
||||
empresa: {
|
||||
intervencoes_por_demanda: 1.4,
|
||||
@@ -358,3 +360,69 @@ describe("formatação", () => {
|
||||
expect(formatarDuracao(segundos)).toBe(esperado);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* O LAÇO DE RETORNO DA PASSAGEM (invariante 7, migration 0294).
|
||||
*
|
||||
* A pergunta que mede se o cartão da passagem serviu para alguma coisa: DEPOIS
|
||||
* de a IA passar a conversa, o cliente precisou repetir o que já tinha dito? Se
|
||||
* o briefing chegou a quem assumiu, a repetição cai. Se não chegou, ela não muda
|
||||
* — e a feature é decoração cara.
|
||||
*
|
||||
* Ela é publicada como DANO do par `automacao`, encostada em "Passagens para
|
||||
* humano": a eficiência ali empurra o sistema a automatizar mais, e o custo de
|
||||
* automatizar mal é exatamente a pessoa repetindo o que já disse.
|
||||
*/
|
||||
describe("repetição depois da passagem — o laço de retorno", () => {
|
||||
const pares = montarPares(RAW);
|
||||
const automacao = pares.find((p) => p.chave === "automacao")!;
|
||||
const medida = automacao.danos.find((d) => d.chave === "repeticao_pos_passagem");
|
||||
|
||||
it("é publicada no par da AUTOMAÇÃO, ao lado das passagens para humano", () => {
|
||||
// Fora do par ela vira número solto num painel: sem a eficiência ao lado,
|
||||
// ninguém sabe do que ela é o custo — e é justamente a regra 3.3 que este
|
||||
// arquivo inteiro guarda.
|
||||
expect(medida, "a medida do laço sumiu do par `automacao`").toBeDefined();
|
||||
const chaves = automacao.danos.map((d) => d.chave);
|
||||
expect(chaves).toContain("pedidos_de_humano");
|
||||
});
|
||||
|
||||
it("é uma RAZÃO calculada na borda: 4 de 10", () => {
|
||||
expect(medida!.unidade).toBe("razao");
|
||||
expect(medida!.valor).toBeCloseTo(0.4, 6);
|
||||
});
|
||||
|
||||
it("sem passagem em que o cliente voltou a falar, o número é `—`, nunca 0%", () => {
|
||||
// AUSÊNCIA DE DADO É null. Um `0` aqui viraria "0% de repetição" numa
|
||||
// organização onde ninguém voltou a falar depois de nenhuma passagem — a
|
||||
// frase tranquilizadora que a falta de medição não autoriza, e o número que
|
||||
// faria alguém declarar a feature um sucesso.
|
||||
const vazio = montarPares({
|
||||
...RAW,
|
||||
cliente: { ...RAW.cliente, repeticao_pos_passagem: 0, passagens_medidas: 0 },
|
||||
});
|
||||
const semDado = vazio
|
||||
.find((p) => p.chave === "automacao")!
|
||||
.danos.find((d) => d.chave === "repeticao_pos_passagem")!;
|
||||
expect(semDado.valor).toBeNull();
|
||||
expect(formatarMedida(semDado)).toBe("—");
|
||||
});
|
||||
|
||||
it("a nota traz o numerador, o denominador e a RÉGUA — número sem régua não compara", () => {
|
||||
// Limiar e janela precisam viajar com o número: se amanhã alguém mexer em
|
||||
// qualquer um dos dois, o valor de hoje e o de então não são comparáveis, e
|
||||
// o índice perde a única coisa que ele tinha.
|
||||
expect(medida!.nota).toContain("4");
|
||||
expect(medida!.nota).toContain("10");
|
||||
expect(medida!.nota).toMatch(/0,7/);
|
||||
expect(medida!.nota).toMatch(/24h/);
|
||||
});
|
||||
|
||||
it("a nota declara que dois papéis veem números diferentes — e isso é de boa-fé", () => {
|
||||
// `fn_atrito_metrics` é SECURITY INVOKER: um `agent` numa organização em
|
||||
// `visibility_mode='own'` enxerga só as conversas dele. Sem esta ressalva,
|
||||
// quem comparar o painel de duas pessoas vai concluir que um dos dois está
|
||||
// errado — e nenhum está.
|
||||
expect(medida!.nota).toMatch(/own/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
montarCartoesDaPassagem,
|
||||
type PassagemDaConversa,
|
||||
type QuemOlha,
|
||||
} from "@/lib/escalacao/cartao-da-passagem";
|
||||
import { DICIONARIO } from "@/lib/i18n/dicionario";
|
||||
import {
|
||||
FRASE_DO_MOTIVO,
|
||||
FRASE_DO_MOTIVO_DO_AVISO,
|
||||
MOTIVOS_DA_PASSAGEM,
|
||||
PISO_DO_BRIEFING,
|
||||
} from "@/lib/escalacao/passagem";
|
||||
|
||||
/**
|
||||
* OS SETE ESTADOS DO CARTÃO, DECIDIDOS FORA DO JSX.
|
||||
*
|
||||
* ═══ Por que a decisão não mora no componente ═══
|
||||
*
|
||||
* Duas razões, e nenhuma é gosto:
|
||||
*
|
||||
* 1. **`tests/unit/passagem-motivo-em-portugues.test.ts` proíbe o código cru
|
||||
* em `app/`, `components/` e `hooks/`.** Um `motivo_codigo ===
|
||||
* "suspected_optout"` dentro do JSX reprova aquele gate — e ele está certo:
|
||||
* vocabulário de CONSTRAINT dentro de uma tela é o caminho mais curto para
|
||||
* ele virar texto no rosto de quem opera.
|
||||
* 2. **Estado de tela testado por render é caro e frágil.** Sete estados × a
|
||||
* variante de quem olha dá uma matriz que um teste de DOM mede devagar e
|
||||
* com falso vermelho de layout. Aqui a matriz é um `expect` por linha.
|
||||
*
|
||||
* ═══ O que este arquivo NÃO prova ═══
|
||||
*
|
||||
* Que a tela RENDERIZA o que a função decidiu. Isso é do componente e da prova
|
||||
* em tela (DoD 12, onda 12). O que se prova aqui é a decisão — e é ela que
|
||||
* carrega a regra de negócio.
|
||||
*/
|
||||
|
||||
const AGORA = "2026-09-18T12:00:00.000Z";
|
||||
|
||||
function passagem(over: Partial<PassagemDaConversa> = {}): PassagemDaConversa {
|
||||
return {
|
||||
id: "p1",
|
||||
origem: "pedido_explicito",
|
||||
motivo_codigo: "requested_human",
|
||||
title: "15% de desconto no plano anual",
|
||||
body: "Cliente de 200 unidades, comparando com concorrente.",
|
||||
notes: "então me passa pra uma pessoa",
|
||||
content: null,
|
||||
tentativas: [
|
||||
{ o_que: "Buscou a política de desconto", desfecho: "só vai a 10%" },
|
||||
{ o_que: "Ofereceu 10% + 2 meses", desfecho: "recusado" },
|
||||
],
|
||||
cliente_avisado: true,
|
||||
aviso_motivo_codigo: null,
|
||||
caso_id: null,
|
||||
criado_em: AGORA,
|
||||
reconhecido_em: null,
|
||||
reconhecido_por: null,
|
||||
reconhecido_por_nome: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const NINGUEM_ATENDE: QuemOlha = { usuarioId: "u-eu", donoId: null, donoNome: null };
|
||||
|
||||
describe("cartão da passagem — o estado do episódio", () => {
|
||||
it("CONTROLE DE VACUIDADE: lista vazia devolve lista vazia, e não um cartão em branco", () => {
|
||||
// Sem isto, um bug que devolvesse `[{}]` para entrada vazia passaria em
|
||||
// todos os casos abaixo, que só olham o primeiro elemento.
|
||||
expect(montarCartoesDaPassagem([], NINGUEM_ATENDE)).toEqual([]);
|
||||
});
|
||||
|
||||
it("passagem não reconhecida fica ABERTA, em destaque, com o convite de assumir", () => {
|
||||
const c = montarCartoesDaPassagem([passagem()], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.estado).toBe("aberta");
|
||||
expect(c.recolhido).toBe(false);
|
||||
expect(c.acao.tipo).toBe("assumir_e_responder");
|
||||
});
|
||||
|
||||
it("passagem com dono fica RECONHECIDA, sem convite, e nomeia quem assumiu", () => {
|
||||
const c = montarCartoesDaPassagem(
|
||||
[
|
||||
passagem({
|
||||
reconhecido_em: AGORA,
|
||||
reconhecido_por: "u-joana",
|
||||
reconhecido_por_nome: "Joana",
|
||||
}),
|
||||
],
|
||||
{ usuarioId: "u-eu", donoId: "u-joana", donoNome: "Joana" },
|
||||
)[0]!;
|
||||
expect(c.estado).toBe("reconhecida");
|
||||
expect(c.assumidaPor).toBe("Joana");
|
||||
expect(c.acao.tipo).toBe("nenhuma");
|
||||
});
|
||||
|
||||
it("reconhecida SEM dono é devolução ao automático — o episódio fechou e ninguém assumiu", () => {
|
||||
// É o par que a 0291 documentou e o CHECK `passagens_reconhecimento_coerente`
|
||||
// permite só neste sentido: `reconhecido_em` preenchido com
|
||||
// `reconhecido_por` nulo. Ler isso como "reconhecida" diria a quem abre a
|
||||
// conversa que alguém está cuidando — e não está.
|
||||
const c = montarCartoesDaPassagem(
|
||||
[passagem({ reconhecido_em: AGORA, reconhecido_por: null })],
|
||||
NINGUEM_ATENDE,
|
||||
)[0]!;
|
||||
expect(c.estado).toBe("devolvida");
|
||||
expect(c.assumidaPor).toBeNull();
|
||||
expect(c.acao.tipo).toBe("nenhuma");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — o que ele mostra", () => {
|
||||
it("o motivo sai em PORTUGUÊS, e o código do banco não aparece em lugar nenhum", () => {
|
||||
const c = montarCartoesDaPassagem([passagem({ motivo_codigo: "low_sentiment" })], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.motivo).toBe(FRASE_DO_MOTIVO.low_sentiment);
|
||||
expect(JSON.stringify(c)).not.toContain("low_sentiment");
|
||||
});
|
||||
|
||||
it("o resumo que é o PISO some — cabeçalho órfão é ruído", () => {
|
||||
// `body` é `not null`, então uma passagem sem contexto carrega o piso. Um
|
||||
// cabeçalho "Resumo da IA" seguido da frase "sem resumo acumulado" ocupa
|
||||
// três linhas para dizer nada.
|
||||
const c = montarCartoesDaPassagem([passagem({ body: PISO_DO_BRIEFING })], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.resumo).toBeNull();
|
||||
expect(c.semContexto).toBe(true);
|
||||
});
|
||||
|
||||
it("resumo de verdade fica, e `semContexto` é falso", () => {
|
||||
const c = montarCartoesDaPassagem([passagem()], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.resumo).toBe("Cliente de 200 unidades, comparando com concorrente.");
|
||||
expect(c.semContexto).toBe(false);
|
||||
});
|
||||
|
||||
it("a fala do cliente e o que ele quer chegam separados — um é citação, o outro é conclusão da IA", () => {
|
||||
const c = montarCartoesDaPassagem([passagem()], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.falaDoCliente).toBe("então me passa pra uma pessoa");
|
||||
expect(c.clienteQuer).toBe("15% de desconto no plano anual");
|
||||
});
|
||||
|
||||
it("texto em branco vira `null`, nunca string vazia", () => {
|
||||
// Uma seção com título e corpo vazio afirma que o dado existe e é nada.
|
||||
const c = montarCartoesDaPassagem(
|
||||
[passagem({ title: " ", notes: "", content: "\n" })],
|
||||
NINGUEM_ATENDE,
|
||||
)[0]!;
|
||||
expect(c.clienteQuer).toBeNull();
|
||||
expect(c.falaDoCliente).toBeNull();
|
||||
expect(c.textoDeQuemPassou).toBeNull();
|
||||
});
|
||||
|
||||
it("as tentativas viram lista, na ordem em que a IA as declarou", () => {
|
||||
const c = montarCartoesDaPassagem([passagem()], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.tentativas.map((t) => t.o_que)).toEqual([
|
||||
"Buscou a política de desconto",
|
||||
"Ofereceu 10% + 2 meses",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tentativa fora do schema é DESCARTADA, e o resto do cartão sobrevive", () => {
|
||||
// `tentativas` é jsonb escrito a partir do que o modelo (ou um agente MCP
|
||||
// externo) mandou. Um item torto não pode derrubar o cartão inteiro: o
|
||||
// motivo e a fala do cliente continuam sendo o que a pessoa precisa ler.
|
||||
const c = montarCartoesDaPassagem(
|
||||
[passagem({ tentativas: [{ o_que: "válida" }, { nada: "disso" }] as never })],
|
||||
NINGUEM_ATENDE,
|
||||
)[0]!;
|
||||
expect(c.tentativas.map((t) => t.o_que)).toEqual(["válida"]);
|
||||
expect(c.motivo).toBe(FRASE_DO_MOTIVO.requested_human);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — o cliente sabe que alguém vem?", () => {
|
||||
it("avisado: a linha afirma, e não há motivo a explicar", () => {
|
||||
const c = montarCartoesDaPassagem([passagem({ cliente_avisado: true })], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.aviso).toEqual({ avisado: true, frase: null });
|
||||
});
|
||||
|
||||
it("NÃO avisado: a linha diz o porquê, em português", () => {
|
||||
const c = montarCartoesDaPassagem(
|
||||
[passagem({ cliente_avisado: false, aviso_motivo_codigo: "na_fila_canal_fora" })],
|
||||
NINGUEM_ATENDE,
|
||||
)[0]!;
|
||||
expect(c.aviso).toEqual({
|
||||
avisado: false,
|
||||
frase: FRASE_DO_MOTIVO_DO_AVISO.na_fila_canal_fora,
|
||||
});
|
||||
});
|
||||
|
||||
it("ninguém TENTOU avisar é diferente de tentou e não conseguiu — e a tela não afirma nada", () => {
|
||||
// `null` na coluna. A primeira frase que o atendente digita muda: no
|
||||
// primeiro caso a pessoa não espera nada; no segundo ela espera sem saber.
|
||||
const c = montarCartoesDaPassagem([passagem({ cliente_avisado: null })], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.aviso).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — opt-out provável", () => {
|
||||
const optOut = passagem({ motivo_codigo: "suspected_optout", origem: "opt_out_provavel" });
|
||||
|
||||
it("o título muda, e NÃO convida a responder", () => {
|
||||
// Um cartão que convida a "assumir e responder" empurra alguém a escrever
|
||||
// para quem pediu para parar de receber mensagens. O gesto certo é conferir
|
||||
// o bloqueio na ficha do contato.
|
||||
const c = montarCartoesDaPassagem([optOut], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.optOut).toBe(true);
|
||||
expect(c.acao.tipo).toBe("abrir_contato");
|
||||
expect(c.titulo).not.toBe(montarCartoesDaPassagem([passagem()], NINGUEM_ATENDE)[0]!.titulo);
|
||||
});
|
||||
|
||||
it("nem mesmo com a conversa sem dono o convite de responder volta", () => {
|
||||
for (const quem of [
|
||||
NINGUEM_ATENDE,
|
||||
{ usuarioId: "u-eu", donoId: "u-eu", donoNome: "Eu" },
|
||||
{ usuarioId: "u-eu", donoId: "u-outro", donoNome: "Outro" },
|
||||
] satisfies QuemOlha[]) {
|
||||
expect(montarCartoesDaPassagem([optOut], quem)[0]!.acao.tipo).not.toBe("assumir_e_responder");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — quem já está atendendo", () => {
|
||||
it("a conversa é MINHA: nada a convidar, o cartão é só contexto", () => {
|
||||
const c = montarCartoesDaPassagem([passagem()], {
|
||||
usuarioId: "u-eu",
|
||||
donoId: "u-eu",
|
||||
donoNome: "Eu",
|
||||
})[0]!;
|
||||
expect(c.acao.tipo).toBe("nenhuma");
|
||||
});
|
||||
|
||||
it("a conversa é de OUTRA pessoa: o cartão diz quem atende, em vez de ficar mudo", () => {
|
||||
// B4: sem isto o cartão mais caro da entrega não fala nada para metade dos
|
||||
// leitores — e "Assumir e responder" ali seria oferecer um gesto que a rota
|
||||
// recusa, porque a conversa já tem dono.
|
||||
const c = montarCartoesDaPassagem([passagem()], {
|
||||
usuarioId: "u-eu",
|
||||
donoId: "u-joana",
|
||||
donoNome: "Joana",
|
||||
})[0]!;
|
||||
expect(c.acao).toEqual({ tipo: "avisa_quem_atende", donoNome: "Joana" });
|
||||
});
|
||||
|
||||
it("dono sem nome resolvido continua dizendo que HÁ dono", () => {
|
||||
// O nome é cortesia (self-host sem service role devolve `null`); o dono é a
|
||||
// verdade. Cair no convite de assumir aqui ofereceria o gesto errado.
|
||||
const c = montarCartoesDaPassagem([passagem()], {
|
||||
usuarioId: "u-eu",
|
||||
donoId: "u-joana",
|
||||
donoNome: null,
|
||||
})[0]!;
|
||||
expect(c.acao).toEqual({ tipo: "avisa_quem_atende", donoNome: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — várias passagens na mesma conversa", () => {
|
||||
const tres = [
|
||||
passagem({ id: "p1", criado_em: "2026-09-16T10:00:00.000Z", reconhecido_em: "2026-09-16T11:00:00.000Z", reconhecido_por: "u-joana" }),
|
||||
passagem({ id: "p2", criado_em: "2026-09-17T10:00:00.000Z", reconhecido_em: "2026-09-17T11:00:00.000Z", reconhecido_por: null }),
|
||||
passagem({ id: "p3", criado_em: "2026-09-18T10:00:00.000Z" }),
|
||||
];
|
||||
|
||||
it("a mais recente fica aberta; as anteriores ficam recolhidas", () => {
|
||||
const cartoes = montarCartoesDaPassagem(tres, NINGUEM_ATENDE);
|
||||
expect(cartoes.map((c) => c.recolhido)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("a ordem é cronológica, mesmo se a rota devolver fora de ordem", () => {
|
||||
// O fio da conversa é cronológico; um cartão fora de ordem no meio das
|
||||
// mensagens diria que a IA passou a conversa depois de já ter passado.
|
||||
const cartoes = montarCartoesDaPassagem([tres[2]!, tres[0]!, tres[1]!], NINGUEM_ATENDE);
|
||||
expect(cartoes.map((c) => c.id)).toEqual(["p1", "p2", "p3"]);
|
||||
});
|
||||
|
||||
it("só a última convida a assumir — três convites na mesma conversa são um convite só", () => {
|
||||
const cartoes = montarCartoesDaPassagem(
|
||||
[passagem({ id: "a", criado_em: "2026-09-17T10:00:00.000Z" }), passagem({ id: "b" })],
|
||||
NINGUEM_ATENDE,
|
||||
);
|
||||
expect(cartoes.map((c) => c.acao.tipo)).toEqual(["nenhuma", "assumir_e_responder"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — o espanhol dos textos que a tela recebe por VARIÁVEL", () => {
|
||||
it("os dois títulos têm par `es` — o gate de i18n só enxerga literal", () => {
|
||||
// `tests/unit/i18n-espanhol-cobre-a-tela.test.ts` varre `t("literal")`. O
|
||||
// título do cartão chega como `t(cartao.titulo)`, então aquele gate passa
|
||||
// por cima e a tela cairia no português para quem escolheu espanhol — em
|
||||
// silêncio, que é o modo de falha de i18n que este repositório já pagou.
|
||||
const titulos = new Set(
|
||||
MOTIVOS_DA_PASSAGEM.flatMap((motivo) =>
|
||||
montarCartoesDaPassagem([passagem({ motivo_codigo: motivo })], NINGUEM_ATENDE).map(
|
||||
(c) => c.titulo,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(titulos.size, "os títulos deixaram de variar — a varredura ficou vácua").toBe(2);
|
||||
expect([...titulos].filter((texto) => DICIONARIO[texto]?.es === undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cartão da passagem — contato anonimizado", () => {
|
||||
const anonima = passagem({
|
||||
body: "Cliente Anonimizado #0b1f7a2e",
|
||||
title: null,
|
||||
notes: null,
|
||||
content: null,
|
||||
tentativas: [],
|
||||
});
|
||||
|
||||
it("o cartão vira o rótulo e nada mais", () => {
|
||||
const c = montarCartoesDaPassagem([anonima], NINGUEM_ATENDE)[0]!;
|
||||
expect(c.anonimizada).toBe(true);
|
||||
expect(c.resumo).toBeNull();
|
||||
expect(c.falaDoCliente).toBeNull();
|
||||
expect(c.tentativas).toEqual([]);
|
||||
expect(c.aviso).toBeNull();
|
||||
expect(c.acao.tipo).toBe("nenhuma");
|
||||
});
|
||||
|
||||
it("mesmo com sobra de texto de uma cascata parcial, o cartão não a exibe", () => {
|
||||
// A cascata zera as quatro colunas; se um clone antigo tiver sobra em
|
||||
// `notes`, o cartão não pode ressuscitá-la na tela de quem atende. O
|
||||
// reconhecimento é pelo RÓTULO em `body`, que é a coluna `not null` e a
|
||||
// única que a cascata garante ter reescrito.
|
||||
const c = montarCartoesDaPassagem(
|
||||
[{ ...anonima, notes: "sobra de um clone antigo", title: "sobra" }],
|
||||
NINGUEM_ATENDE,
|
||||
)[0]!;
|
||||
expect(c.falaDoCliente).toBeNull();
|
||||
expect(c.clienteQuer).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A PASSAGEM QUE NINGUÉM ASSUMIU VOLTA A PEDIR — o segundo braço do vigia.
|
||||
*
|
||||
* ═══ O defeito, e por que ele não é do vigia de casos ═══
|
||||
*
|
||||
* O reconhecimento da passagem (migration 0293) só acontece por GESTO de quem
|
||||
* chegou: alguém assume a conversa, ou a devolve ao automático. Ninguém cobra a
|
||||
* passagem em que ninguém chegou — e dos TREZE caminhos que passam conversa para
|
||||
* uma pessoa, exatamente UM nasce de caso. O `case-stale-watcher` varre
|
||||
* `agent_cases`; ele não alcançava os outros doze nem por acidente.
|
||||
*
|
||||
* ⚠️ A população não é hipótese. Medida num CRM em produção com o mesmo desenho
|
||||
* de fila (2026-09-14, e o número está escrito no cabeçalho da própria rota):
|
||||
* **22 pedidos parados, o mais antigo há 17,6 dias, e ONZE deles eram gente
|
||||
* pedindo para falar com uma pessoa.**
|
||||
*
|
||||
* ═══ O que este arquivo prova, e o que ele deixa para o invariante ═══
|
||||
*
|
||||
* Prova a decisão do braço: quem entra na varredura, quem NÃO entra, o que
|
||||
* acontece com o aviso da Central, e que o contador para no terceiro. A RLS e o
|
||||
* gatilho de reconhecimento são de `pnpm test:db`; aqui não há banco.
|
||||
*/
|
||||
|
||||
const SEGREDO = "segredo-de-cron-do-teste";
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: { INTERNAL_CRON_SECRET: SEGREDO, INTERNAL_SECRET: "" },
|
||||
}));
|
||||
|
||||
const auditou = vi.fn();
|
||||
vi.mock("@/lib/audit", () => ({ audit: (...args: unknown[]) => auditou(...args) }));
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
type Linha = Record<string, unknown>;
|
||||
|
||||
interface Banco {
|
||||
agent_cases: Linha[];
|
||||
agent_inbox_items: Linha[];
|
||||
passagens_de_atendimento: Linha[];
|
||||
}
|
||||
|
||||
interface Registro {
|
||||
inserts: Array<{ tabela: string; linha: Linha }>;
|
||||
updates: Array<{ tabela: string; patch: Linha; filtros: Array<[string, unknown]> }>;
|
||||
consultas: Array<{ tabela: string; filtros: Array<[string, unknown]> }>;
|
||||
}
|
||||
|
||||
const banco: Banco = { agent_cases: [], agent_inbox_items: [], passagens_de_atendimento: [] };
|
||||
const registro: Registro = { inserts: [], updates: [], consultas: [] };
|
||||
|
||||
/**
|
||||
* Dublê que APLICA os filtros. Um dublê que os ignorasse deixaria passar a
|
||||
* varredura que cobra passagem já reconhecida — e a asserção "não cobra quem já
|
||||
* foi assumido" ficaria verde sem medir nada.
|
||||
*/
|
||||
vi.mock("@/lib/supabase/admin", () => ({
|
||||
createAdminClient: () => ({
|
||||
from: (tabela: keyof Banco) => {
|
||||
let linhas = [...(banco[tabela] ?? [])];
|
||||
let limite = Infinity;
|
||||
const filtros: Array<[string, unknown]> = [];
|
||||
let modo: "select" | "insert" | "update" = "select";
|
||||
let patch: Linha = {};
|
||||
|
||||
const cadeia: Record<string, unknown> = {
|
||||
select: () => {
|
||||
registro.consultas.push({ tabela, filtros });
|
||||
return cadeia;
|
||||
},
|
||||
insert: (linha: Linha) => {
|
||||
modo = "insert";
|
||||
registro.inserts.push({ tabela, linha });
|
||||
banco[tabela].push({ id: `novo-${banco[tabela].length}`, status: "open", ...linha });
|
||||
return cadeia;
|
||||
},
|
||||
update: (p: Linha) => {
|
||||
modo = "update";
|
||||
patch = p;
|
||||
return cadeia;
|
||||
},
|
||||
eq: (col: string, val: unknown) => {
|
||||
filtros.push([col, val]);
|
||||
linhas = linhas.filter((l) => l[col] === val);
|
||||
return cadeia;
|
||||
},
|
||||
is: (col: string, val: unknown) => {
|
||||
filtros.push([`is:${col}`, val]);
|
||||
linhas = linhas.filter((l) => (l[col] ?? null) === val);
|
||||
return cadeia;
|
||||
},
|
||||
lt: (col: string, val: unknown) => {
|
||||
filtros.push([`lt:${col}`, val]);
|
||||
linhas = linhas.filter((l) => (l[col] as number | string) < (val as number | string));
|
||||
return cadeia;
|
||||
},
|
||||
order: (col: string, o: { ascending: boolean }) => {
|
||||
linhas = [...linhas].sort(
|
||||
(x, y) => String(x[col]).localeCompare(String(y[col])) * (o.ascending ? 1 : -1),
|
||||
);
|
||||
return cadeia;
|
||||
},
|
||||
limit: (n: number) => {
|
||||
limite = n;
|
||||
return cadeia;
|
||||
},
|
||||
maybeSingle: () => Promise.resolve({ data: linhas[0] ?? null, error: null }),
|
||||
then: (res: (v: unknown) => unknown) => {
|
||||
if (modo === "update") {
|
||||
registro.updates.push({ tabela, patch, filtros });
|
||||
for (const l of linhas) Object.assign(l, patch);
|
||||
}
|
||||
return Promise.resolve({ data: linhas.slice(0, limite), error: null }).then(res);
|
||||
},
|
||||
};
|
||||
return cadeia;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const ORG = "org-1";
|
||||
const CONVERSA = "conv-1";
|
||||
const ONTEM = new Date(Date.now() - 30 * 60 * 60 * 1000).toISOString();
|
||||
const AGORA = new Date().toISOString();
|
||||
|
||||
function passagem(over: Linha = {}): Linha {
|
||||
return {
|
||||
id: "p1",
|
||||
organization_id: ORG,
|
||||
conversation_id: CONVERSA,
|
||||
criado_em: ONTEM,
|
||||
reconhecido_em: null,
|
||||
cobrancas: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function rodar() {
|
||||
const { GET } = await import("@/app/api/v1/cron/case-stale-watcher/route");
|
||||
const res = await GET({ headers: new Headers({ authorization: `Bearer ${SEGREDO}` }) } as never);
|
||||
return { status: res.status, body: (await res.json()) as { data?: Record<string, number> } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
auditou.mockClear();
|
||||
banco.agent_cases = [];
|
||||
banco.agent_inbox_items = [];
|
||||
banco.passagens_de_atendimento = [];
|
||||
registro.inserts = [];
|
||||
registro.updates = [];
|
||||
registro.consultas = [];
|
||||
});
|
||||
|
||||
describe("o vigia cobra a passagem que ninguém assumiu", () => {
|
||||
it("passagem parada há mais de um dia vira aviso na Central, apontando para a CONVERSA", async () => {
|
||||
// `ref_kind='conversation'` e não `contact`: o botão "Abrir conversa" da
|
||||
// Central sai daí, e é na conversa que o cartão com o contexto mora. Um
|
||||
// aviso que leva à ficha do contato faria a pessoa procurar o atendimento.
|
||||
banco.passagens_de_atendimento = [passagem()];
|
||||
|
||||
const { status, body } = await rodar();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.data?.passagens_cobradas).toBe(1);
|
||||
const inserido = registro.inserts.find((i) => i.tabela === "agent_inbox_items");
|
||||
expect(inserido?.linha).toMatchObject({
|
||||
organization_id: ORG,
|
||||
kind: "handoff",
|
||||
ref_kind: "conversation",
|
||||
ref_id: CONVERSA,
|
||||
});
|
||||
});
|
||||
|
||||
it("passagem JÁ reconhecida não é cobrada — alguém assumiu", async () => {
|
||||
banco.passagens_de_atendimento = [passagem({ reconhecido_em: AGORA })];
|
||||
|
||||
const { body } = await rodar();
|
||||
|
||||
expect(body.data?.passagens_cobradas).toBe(0);
|
||||
expect(registro.inserts.filter((i) => i.tabela === "agent_inbox_items")).toEqual([]);
|
||||
});
|
||||
|
||||
it("passagem RECENTE não é cobrada — o silêncio de uma hora não é abandono", async () => {
|
||||
banco.passagens_de_atendimento = [passagem({ criado_em: AGORA })];
|
||||
|
||||
const { body } = await rodar();
|
||||
|
||||
expect(body.data?.passagens_cobradas).toBe(0);
|
||||
});
|
||||
|
||||
it("no terceiro aviso ele para — alarme que nunca cala ensina a ignorar o alarme certo", async () => {
|
||||
banco.passagens_de_atendimento = [passagem({ cobrancas: 3 })];
|
||||
|
||||
const { body } = await rodar();
|
||||
|
||||
expect(body.data?.passagens_cobradas).toBe(0);
|
||||
});
|
||||
|
||||
it("cada cobrança sobe o contador DAQUELA passagem", async () => {
|
||||
banco.passagens_de_atendimento = [passagem({ cobrancas: 1 })];
|
||||
|
||||
await rodar();
|
||||
|
||||
const contador = registro.updates.find(
|
||||
(u) => u.tabela === "passagens_de_atendimento" && "cobrancas" in u.patch,
|
||||
);
|
||||
expect(contador?.patch.cobrancas).toBe(2);
|
||||
// O filtro por organização é o que impede o contador de subir na linha de
|
||||
// outra instalação num banco compartilhado.
|
||||
expect(contador?.filtros).toContainEqual(["organization_id", ORG]);
|
||||
});
|
||||
|
||||
it("aviso JÁ ABERTO daquela conversa é REUSADO, não duplicado", async () => {
|
||||
// Dois avisos sobre o mesmo atendimento na Central fazem a pessoa resolver
|
||||
// um e continuar vendo o outro — e é o que o dedup do produtor já evita.
|
||||
banco.agent_inbox_items = [
|
||||
{
|
||||
id: "i1",
|
||||
organization_id: ORG,
|
||||
kind: "handoff",
|
||||
ref_kind: "conversation",
|
||||
ref_id: CONVERSA,
|
||||
status: "open",
|
||||
created_at: ONTEM,
|
||||
},
|
||||
];
|
||||
banco.passagens_de_atendimento = [passagem()];
|
||||
|
||||
const { body } = await rodar();
|
||||
|
||||
expect(body.data?.passagens_cobradas).toBe(1);
|
||||
expect(registro.inserts.filter((i) => i.tabela === "agent_inbox_items")).toEqual([]);
|
||||
expect(
|
||||
registro.updates.some((u) => u.tabela === "agent_inbox_items"),
|
||||
"o aviso aberto não foi atualizado — a cobrança não diria nada de novo",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("aviso RESOLVIDO sem ninguém ter assumido é REABERTO", async () => {
|
||||
// Marcar o aviso como resolvido não é assumir a conversa. Enquanto
|
||||
// `reconhecido_em` for nulo, há alguém esperando do outro lado — e o aviso
|
||||
// resolvido é a única coisa que some da tela sem o problema sumir junto.
|
||||
banco.agent_inbox_items = [
|
||||
{
|
||||
id: "i1",
|
||||
organization_id: ORG,
|
||||
kind: "handoff",
|
||||
ref_kind: "conversation",
|
||||
ref_id: CONVERSA,
|
||||
status: "resolved",
|
||||
resolved_at: AGORA,
|
||||
created_at: ONTEM,
|
||||
},
|
||||
];
|
||||
banco.passagens_de_atendimento = [passagem()];
|
||||
|
||||
await rodar();
|
||||
|
||||
const reabertura = registro.updates.find((u) => u.tabela === "agent_inbox_items");
|
||||
expect(reabertura?.patch).toMatchObject({ status: "open", resolved_at: null });
|
||||
});
|
||||
|
||||
it("a rodada que não cobrou ninguém NÃO audita", async () => {
|
||||
// Rodada vazia não é mutação. O vigia roda de hora em hora: auditar sempre
|
||||
// grava milhares de linhas/mês numa instalação parada, numa tabela
|
||||
// append-only sem UPDATE nem DELETE para papel nenhum.
|
||||
banco.passagens_de_atendimento = [passagem({ reconhecido_em: AGORA })];
|
||||
|
||||
await rodar();
|
||||
|
||||
expect(auditou).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a rodada que cobrou AUDITA, com código próprio e a contagem", async () => {
|
||||
// A outra direção, que não pode se perder: "parar de auditar" trocaria
|
||||
// ruído por cegueira. Código próprio porque a pergunta é outra — caso parado
|
||||
// é a IA esperando decisão; passagem parada é um cliente esperando resposta.
|
||||
banco.passagens_de_atendimento = [passagem()];
|
||||
|
||||
await rodar();
|
||||
|
||||
expect(auditou).toHaveBeenCalledTimes(1);
|
||||
expect(auditou.mock.calls[0]?.[0]).toMatchObject({
|
||||
action: "ai.passagem_parada_cobrada",
|
||||
metadata: { cobradas: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("o braço dos CASOS continua de pé — o segundo não substituiu o primeiro", async () => {
|
||||
// Guarda de regressão: os dois braços vivem na mesma rota e a varredura de
|
||||
// casos é o motivo de ela existir. Quebrá-la ao acrescentar a de passagens
|
||||
// seria trocar um buraco por outro.
|
||||
banco.agent_cases = [
|
||||
{
|
||||
id: "c1",
|
||||
organization_id: ORG,
|
||||
title: "Cliente quer trocar o produto",
|
||||
opened_at: ONTEM,
|
||||
updated_at: ONTEM,
|
||||
followup_attempts: 0,
|
||||
status: "awaiting_human",
|
||||
},
|
||||
];
|
||||
|
||||
const { body } = await rodar();
|
||||
|
||||
expect(body.data?.avisados).toBe(1);
|
||||
expect(
|
||||
registro.inserts.find((i) => i.tabela === "agent_inbox_items")?.linha,
|
||||
).toMatchObject({ kind: "case_stale", ref_kind: "agent_case" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { requireRole } from "@/lib/auth/require-role";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { nomesDosAtendentes } from "@/lib/users/nome-do-atendente";
|
||||
|
||||
/**
|
||||
* A ROTA QUE ALIMENTA O CARTÃO DA PASSAGEM — e por que ela lê com o client da
|
||||
* SESSÃO.
|
||||
*
|
||||
* ═══ O que está em jogo ═══
|
||||
*
|
||||
* A linha de `passagens_de_atendimento` carrega o que a IA concluiu sobre uma
|
||||
* pessoa: o que ela quer, o que já foi tentado, as palavras dela. A policy da
|
||||
* tabela (0291) exige TRÊS condições para ler — organização, papel `agent`+ e
|
||||
* `fn_can_view_conversation`. O service role bypassa RLS: uma leitura com admin
|
||||
* aqui entregaria o briefing de um atendimento que a política de visibilidade
|
||||
* não deixa a pessoa abrir. O link já é protegido; o TEXTO só é protegido se o
|
||||
* client for o da sessão.
|
||||
*
|
||||
* É exatamente o defeito que a Central tem e que esta entrega contornou pelo
|
||||
* outro lado (o corpo do aviso ficou curto, sem conversa). Repeti-lo aqui
|
||||
* desfaria aquilo.
|
||||
*
|
||||
* ═══ O que este arquivo NÃO prova ═══
|
||||
*
|
||||
* Que a RLS funciona — isso é `tests/invariants/passagem-isolamento-e-
|
||||
* visibilidade.test.ts`, contra um Postgres de verdade. Aqui se prova que a
|
||||
* rota ENTREGA a leitura ao client que tem RLS, que é a condição de aquilo
|
||||
* valer.
|
||||
*/
|
||||
|
||||
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/users/nome-do-atendente", () => ({ nomesDosAtendentes: vi.fn() }));
|
||||
const logError = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { error: logError, info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const ORG = "org-1";
|
||||
const CONVERSA = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
type Linha = Record<string, unknown>;
|
||||
|
||||
interface Espiao {
|
||||
tabelas: string[];
|
||||
limites: number[];
|
||||
colunas: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dublê que aplica `eq`, `order` e `limit`. Ignorá-los não seria simplificação:
|
||||
* um dublê que ignora `eq` deixa passar uma rota que lê a tabela inteira e a
|
||||
* asserção de isolamento fica verde sem medir nada.
|
||||
*/
|
||||
function bancoFalso(
|
||||
porTabela: Record<string, Linha[]>,
|
||||
opcoes: { erroEm?: string; espiao?: Espiao } = {},
|
||||
) {
|
||||
const from = (tabela: string) => {
|
||||
opcoes.espiao?.tabelas.push(tabela);
|
||||
let rows = [...(porTabela[tabela] ?? [])];
|
||||
let limite = Infinity;
|
||||
const chain = {
|
||||
select: (cols?: string) => {
|
||||
if (cols) opcoes.espiao?.colunas.push(cols);
|
||||
return chain;
|
||||
},
|
||||
eq: (col: string, val: unknown) => ((rows = rows.filter((l) => l[col] === val)), chain),
|
||||
order: (col: string, o: { ascending: boolean }) => {
|
||||
rows = [...rows].sort(
|
||||
(x, y) => String(x[col]).localeCompare(String(y[col])) * (o.ascending ? 1 : -1),
|
||||
);
|
||||
return chain;
|
||||
},
|
||||
limit: (n: number) => {
|
||||
limite = n;
|
||||
opcoes.espiao?.limites.push(n);
|
||||
return chain;
|
||||
},
|
||||
maybeSingle: () =>
|
||||
Promise.resolve({
|
||||
data: opcoes.erroEm === tabela ? null : (rows[0] ?? null),
|
||||
error: opcoes.erroEm === tabela ? { message: "boom" } : null,
|
||||
}),
|
||||
then: (res: (v: unknown) => unknown) =>
|
||||
Promise.resolve({
|
||||
data: opcoes.erroEm === tabela ? null : rows.slice(0, limite),
|
||||
error: opcoes.erroEm === tabela ? { message: "boom" } : null,
|
||||
}).then(res),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
return { from } as never;
|
||||
}
|
||||
|
||||
function passagem(over: Linha = {}): Linha {
|
||||
return {
|
||||
id: "p1",
|
||||
organization_id: ORG,
|
||||
conversation_id: CONVERSA,
|
||||
origem: "pedido_explicito",
|
||||
motivo_codigo: "requested_human",
|
||||
title: "desconto",
|
||||
body: "a narrativa",
|
||||
notes: "me passa pra uma pessoa",
|
||||
content: null,
|
||||
tentativas: [],
|
||||
cliente_avisado: true,
|
||||
aviso_motivo_codigo: null,
|
||||
caso_id: null,
|
||||
criado_em: "2026-09-18T10:00:00.000Z",
|
||||
reconhecido_em: null,
|
||||
reconhecido_por: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function chamaRota() {
|
||||
const { GET } = await import("@/app/api/v1/conversations/[id]/passagens/route");
|
||||
const res = await GET(new NextRequest(`http://x/api/v1/conversations/${CONVERSA}/passagens`), {
|
||||
params: Promise.resolve({ id: CONVERSA }),
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as { data?: Linha[]; error?: { code: string; message: string } },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
logError.mockReset();
|
||||
vi.mocked(createAdminClient).mockReset();
|
||||
vi.mocked(nomesDosAtendentes).mockResolvedValue(new Map());
|
||||
vi.mocked(requireRole).mockResolvedValue({
|
||||
ok: true,
|
||||
user: { id: "u-eu", idioma: "pt-BR" },
|
||||
org: { orgId: ORG, name: "Org", role: "agent" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/v1/conversations/[id]/passagens", () => {
|
||||
it("devolve as passagens daquela conversa, em ordem cronológica", async () => {
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso({
|
||||
conversations: [{ id: CONVERSA, organization_id: ORG }],
|
||||
passagens_de_atendimento: [
|
||||
passagem({ id: "p2", criado_em: "2026-09-18T11:00:00.000Z" }),
|
||||
passagem({ id: "p1", criado_em: "2026-09-18T10:00:00.000Z" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { status, body } = await chamaRota();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.data?.map((l) => l.id)).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("a leitura dos DADOS usa o client da sessão — o admin não toca a tabela", async () => {
|
||||
// A policy da tabela é o que faz `visibility_mode` valer para o TEXTO.
|
||||
// `createAdminClient` bypassa RLS; se ele aparecer aqui, o briefing de um
|
||||
// atendimento de outra pessoa é entregue a qualquer `agent` da organização.
|
||||
const espiao: Espiao = { tabelas: [], limites: [], colunas: [] };
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso(
|
||||
{
|
||||
conversations: [{ id: CONVERSA, organization_id: ORG }],
|
||||
passagens_de_atendimento: [passagem()],
|
||||
},
|
||||
{ espiao },
|
||||
),
|
||||
);
|
||||
|
||||
await chamaRota();
|
||||
|
||||
expect(espiao.tabelas).toContain("passagens_de_atendimento");
|
||||
expect(vi.mocked(createAdminClient)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("conversa de outra organização devolve 404, e não uma lista vazia", async () => {
|
||||
// Lista vazia com 200 diria "esta conversa não teve passagem nenhuma" sobre
|
||||
// uma conversa que não é desta organização — vazando a existência dela.
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso({
|
||||
conversations: [{ id: CONVERSA, organization_id: "org-2" }],
|
||||
passagens_de_atendimento: [passagem()],
|
||||
}),
|
||||
);
|
||||
|
||||
const { status, body } = await chamaRota();
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(body.error?.code).toBe("not_found");
|
||||
});
|
||||
|
||||
it("papel abaixo de `agent` nem chega ao banco", async () => {
|
||||
vi.mocked(requireRole).mockResolvedValue({
|
||||
ok: false,
|
||||
response: new Response(JSON.stringify({ error: { code: "forbidden", message: "não" } }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
} as never);
|
||||
const chamouOBanco = vi.fn();
|
||||
vi.mocked(createClient).mockImplementation((() => {
|
||||
chamouOBanco();
|
||||
return Promise.resolve(bancoFalso({}));
|
||||
}) as never);
|
||||
|
||||
const { status } = await chamaRota();
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(chamouOBanco).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("há teto de linhas — uma conversa patológica não devolve a tabela inteira", async () => {
|
||||
const espiao: Espiao = { tabelas: [], limites: [], colunas: [] };
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso(
|
||||
{
|
||||
conversations: [{ id: CONVERSA, organization_id: ORG }],
|
||||
passagens_de_atendimento: [passagem()],
|
||||
},
|
||||
{ espiao },
|
||||
),
|
||||
);
|
||||
|
||||
await chamaRota();
|
||||
|
||||
expect(espiao.limites.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...espiao.limites)).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("o nome de quem assumiu é resolvido, e vem junto da linha", async () => {
|
||||
vi.mocked(nomesDosAtendentes).mockResolvedValue(new Map([["u-joana", "Joana"]]));
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso({
|
||||
conversations: [{ id: CONVERSA, organization_id: ORG }],
|
||||
passagens_de_atendimento: [
|
||||
passagem({ reconhecido_em: "2026-09-18T11:00:00.000Z", reconhecido_por: "u-joana" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { status, body } = await chamaRota();
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.data?.[0]?.reconhecido_por_nome).toBe("Joana");
|
||||
});
|
||||
|
||||
it("sem nome resolvido a linha ainda diz que HÁ dono — o nome é cortesia", async () => {
|
||||
// Self-host sem service role devolve mapa vazio, por decisão declarada em
|
||||
// `lib/users/nome-do-atendente.ts`. Cair para `reconhecido_por: null` aqui
|
||||
// faria o cartão ler a passagem como DEVOLVIDA ao automático.
|
||||
vi.mocked(nomesDosAtendentes).mockResolvedValue(new Map());
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso({
|
||||
conversations: [{ id: CONVERSA, organization_id: ORG }],
|
||||
passagens_de_atendimento: [
|
||||
passagem({ reconhecido_em: "2026-09-18T11:00:00.000Z", reconhecido_por: "u-joana" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { body } = await chamaRota();
|
||||
|
||||
expect(body.data?.[0]?.reconhecido_por).toBe("u-joana");
|
||||
expect(body.data?.[0]?.reconhecido_por_nome).toBeNull();
|
||||
});
|
||||
|
||||
it("erro do banco vira 500 sem devolver a mensagem crua", async () => {
|
||||
vi.mocked(createClient).mockResolvedValue(
|
||||
bancoFalso(
|
||||
{
|
||||
conversations: [{ id: CONVERSA, organization_id: ORG }],
|
||||
passagens_de_atendimento: [passagem()],
|
||||
},
|
||||
{ erroEm: "passagens_de_atendimento" },
|
||||
),
|
||||
);
|
||||
|
||||
const { status, body } = await chamaRota();
|
||||
|
||||
expect(status).toBe(500);
|
||||
expect(JSON.stringify(body)).not.toContain("boom");
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,3 +29,51 @@ describe("mergeThreadItems", () => {
|
||||
expect(mergeThreadItems([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A PASSAGEM É O TERCEIRO TIPO DE ITEM DO FIO.
|
||||
*
|
||||
* Ela não é mensagem (não foi para o cliente) e não é nota (ninguém a escreveu),
|
||||
* mas entra no fio pelo mesmo mecanismo — e a posição dela no tempo é o que faz
|
||||
* o cartão aparecer onde o olho está: logo depois da última fala do cliente, que
|
||||
* é a fala que a causou.
|
||||
*/
|
||||
describe("mergeThreadItems — a passagem entra no fio", () => {
|
||||
const cartao = (id: string, criadoEm: string) => ({ id, criadoEm }) as never;
|
||||
|
||||
it("o cartão entra pelo `criadoEm`, no meio das mensagens", () => {
|
||||
const msgs = [
|
||||
{ id: "m1", sent_at: "2026-09-18T10:00:00Z" },
|
||||
{ id: "m2", sent_at: "2026-09-18T10:05:00Z" },
|
||||
] as never;
|
||||
const out = mergeThreadItems(msgs, [], [cartao("p1", "2026-09-18T10:02:00Z")]);
|
||||
expect(out.map((i) => i.data.id)).toEqual(["m1", "p1", "m2"]);
|
||||
expect(out[1]!.kind).toBe("passagem");
|
||||
});
|
||||
|
||||
it("no MESMO instante da mensagem que a causou, o cartão vem DEPOIS dela", () => {
|
||||
// A passagem é consequência da última fala: mostrá-la antes inverteria a
|
||||
// causa na leitura de quem chega, e o empate de timestamp é o caso comum —
|
||||
// o motor grava as duas no mesmo turno.
|
||||
const msgs = [{ id: "m1", sent_at: "2026-09-18T10:00:00Z" }] as never;
|
||||
const notes = [{ id: "n1", created_at: "2026-09-18T10:00:00Z" }] as never;
|
||||
const out = mergeThreadItems(msgs, notes, [cartao("p1", "2026-09-18T10:00:00Z")]);
|
||||
expect(out.map((i) => i.data.id)).toEqual(["m1", "n1", "p1"]);
|
||||
});
|
||||
|
||||
it("o terceiro argumento é OPCIONAL — conversa que nunca saiu do automático não tem nenhuma", () => {
|
||||
// Guarda de compatibilidade: o fio existe desde antes da passagem, e os dois
|
||||
// chamadores de duas linhas acima continuam válidos.
|
||||
const msgs = [{ id: "m1", sent_at: "2026-09-18T10:00:00Z" }] as never;
|
||||
expect(mergeThreadItems(msgs, []).map((i) => i.kind)).toEqual(["message"]);
|
||||
});
|
||||
|
||||
it("só passagens, sem mensagem nenhuma, ainda é um fio com conteúdo", () => {
|
||||
// O fio vazio mostra "Nenhuma mensagem nesta conversa."; se a passagem não
|
||||
// contasse como item, uma conversa cujo histórico já foi expurgado abriria
|
||||
// dizendo que não há nada, com um cartão de passagem existindo no banco.
|
||||
const out = mergeThreadItems([], [], [cartao("p1", "2026-09-18T10:00:00Z")]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.kind).toBe("passagem");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user