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:
Pessoa
2026-09-18 04:41:48 -03:00
co-authored by Claude Opus 5
parent f7523adc5a
commit 9113c5fae2
31 changed files with 3454 additions and 36 deletions
@@ -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;
},
};
},
};
+214 -1
View File
@@ -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;
+5
View File
@@ -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,
+68 -9
View File
@@ -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}
+13 -1
View File
@@ -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 && (
+308
View File
@@ -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": [
+9 -1
View File
@@ -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
+34 -6
View File
@@ -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
+5
View File
@@ -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",
+74
View File
@@ -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 ?? [];
}
+5 -1
View File
@@ -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." },
+16
View File
@@ -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. */
+3
View File
@@ -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
+240
View File
@@ -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 }),
};
});
}
+1 -1
View File
@@ -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)`. */
+52
View File
@@ -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." },
};
/**
+24
View File
@@ -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.
{
+18 -2
View File
@@ -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
+367
View File
@@ -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;
+1
View File
@@ -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/);
});
});
+332
View File
@@ -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();
});
});
+48
View File
@@ -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");
});
});