Merge da main no #1720: une a poda do golden_candidates com a dos rascunhos (#1719)

O #1719 entrou na main mexendo no mesmo cron data-retention. Uniao das duas:
PodaDb/drenar, campos de ResultadoDaRetencao, houveEfeito, avisos, as duas
chaves no .env.example e no lib/env.ts, e o fixture `base` dos testes. A poda
dos rascunhos (entrou primeiro na main) fica como a decima; a dos candidatos
ao golden set vira a decima primeira.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
melgarafael
2026-09-26 14:00:07 -03:00
co-authored by Claude Opus 5.5
20 changed files with 923 additions and 40 deletions
@@ -0,0 +1,7 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O rascunho sugerido por integração passa a ser apagado 30 dias depois de vencer
---
O rascunho que outro sistema cria na conversa, o texto sugerido para revisar antes de enviar, guarda uma mensagem escrita para uma pessoa. Depois de vencido ele não abre nem pode ser usado, mas ficava guardado para sempre. Agora a limpeza diária (`data-retention`) apaga o rascunho 30 dias depois do vencimento (mínimo de 7), usado ou não. O que foi enviado continua na conversa, e a criação e o uso continuam na auditoria. Nada a fazer na VPS; o prazo muda com `DRAFT_RETENTION_DAYS` no `.env`. Contribuição de @webtecnica (#1719).
@@ -0,0 +1,13 @@
---
titulo: "O seletor de modelo do atendente não oferece mais modelo de busca, e o fim do onboarding só diz que o atendente está no ar quando ele está"
impacto: nada_mudou
secao: corrigido
---
**Quatro correções de tela achadas numa jornada real de dono de clínica.** A lista de modelos do atendente (IA › Agentes › Modelo) deixa de oferecer o modelo de busca do material (Text Embedding), que não conversa: agora só aparecem modelos que usam as ferramentas do CRM, a mesma regra que o sistema já usava para escolher o modelo sozinho. Um agente novo passa a nascer no provedor de IA que a organização já usa, em vez de sempre em Anthropic.
O diálogo de publicar uma versão fala português: diz a empresa pelo nome, conta os caracteres a mais ou a menos do prompt e explica que a versão anterior continua no histórico; na primeira publicação, diz que é a primeira. E a última página do onboarding pergunta ao banco se há atendente publicado: quem pulou o passo da IA ou deixou o atendente em rascunho vê "Quase lá!" e o que falta, em vez de "Tudo pronto! Seu funcionário já está de pé".
Não há ação para quem opera a VPS: nenhum dado é reescrito.
Contribuição de @webtecnica (#1718, #1694).
+7
View File
@@ -462,6 +462,13 @@ PROSPECCAO_RETENTION_DAYS=365
# deixar o Jev decidir esta tarefa?". Piso de 30 dias — a janela da concordância
# no cartão —, aplicado dentro da função do banco.
JEV_OBSERVACOES_RETENTION_DAYS=90
# Idade a partir da qual um RASCUNHO sugerido por integração já VENCIDO é
# apagado (conversation_drafts). O relógio conta do `expires_at`, não da
# criação: depois do vencimento o link não abre e o consumo é recusado, e o
# texto é proposta que ninguém enviou. Trinta dias é o prazo de apurar "o link
# chegou?"; depois disso a trilha de auditoria responde sem guardar o texto.
# Piso de 7 dias (valor menor é elevado, com aviso no log).
DRAFT_RETENTION_DAYS=30
# Idade a partir da qual um candidato ao golden set (near-miss de skill e
# divergência classificador×modelo — rótulo, sem texto de cliente) é apagado.
# Três meses: a pergunta que a linha responde é de curadoria recente. Piso de 30
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { createClient } from "@/lib/supabase/server";
import type { AuthUser } from "@/lib/auth/types";
/**
* O SELETOR DE MODELO DO ATENDENTE NÃO OFERECE MODELO DE BUSCA.
*
* MEDIDO numa instalação fresca (issue #1694, item 1): IA › Agentes › Modelo
* listava "Text Embedding 3 Small" — o modelo que indexa o material do RAG,
* que não conversa. Um leigo escolhia e ficava com um atendente mudo.
*
* Esta rota é a ÚNICA fonte do `ModelPicker`, então o filtro mora aqui e
* alcança todos os seletôres de uma vez. A régua é `supports_tools`, a mesma
* que `escolherModeloDoProvedor` usa para escolher o modelo sozinho: sem
* ferramenta o modelo devolve texto plausível e nada chega ao funil.
*
* O filtro é em memória (e não `eq` no banco) de propósito: o dublê abaixo
* devolve a lista inteira, como o PostgREST devolveria sem where — assim o
* teste enxerga a REGRA. Com `eq("supports_tools", true)` o verde seria do
* dublê, não do código.
*/
vi.mock("@/lib/auth/server", () => ({ loadAuthUser: vi.fn(), resolveActiveOrg: vi.fn() }));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
const ORG_ID = "33333333-3333-4333-8333-333333333333";
/** Uma linha de `ai_models` — só o que a rota lê. */
interface LinhaDeModelo {
id: string;
provider: string;
model_id: string;
display_name: string;
supports_tools: boolean;
is_default_for_provider: boolean;
}
/** O que o catálogo de `openai` devolve hoje, incluindo a linha do defeito. */
const CATALOGO: LinhaDeModelo[] = [
{
id: "m1",
provider: "openai",
model_id: "gpt-5-mini",
display_name: "GPT-5 Mini",
supports_tools: true,
is_default_for_provider: true,
},
{
id: "m2",
provider: "openai",
model_id: "text-embedding-3-small",
display_name: "Text Embedding 3 Small",
supports_tools: false,
is_default_for_provider: false,
},
{
id: "m3",
provider: "openai",
model_id: "text-embedding-3-large",
display_name: "Text Embedding 3 Large",
supports_tools: false,
is_default_for_provider: false,
},
];
/**
* Dublê do cliente Supabase: uma query builder encadeável E thenável, porque a
* rota faz `await supabase.from(...).select(...).eq(...).is(...).order(...)`.
* Devolve o catálogo inteiro — é o banco, não o filtro, quem manda.
*/
function stubDoBanco(linhas: LinhaDeModelo[]) {
return {
from(tabela: string) {
if (tabela !== "ai_models") throw new Error(`tabela inesperada: ${tabela}`);
const cadeia: Record<string, unknown> = {};
for (const metodo of ["select", "eq", "is", "order", "limit"]) {
cadeia[metodo] = () => cadeia;
}
cadeia.then = (aoOk: unknown, aoErr: unknown) =>
Promise.resolve({ data: linhas, error: null }).then(aoOk as never, aoErr as never);
return cadeia;
},
};
}
function autorizado() {
vi.mocked(loadAuthUser).mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
email: "dono@example.com",
full_name: null,
avatar_url: null,
is_platform_admin: false,
idioma: "pt-BR",
} as unknown as AuthUser);
vi.mocked(resolveActiveOrg).mockResolvedValue({
orgId: ORG_ID,
role: "admin",
} as unknown as Awaited<ReturnType<typeof resolveActiveOrg>>);
}
function listar(provider = "openai") {
const req = new NextRequest(`http://localhost/api/v1/ai/providers/${provider}/models`);
return import("./route").then(({ GET }) =>
GET(req, { params: Promise.resolve({ provider }) }),
);
}
describe("GET /api/v1/ai/providers/:provider/models — o que o agente pode escolher", () => {
beforeEach(() => {
vi.clearAllMocks();
autorizado();
vi.mocked(createClient).mockResolvedValue(
stubDoBanco(CATALOGO) as unknown as Awaited<ReturnType<typeof createClient>>,
);
});
it("não oferece modelo de busca ao atendente", async () => {
const res = await listar();
expect(res.status).toBe(200);
const corpo = (await res.json()) as { data: { models: LinhaDeModelo[] } };
const ids = corpo.data.models.map((m) => m.model_id);
expect(ids, "embedding entrou na lista de modelos do agente").toEqual(["gpt-5-mini"]);
expect(ids).not.toContain("text-embedding-3-small");
expect(JSON.stringify(corpo)).not.toContain("Text Embedding");
});
it("o padrão do provedor continua vindo primeiro", async () => {
const res = await listar();
const corpo = (await res.json()) as { data: { models: LinhaDeModelo[] } };
expect(corpo.data.models[0]?.is_default_for_provider).toBe(true);
});
it("catálogo só com modelos de busca devolve lista vazia, não o embedding", async () => {
vi.mocked(createClient).mockResolvedValue(
stubDoBanco(CATALOGO.filter((m) => !m.supports_tools)) as unknown as Awaited<
ReturnType<typeof createClient>
>,
);
const res = await listar();
const corpo = (await res.json()) as { data: { models: LinhaDeModelo[] } };
// Lista vazia é o estado em que o `ModelPicker` cai no campo de texto —
// melhor do que oferecer um modelo que não conversa.
expect(corpo.data.models).toEqual([]);
});
it("provedor que a lista não conhece continua 404", async () => {
const res = await listar("provedor-fantasma");
expect(res.status).toBe(404);
});
});
@@ -53,5 +53,23 @@ export async function GET(
return fail("internal_error", "Erro ao listar modelos.", 500, { requestId });
}
return ok({ models: data ?? [] }, { requestId });
// UM MODELO DE BUSCA NÃO É UM ATENDENTE.
//
// O catálogo é o mesmo que alimenta os pontos de índice/busca do RAG, então
// ele traz `text-embedding-3-small` — modelo que só converte texto em
// vetor. Era oferecido no seletor "Modelo" do agente (IA › Agentes › Modelo),
// e quem o escolhia ficava com um atendente mudo: embedding não conversa.
//
// `supports_tools` é a MESMA régua que `escolherModeloDoProvedor`
// (`lib/ai/agents/escolher-modelo.ts`) já usa para escolher o modelo do
// atendente e que `validarBinding` aplica no painel: sem ferramenta o modelo
// devolve texto plausível e nada chega ao funil. Filtrar aqui é filtrar em
// todos os seletôres — esta rota é a única fonte do `ModelPicker`.
//
// O filtro é em memória de propósito: são no máximo centenas de linhas, e
// assim o teste da rota enxerga a regra (um `eq` no banco o esconderia do
// dublê, que devolve a lista inteira).
const models = (data ?? []).filter((m) => m.supports_tools === true);
return ok({ models }, { requestId });
}
+94 -5
View File
@@ -74,6 +74,8 @@ import {
RETENCAO_PASSAGEM_DIAS_PISO,
RETENCAO_PROSPECCAO_DIAS_PADRAO,
RETENCAO_PROSPECCAO_DIAS_PISO,
RETENCAO_RASCUNHO_DIAS_PADRAO,
RETENCAO_RASCUNHO_DIAS_PISO,
interpretarRetencao,
} from "@/lib/retencao/politica";
import {
@@ -134,6 +136,11 @@ export interface ResultadoDaRetencao {
observacoes_do_jev_apagadas: number;
lotes_observacoes_do_jev: number;
observacoes_do_jev_tem_resto: boolean;
/** O rascunho sugerido por integração já vencido (migration 0419, issue #1686). */
rascunhos_apagados: number;
lotes_rascunhos: number;
rascunhos_tem_resto: boolean;
retencao_rascunho_dias: number;
/** O candidato ao golden set vencido — rótulo, sem texto de cliente (0428). */
candidatos_do_golden_apagados: number;
lotes_candidatos_do_golden: number;
@@ -167,6 +174,17 @@ export interface PodaDb {
| "fn_expurgar_candidatos_do_golden",
args: { p_retencao_dias: number; p_limite: number },
): Promise<{ data: number | null; error: { message: string } | null }>;
/**
* A DÉCIMA poda é a única que não é `security definer`: a tabela 0419 nunca
* teve função de expurgo, então o corte (`expires_at` mais velho que o prazo)
* é calculado em TypeScript e chega AQUI pronto — a mesma exceção declarada
* para a captação em `lib/retencao/politica.ts`. Mesmo contrato de retorno do
* `rpc`: contagem ou erro, nunca silêncio.
*/
apagarRascunhos(
vencidosAntesDe: string,
lote: number,
): Promise<{ data: number | null; error: { message: string } | null }>;
}
async function drenar(
@@ -202,6 +220,38 @@ async function drenar(
return { apagadas, lotes, temResto: true };
}
/**
* A décima poda (issue #1686): o rascunho sugerido por integração já vencido.
*
* Mesmo laço de `drenar` — par no lote incompleto, teto por invocação, `temResto`
* quando o teto fecha — e a MESMA regra de erro: sobe. O que é diferente é a
* origem do corte, e ela é o motivo deste helper existir em vez de mais uma
* entrada na união do `rpc`: `conversation_drafts` não tem função de expurgo,
* então `p_retencao_dias` não existe para onde ir, e o corte nasce aqui,
* já convertido em instante absoluto (`expires_at` mais velho que o prazo).
*
* O relógio é `expires_at`, nunca `created_at` (como o espelho da agenda corta
* por `ends_at`): uma janela de 72 h pede 72 h de janela, e cortar pela criação
* apagaria rascunho que AINDA ABRIRIA o link.
*/
async function drenarRascunhos(
db: PodaDb,
dias: number,
): Promise<{ apagadas: number; lotes: number; temResto: boolean }> {
const corte = new Date(Date.now() - dias * 86_400_000).toISOString();
let apagadas = 0;
let lotes = 0;
for (let i = 0; i < MAX_LOTES; i += 1) {
const { data, error } = await db.apagarRascunhos(corte, TAMANHO_DO_LOTE);
if (error) throw new Error(`conversation_drafts: ${error.message}`);
const n = data ?? 0;
lotes += 1;
apagadas += n;
if (n < TAMANHO_DO_LOTE) return { apagadas, lotes, temResto: false };
}
return { apagadas, lotes, temResto: true };
}
/**
* Separado do handler HTTP para o teste exercitar a REGRA (o laço de lotes, o
* teto, o corte no lote incompleto) sem montar request/auth — mesmo desenho de
@@ -218,6 +268,7 @@ export async function podarHistorico(
CASE_ALERT_RETENTION_DAYS?: string;
PROSPECCAO_RETENTION_DAYS?: string;
JEV_OBSERVACOES_RETENTION_DAYS?: string;
DRAFT_RETENTION_DAYS?: string;
GOLDEN_CANDIDATES_RETENTION_DAYS?: string;
},
): Promise<ResultadoDaRetencao> {
@@ -268,6 +319,12 @@ export async function podarHistorico(
piso: RETENCAO_OBSERVACOES_DO_JEV_DIAS_PISO,
});
const rascunho = interpretarRetencao(ambiente.DRAFT_RETENTION_DAYS, {
chave: "DRAFT_RETENTION_DAYS",
padrao: RETENCAO_RASCUNHO_DIAS_PADRAO,
piso: RETENCAO_RASCUNHO_DIAS_PISO,
});
const candidatosDoGolden = interpretarRetencao(ambiente.GOLDEN_CANDIDATES_RETENTION_DAYS, {
chave: "GOLDEN_CANDIDATES_RETENTION_DAYS",
padrao: RETENCAO_CANDIDATOS_GOLDEN_DIAS_PADRAO,
@@ -309,8 +366,13 @@ export async function podarHistorico(
// Nona poda: as observações do Jev (0421). Padrão 90 / piso 30, a janela da
// concordância que o cartão mostra — o piso mora no CORPO da função.
const observacoesDrenadas = await drenar(db, "fn_expurgar_observacoes_do_jev", observacoesDoJev.dias);
// Décima poda: o candidato ao golden set (0428, issue #1695). Padrão 90 /
// piso 30, a janela em que o near-miss ainda é curável — o piso mora no
// Décima poda: o rascunho sugerido por integração já VENCIDO (migration 0419,
// issue #1686). A única que não passa pelo `rpc` — a tabela 0419 não tem
// função de expurgo, e o corte (`expires_at` + prazo) nasce em TypeScript,
// mesma exceção da captação. Piso de 7 dias mora AQUI, no interpretador.
const rascunhosDrenados = await drenarRascunhos(db, rascunho.dias);
// Décima primeira poda: o candidato ao golden set (0428, issue #1695).
// Padrão 90 / piso 30, a janela em que o near-miss ainda é curável — o piso mora no
// CORPO da função, como nas irmãs. A linha é rótulo, sem texto de cliente.
const candidatosDrenados = await drenar(db, "fn_expurgar_candidatos_do_golden", candidatosDoGolden.dias);
@@ -342,6 +404,9 @@ export async function podarHistorico(
avisos_de_caso_tem_resto: avisosDeCaso.temResto,
prospeccao_tem_resto: prospeccaoDrenada.temResto,
observacoes_do_jev_tem_resto: observacoesDrenadas.temResto,
rascunhos_apagados: rascunhosDrenados.apagadas,
lotes_rascunhos: rascunhosDrenados.lotes,
rascunhos_tem_resto: rascunhosDrenados.temResto,
candidatos_do_golden_tem_resto: candidatosDrenados.temResto,
retencao_fila_dias: fila.dias,
retencao_auditoria_dias: auditoria.dias,
@@ -351,6 +416,7 @@ export async function podarHistorico(
retencao_aviso_de_caso_dias: avisoDeCaso.dias,
retencao_prospeccao_dias: prospeccao.dias,
retencao_observacoes_do_jev_dias: observacoesDoJev.dias,
retencao_rascunho_dias: rascunho.dias,
retencao_candidatos_do_golden_dias: candidatosDoGolden.dias,
avisos: [
fila.aviso,
@@ -361,6 +427,7 @@ export async function podarHistorico(
avisoDeCaso.aviso,
prospeccao.aviso,
observacoesDoJev.aviso,
rascunho.aviso,
candidatosDoGolden.aviso,
].filter((a): a is string => a !== null),
};
@@ -403,9 +470,14 @@ export function houveEfeito(resultado: ResultadoDaRetencao): boolean {
// A nona, pela mesma razão: poda que apagou sem deixar trilha é
// encolhimento silencioso.
resultado.observacoes_do_jev_apagadas > 0 ||
// A décima, pela mesma razão das nove: uma rodada que só apagou candidato
// ao golden set vencido apagaria linha sem deixar registro — encolhimento
// silencioso.
// A décima, pela mesma razão das nove anteriores: uma rodada que só apagou
// rascunho vencido apagaria linhas e não deixaria registro. E esta é a
// única que apaga TEXTO escrito para uma pessoa — silenciar aqui seria
// apagar dado pessoal sem trilha.
resultado.rascunhos_apagados > 0 ||
// A décima primeira, pela mesma razão das dez: uma rodada que só apagou
// candidato ao golden set vencido apagaria linha sem deixar registro —
// encolhimento silencioso.
resultado.candidatos_do_golden_apagados > 0
);
}
@@ -435,6 +507,22 @@ async function handle(req: NextRequest): Promise<Response> {
const { data, error } = await admin.rpc(nome as never, args as never);
return { data: typeof data === "number" ? data : null, error };
},
// A décima poda: DELETE do admin client, sem função de expurgo no banco.
// Lote curto, transação fechada a cada rodada, e o retorno `select("id")`
// é a CONTAGEM que o relatório e `houveEfeito` usam. O `.order("id")` NÃO
// é enfeite: o PostgREST 12.2 recusa `limit` sem `order` num DELETE
// (400 PGRST109), e aqui o erro sobe e derruba a rodada inteira,
// inclusive a retomada da cascata de LGPD que vem depois.
async apagarRascunhos(vencidosAntesDe, lote) {
const { data, error } = await admin
.from("conversation_drafts")
.delete()
.lt("expires_at", vencidosAntesDe)
.select("id")
.order("id")
.limit(lote);
return { data: Array.isArray(data) ? data.length : null, error };
},
};
resultado = await podarHistorico(db, {
JOB_QUEUE_RETENTION_DAYS: env.JOB_QUEUE_RETENTION_DAYS,
@@ -444,6 +532,7 @@ async function handle(req: NextRequest): Promise<Response> {
CASE_ALERT_RETENTION_DAYS: env.CASE_ALERT_RETENTION_DAYS,
PROSPECCAO_RETENTION_DAYS: env.PROSPECCAO_RETENTION_DAYS,
JEV_OBSERVACOES_RETENTION_DAYS: env.JEV_OBSERVACOES_RETENTION_DAYS,
DRAFT_RETENTION_DAYS: env.DRAFT_RETENTION_DAYS,
GOLDEN_CANDIDATES_RETENTION_DAYS: env.GOLDEN_CANDIDATES_RETENTION_DAYS,
});
// ── A cascata de anonimização que ficou pela metade ──────────────────
@@ -103,6 +103,16 @@ interface BaseProps {
* conseguia salvar nada.
*/
provedoresDaInstalacao?: string[];
/**
* O provedor que a organização já usa — `organizations.settings.llm.provider`,
* lido pela página de CRIAÇÃO junto com as credenciais.
*
* É o defeito do "agente novo já nasce Anthropic": o formulário oferecia
* `anthropic` (e "Cadastrar credencial anthropic") para uma organização cuja
* única chave é da OpenAI. Aqui só o valor chega; quem lê `settings` é a
* página server component, do mesmo jeito que as credenciais.
*/
provedorPadrao?: string;
channelSessions: ChannelSessionLite[];
routerMembership?: { routerId: string; routerName: string } | null;
readOnly?: boolean;
@@ -206,17 +216,42 @@ const DEFAULT_TRIGGER: TriggerValue = {
concurrency: "one_per_conversation",
};
function buildState(args: {
/**
* O provedor inicial de um agente que ainda não tem versão.
*
* Só a lista que o seletor OFERECE vale como resposta: `settings.llm` é jsonb
* gravado por várias telas, e um id que `PROVEDORES` não conhece cairia num
* `<Select>` sem opção correspondente — o campo abrindo em branco e o
* formulário pedindo para escolher de novo. Fora da lista, `anthropic` (o que
* o seed da instalação sempre teve).
*/
export function provedorInicial(provedorPadrao?: string): Provider {
if (provedorPadrao && PROVEDORES.some((p) => p.id === provedorPadrao)) {
return provedorPadrao as Provider;
}
return "anthropic";
}
export function buildState(args: {
agent?: AgentRow;
version: AgentVersionRow | null;
t: (texto: string) => string;
/**
* O provedor que a ORGANIZAÇÃO já usa (`organizations.settings.llm.provider`).
*
* Sem isto, um agente NOVO nascia `anthropic` — e o formulário mostrava
* "Cadastrar credencial anthropic" para uma organização que só tem chave da
* OpenAI. A escolha passa a herdar o que a instalação já decidiu; o `anthropic`
* continua sendo o último degrau, para instalação que ainda não escolheu nada.
*/
provedorPadrao?: string;
}): FormState {
const { agent, version, t } = args;
const { agent, version, t, provedorPadrao } = args;
return {
name: agent?.name ?? "",
description: agent?.description ?? "",
priority: agent?.priority ?? 0,
provider: (version?.provider as Provider) ?? "anthropic",
provider: (version?.provider as Provider) ?? provedorInicial(provedorPadrao),
model: version?.model ?? "",
// `null` gravado = a versão usa a chave da instalação. Sem esta tradução,
// reabrir o agente mostraria o campo em branco e pediria para escolher de novo.
@@ -327,7 +362,7 @@ export function AgentForm(props: Props) {
const ref = props.base ?? props.draft ?? props.published;
return buildState({ agent: props.agent, version: ref, t });
}
return buildState({ version: null, t });
return buildState({ version: null, t, provedorPadrao: props.provedorPadrao });
}, [isEdit, props, t]);
const [form, setForm] = React.useState<FormState>(baseline);
@@ -13,6 +13,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useT } from "@/hooks/i18n/useT";
import { PROVEDORES } from "@/lib/ai/pontos/provedores";
import type { AgentVersionRow } from "@/hooks/ai/useAgentVersions";
interface Props {
@@ -30,6 +31,19 @@ function diffArr(prev: string[], next: string[]) {
return { added, removed };
}
/**
* `openai` → "OpenAI (GPT)".
*
* A caixa mostrava o id cru (`Provider: openai`) — inglês de manual, e um nome
* que o dono da clínica não reconhece. O rótulo é o MESMO da lista que ele já
* viu na tela de Credenciais; quem não estiver nessa lista (id semeado por
* migration antiga, provedor renomeado) cai no próprio id, que é melhor do que
* sumir com a informação.
*/
function rotuloDoProvedor(id: string): string {
return PROVEDORES.find((p) => p.id === id)?.rotulo ?? id;
}
export function PublishConfirmDialog({
open,
onOpenChange,
@@ -54,17 +68,25 @@ export function PublishConfirmDialog({
{draft.version_number}?
</AlertDialogTitle>
<AlertDialogDescription>
{t("Esta versão se tornará a ativa no atendimento. A versão atual (")}
{published ? `v${published.version_number}` : t("nenhuma")}
{t(") será marcada como superseded.")}
{published ? (
<>
{t("Esta versão se tornará a ativa no atendimento. A versão atual (")}
{`v${published.version_number}`}
{t(") continua guardada no histórico, mas deixa de atender.")}
</>
) : (
t("Esta versão se tornará a ativa no atendimento. É a primeira publicação deste agente.")
)}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 rounded-md border border-border/60 p-3 text-xs">
{providerChanged ? (
<p>
<strong>{t("Provider:")}</strong>{" "}
{published ? `${published.provider} → ${draft.provider}` : draft.provider}
<strong>{t("Empresa:")}</strong>{" "}
{published
? `${rotuloDoProvedor(published.provider)} → ${rotuloDoProvedor(draft.provider)}`
: rotuloDoProvedor(draft.provider)}
</p>
) : null}
{modelChanged ? (
@@ -86,9 +108,9 @@ export function PublishConfirmDialog({
<p>
<strong>{t("Prompt:")}</strong>{" "}
{promptDeltaChars > 0
? `+${promptDeltaChars} ${t("chars")}`
? `${promptDeltaChars} ${t("caracteres a mais")}`
: promptDeltaChars < 0
? `${promptDeltaChars} ${t("chars")}`
? `${Math.abs(promptDeltaChars)} ${t("caracteres a menos")}`
: t("sem alteração")}
</p>
</div>
+9 -1
View File
@@ -40,7 +40,11 @@ export default async function NewAgentPage() {
}
const supabase = await createClient();
const [credentialsRes, channelSessions] = await Promise.all([
const [orgRes, credentialsRes, channelSessions] = await Promise.all([
// O provedor que a organização JÁ usa: sem ele, o agente novo nascia
// `anthropic` e o formulário pedia "Cadastrar credencial anthropic" para
// quem só tem chave da OpenAI.
supabase.from("organizations").select("settings").eq("id", activeOrg.orgId).maybeSingle(),
supabase
.from("ai_provider_credentials_safe")
.select(CREDENTIAL_COLUMNS)
@@ -49,6 +53,9 @@ export default async function NewAgentPage() {
]);
const credentials = (credentialsRes.data ?? []) as unknown as CredentialRow[];
const llmDaOrg = (
orgRes.data?.settings as { llm?: { provider?: string } } | null
)?.llm;
return (
<div className="flex h-full flex-col gap-6 p-6">
@@ -56,6 +63,7 @@ export default async function NewAgentPage() {
mode="create"
credentials={credentials}
provedoresDaInstalacao={provedoresDaInstalacao()}
provedorPadrao={llmDaOrg?.provider}
channelSessions={channelSessions}
organizationTimezone={fusoUtilizavel(activeOrg.timezone)}
/>
+35 -6
View File
@@ -12,23 +12,52 @@ import type { PecaDoSistema } from "@/lib/onboarding/o-que-mais-existe";
export function DoneClient({
itens,
pecas,
noAr,
}: {
itens: ItemDoResumo[];
pecas: PecaDoSistema[];
/**
* Existe ao menos um agente PUBLICADO nesta organização (não-arquivado, com
* `published_version_id`)? É a página que lê o banco e passa — este componente
* é cliente e não tem como perguntar sem um fetch que faria a tela piscar.
*/
noAr: boolean;
}) {
const t = useT();
const [pending, startTransition] = useTransition();
const pendentes = itens.filter((i) => !i.feito);
/**
* O passo da IA ficou de fora (pulado ou nunca feito)? Sem ele não há
* atendente treinado, e a frase antiga dizia "já está de pé" do mesmo jeito.
*/
const semIa = itens.some((i) => i.segmento === "setup-ai" && !i.feito);
/**
* A CONCORDÂNCIA COM A REALIDADE.
*
* O fim do wizard era uma frase fixa: "Tudo pronto! Seu funcionário já está
* de pé" — dita também para quem pulou o passo da IA e para quem ficou com o
* atendente em rascunho. O wizard prometia o que não tinha entregue, e a
* pessoa só descobria isso no primeiro cliente que ninguém respondeu.
*
* O que decide é o BANCO (`noAr`), não a contagem de pendências: publicar é
* o que coloca o atendente no ar, e pular um passo que não depende da IA
* (telefone, equipe) não tira ninguém do ar.
*/
const titulo = noAr ? t("Tudo pronto!") : t("Quase lá!");
const resumo = !noAr
? semIa
? t("O passo da IA ficou para depois: ele ainda não foi treinado nem colocado no ar.")
: t("Ele já foi treinado, mas o atendimento ainda não foi publicado — ele segue em rascunho.")
: pendentes.length === 0
? t("Seu funcionário está montado. Daqui em diante é só acompanhar.")
: t("Seu funcionário já está de pé. O que ficou para depois continua te esperando.");
return (
<div className="space-y-6 rounded-lg border bg-background p-6">
<div className="space-y-1 text-center">
<h2 className="text-2xl font-semibold tracking-tight">{t("Tudo pronto!")}</h2>
<p className="text-sm text-muted-foreground">
{pendentes.length === 0
? t("Seu funcionário está montado. Daqui em diante é só acompanhar.")
: t("Seu funcionário já está de pé. O que ficou para depois continua te esperando.")}
</p>
<h2 className="text-2xl font-semibold tracking-tight">{titulo}</h2>
<p className="text-sm text-muted-foreground">{resumo}</p>
</div>
<ul className="mx-auto max-w-sm space-y-2 text-left text-sm">
+16 -1
View File
@@ -2,6 +2,7 @@ import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { redirect } from "next/navigation";
import { loadOnboardingState } from "@/app/actions/onboarding/_shared";
import { resumoDoOnboarding } from "@/lib/onboarding/passos";
import { createClient } from "@/lib/supabase/server";
import { env } from "@/lib/env";
import { oQueMaisExiste } from "@/lib/onboarding/o-que-mais-existe";
import { DoneClient } from "./_client";
@@ -21,5 +22,19 @@ export default async function DonePage() {
// acusando a pessoa de não fazer o que ninguém lhe pediu.
const itens = resumoDoOnboarding(state, { lojaLigada: env.NUVEMSHOP_ENABLED });
return <DoneClient itens={itens} pecas={oQueMaisExiste()} />;
const supabase = await createClient();
// SE O ATENDENTE ESTÁ NO AR vem do banco, não das pendências: publicar é o
// que coloca o agente de pé, e a tela antiga inferia isso (mal) só de
// "ficou algo por fazer" — daí "Seu funcionário já está de pé" dito para quem
// pulou a IA e para quem ficou com o rascunho.
const { data: publicados } = await supabase
.from("ai_agents")
.select("id")
.eq("organization_id", activeOrg.orgId)
.is("archived_at", null)
.not("published_version_id", "is", null)
.limit(1);
const noAr = (publicados ?? []).length > 0;
return <DoneClient itens={itens} pecas={oQueMaisExiste()} noAr={noAr} />;
}
+8
View File
@@ -409,6 +409,14 @@ const schema = z.object({
* 30 (a janela da concordância).
*/
JEV_OBSERVACOES_RETENTION_DAYS: z.string().optional().default(""),
/**
* Rascunho sugerido por integração JÁ VENCIDO (`conversation_drafts`,
* migration 0419, issue #1686). `z.string()` pela MESMA razão das irmãs
* acima — quem interpreta é `lib/retencao/politica.ts`, onde lixo resolve
* para o lado seguro. Padrão 30, piso 7, contados do `expires_at` (a linha
* só responde enquanto a janela dela está aberta).
*/
DRAFT_RETENTION_DAYS: z.string().optional().default(""),
/**
* Candidatos ao golden set (migration 0428, issue #1695): rótulo de near-miss
* e de divergência, sem texto de cliente. `z.string()` pela MESMA razão das
+16 -4
View File
@@ -1703,14 +1703,19 @@ export const DICIONARIO: Traducoes = {
"Esta versão se tornará a ativa no atendimento. A versão atual (": {
es: "Esta versión será la activa en la atención. La versión actual (",
},
") será marcada como superseded.": { es: ") quedará marcada como reemplazada." },
nenhuma: { es: "ninguna" },
"Provider:": { es: "Proveedor:" },
") continua guardada no histórico, mas deixa de atender.": {
es: ") sigue guardada en el historial, pero deja de atender.",
},
"Esta versão se tornará a ativa no atendimento. É a primeira publicação deste agente.": {
es: "Esta versión será la activa en la atención. Es la primera publicación de este agente.",
},
"Empresa:": { es: "Empresa:" },
"Modelo:": { es: "Modelo:" },
"Tools adicionadas:": { es: "Herramientas agregadas:" },
"Tools removidas:": { es: "Herramientas eliminadas:" },
"Prompt:": { es: "Prompt:" },
chars: { es: "caracteres" },
"caracteres a mais": { es: "caracteres a más" },
"caracteres a menos": { es: "caracteres a menos" },
"sem alteração": { es: "sin cambios" },
// ─── Agentes de IA: execuções e trace ───
Execução: { es: "Ejecución" },
@@ -6558,6 +6563,13 @@ export const DICIONARIO: Traducoes = {
"Seu funcionário já está de pé. O que ficou para depois continua te esperando.": {
es: "Tu empleado ya está en marcha. Lo que dejaste para después te sigue esperando.",
},
"Quase lá!": { es: "¡Ya casi!" },
"O passo da IA ficou para depois: ele ainda não foi treinado nem colocado no ar.": {
es: "El paso de la IA quedó para después: todavía no fue entrenado ni puesto en marcha.",
},
"Ele já foi treinado, mas o atendimento ainda não foi publicado — ele segue em rascunho.": {
es: "Ya fue entrenado, pero la atención aún no se publicó: sigue en borrador.",
},
"você pulou": { es: "lo omitiste" },
"ainda não": { es: "todavía no" },
"O que mais tem aqui": { es: "Qué más hay aquí" },
+32 -1
View File
@@ -211,6 +211,38 @@ export const RETENCAO_OBSERVACOES_DO_JEV_DIAS_PADRAO = 90;
*/
export const RETENCAO_OBSERVACOES_DO_JEV_DIAS_PISO = 30;
/**
* 30 dias para o RASCUNHO SUGERIDO POR INTEGRAÇÃO já vencido
* (`conversation_drafts`, migration 0419 / issue #1611; expurgo pedido na #1686).
*
* O relógio é `expires_at`, NUNCA `created_at` — a mesma decisão do espelho da
* agenda, e pela mesma razão: a linha só responde enquanto a janela dela está
* aberta. Depois do vencimento o link `?rascunho=` não abre, `consumirRascunho`
* recusa (`lib/inbox/rascunho-sugerido.ts`) e o texto é proposta que NINGUÉM
* enviou. Trinta dias é o prazo de apurar "o link chegou, por que não abriu?";
* depois disso o que houve de operação está na trilha
* (`conversation.draft_created` / `conversation.draft_used`), que responde sem
* guardar o texto da pessoa de novo.
*
* Quem aplica é o cron `data-retention` (a décima poda), em lotes, com este piso
* aplicado NO TYPESCRIPT: a poda é um DELETE do admin client
* (`app/api/v1/cron/data-retention/route.ts`), não uma `security definer` — não
* há função onde enfiar o piso, e é a MESMA exceção declarada para a captação
* acima. Por isso a cerca `tests/unit/retencao-todo-piso-tem-dono.test.ts` a
* lista em `SEM_FUNCAO_NO_SQL`, com esta razão escrita aqui.
*/
export const RETENCAO_RASCUNHO_DIAS_PADRAO = 30;
/**
* Piso de 7 dias CONTADOS DO VENCIMENTO — nunca do `created_at`.
*
* Sete dias é a janela em que "o link do rascunho não abriu" ainda é pergunta
* viva (o texto vale 24 h por padrão, `JANELA_PADRAO_HORAS`). Abaixo disso o
* knob viraria apagador de rastro de INCIDENTE; acima, nada se protegeria: a
* linha não tem leitor depois do vencimento, e apagá-la cedo ou tarde não muda
* o que a trilha de auditoria responde.
*/
export const RETENCAO_RASCUNHO_DIAS_PISO = 7;
/**
* 90 dias para os CANDIDATOS AO GOLDEN SET (`golden_candidates`, migration 0428).
*
@@ -231,7 +263,6 @@ export const RETENCAO_CANDIDATOS_GOLDEN_DIAS_PADRAO = 90;
*/
export const RETENCAO_CANDIDATOS_GOLDEN_DIAS_PISO = 30;
export interface RetencaoInterpretada {
/** Dias a pedir ao banco. Nunca abaixo do piso, nunca `NaN`. */
readonly dias: number;
@@ -0,0 +1,45 @@
/**
* O AGENTE NOVO NASCE NO PROVEDOR DA ORGANIZAÇÃO, NÃO EM ANTHROPIC.
*
* MEDIDO numa instalação fresca (issue #1694, item 2): `settings.llm.provider
* = openai` — a organização só tem chave da OpenAI —, e o formulário de agente
* novo abria em Anthropic (Claude), oferecendo "Cadastrar credencial
* anthropic" para quem não tem essa chave.
*
* Três degraus, um por caso: o provedor que a página passa (a organização),
* o que acontece quando ele não é opção nenhuma do seletor (o `anthropic` de
* sempre) e a precedência da versão existente (quem já tem versão não muda de
* cérebro ao reabrir o editor).
*/
import { describe, expect, it } from "vitest";
import { buildState, provedorInicial } from "@/app/app/ai/agents/[id]/_components/AgentForm";
import type { AgentVersionRow } from "@/hooks/ai/useAgentVersions";
const t = (texto: string) => texto;
describe("provedor inicial de um agente sem versão", () => {
it("herda settings.llm.provider da organização", () => {
const estado = buildState({ version: null, t, provedorPadrao: "openai" });
expect(estado.provider).toBe("openai");
});
it("sem provedor passado, continua nascendo anthropic", () => {
expect(provedorInicial(undefined)).toBe("anthropic");
expect(buildState({ version: null, t }).provider).toBe("anthropic");
});
it("provedor que o seletor não oferece cai no anthropic, não em branco", () => {
// `settings` é jsonb gravado por várias telas: um id fora de `PROVEDORES`
// viraria `<Select>` sem opção correspondente — o campo abrindo vazio e o
// formulário pedindo para escolher de novo.
expect(provedorInicial("provedor-que-nao-existe")).toBe("anthropic");
expect(provedorInicial("")).toBe("anthropic");
});
it("a versão existente continua mandando no provedor", () => {
const versao = { provider: "google" } as AgentVersionRow;
const estado = buildState({ version: versao, t, provedorPadrao: "openai" });
expect(estado.provider).toBe("google");
});
});
@@ -0,0 +1,93 @@
/**
* O DIÁLOGO "PUBLICAR V?" FALA PORTUGUÊS, NÃO JARGÃO DE MANUAL.
*
* MEDIDO na tela (issue #1694, item 3): a caixa dizia
* "A versão atual (nenhuma) será marcada como **superseded**", "**Provider:**
* openai" e "Prompt: **+65 chars**" — inglês e id cru para o dono da clínica.
*
* Este teste guarda as três frases novas E as três que sumiram: o vermelho
* abaixo é o jargão voltando à tela.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PublishConfirmDialog } from "@/app/app/ai/agents/[id]/_components/PublishConfirmDialog";
import type { AgentVersionRow } from "@/hooks/ai/useAgentVersions";
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (texto: string) => texto }));
function versao(campo: Partial<AgentVersionRow>): AgentVersionRow {
return {
id: "v7",
version_number: 7,
provider: "anthropic",
model: "claude-sonnet-5",
// 100 caracteres: o delta de 65 vem do rascunho de 165.
system_prompt: "p".repeat(100),
tool_ids: [],
...campo,
} as AgentVersionRow;
}
function abrir(draft: AgentVersionRow, published: AgentVersionRow | null) {
render(
<PublishConfirmDialog
open
onOpenChange={() => {}}
draft={draft}
published={published}
onConfirm={() => {}}
isPending={false}
/>,
);
const dialogo = screen.getByRole("alertdialog");
return dialogo.textContent ?? "";
}
describe("Publicar v? — o texto que o operador lê", () => {
it("explica a troca de versão sem a palavra superseded", () => {
const texto = abrir(versao({ version_number: 8 }), versao({}));
expect(texto, "jargão de status voltou para a tela").not.toMatch(/superseded/i);
expect(texto).toContain("A versão atual (v7) continua guardada no histórico, mas deixa de atender.");
});
it("sem versão publicada, diz que é a primeira e não fala de 'versão atual'", () => {
const texto = abrir(versao({ version_number: 1 }), null);
expect(texto).not.toMatch(/superseded/i);
expect(texto, "a v1 não tem versão atual para 'deixar de atender'").not.toContain("(nenhuma)");
expect(texto).not.toContain("deixa de atender");
expect(texto).toContain("É a primeira publicação deste agente.");
});
it("mostra a EMPRESA pelo nome que o operador conhece, não o id cru", () => {
const texto = abrir(
versao({ version_number: 8, provider: "openai", model: "gpt-5-mini" }),
versao({}),
);
expect(texto, "o rótulo em inglês voltou").not.toContain("Provider:");
expect(texto).not.toMatch(/\bprovider\b/i);
expect(texto).toContain("Empresa:");
expect(texto).toContain("Anthropic (Claude) → OpenAI (GPT)");
});
it("o delta do prompt vira 'caracteres a mais', não '+65 chars'", () => {
const texto = abrir(
versao({ version_number: 8, system_prompt: "p".repeat(165) }),
versao({}),
);
expect(texto, "'chars' voltou ao diálogo").not.toMatch(/chars/i);
expect(texto).not.toContain("+65");
expect(texto).toContain("65 caracteres a mais");
});
it("prompt encurtado conta para menos, sem sinal negativo solto", () => {
const texto = abrir(versao({ version_number: 8 }), versao({ system_prompt: "p".repeat(165) }));
expect(texto).toContain("65 caracteres a menos");
expect(texto).not.toContain("-65");
});
});
@@ -0,0 +1,93 @@
/**
* O FIM DO WIZARD NÃO AFIRMA O QUE NÃO ENTREGOU.
*
* MEDIDO na tela (issue #1694, item 4): a última página dizia "Tudo pronto!
* Seu funcionário já está de pé" para quem pulou o passo da IA e para quem
* ficou com o atendente em rascunho — a promessa feita antes da pessoa
* descobrir, no primeiro cliente, que ninguém estava respondendo.
*
* O que decide é `noAr` (a página lê `ai_agents.published_version_id` no
* banco), não a contagem de pendências: publicar é o que coloca o atendente
* no ar. Pular telefone/equipe sem pular a IA não tira ninguém do ar, e é
* justamente o caso em que a frase antiga continuava certa.
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DoneClient } from "@/app/onboarding/done/_client";
import type { ItemDoResumo } from "@/lib/onboarding/passos";
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (texto: string) => texto }));
vi.mock("@/app/actions/onboarding/finishOnboarding", () => ({
finishOnboarding: vi.fn(async () => ({ ok: true })),
}));
function item(segmento: string, campo: { feito?: boolean; pulado?: boolean } = {}): ItemDoResumo {
const { feito = true, pulado = false } = campo;
return { segmento, rotulo: segmento, feito, pulado };
}
/** Todos os passos cumpridos — inclusive a IA. */
function todosCumpridos(): ItemDoResumo[] {
return [
item("welcome"),
item("connect-whatsapp"),
item("setup-ai"),
item("funil"),
item("testar"),
item("invite-team"),
];
}
function tela(itens: ItemDoResumo[], noAr: boolean): string {
render(<DoneClient itens={itens} pecas={[]} noAr={noAr} />);
return document.body.textContent ?? "";
}
describe("a última página do wizard concorda com o banco", () => {
beforeEach(() => vi.clearAllMocks());
it("pulou a IA e o agente não foi publicado: não diz que está de pé", () => {
const itens = todosCumpridos().map((i) =>
i.segmento === "setup-ai" ? item("setup-ai", { feito: false, pulado: true }) : i,
);
const texto = tela(itens, false);
expect(texto, "a tela prometeu o que a pessoa pulou").not.toContain("já está de pé");
expect(texto).toContain("Quase lá!");
expect(texto).toContain(
"O passo da IA ficou para depois: ele ainda não foi treinado nem colocado no ar.",
);
});
it("treinado mas em rascunho: diz que o atendimento não foi publicado", () => {
const texto = tela(todosCumpridos(), false);
expect(texto).not.toContain("já está de pé");
expect(texto).toContain("Quase lá!");
expect(texto).toContain(
"Ele já foi treinado, mas o atendimento ainda não foi publicado — ele segue em rascunho.",
);
});
it("no ar e sem pendências: é a frase de sempre", () => {
const texto = tela(todosCumpridos(), true);
expect(texto).toContain("Tudo pronto!");
expect(texto).toContain("Seu funcionário está montado. Daqui em diante é só acompanhar.");
});
it("no ar com passo pulado que NÃO é a IA: segue de pé, porque está mesmo", () => {
const itens = todosCumpridos().map((i) =>
i.segmento === "connect-whatsapp" ? item("connect-whatsapp", { feito: false, pulado: true }) : i,
);
const texto = tela(itens, true);
expect(texto).toContain("Tudo pronto!");
expect(texto).toContain(
"Seu funcionário já está de pé. O que ficou para depois continua te esperando.",
);
});
});
@@ -56,10 +56,25 @@ let bancoDoCron: { cliente: ClienteDaCascata } | null = null;
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => {
const cascata = bancoDoCron?.cliente as unknown as { from: (t: string) => unknown };
// A DÉCIMA poda (issue #1686) também chama `from`, e pela MESMA regra do
// `rpc` logo acima: o que este arquivo mede é a varredura, e o poda não
// pode cair junto quando a cascata explode — é exatamente a asserção do
// caso ⭐ lá embaixo. Por isso `conversation_drafts` tem superfície própria
// aqui, sem passar por `bancoDoCron.cliente` (a que o caso faz explodir).
const rascunhosVazios = () => {
const q: Record<string, unknown> = {
lt: () => q,
select: () => q,
order: () => q,
limit: () => q,
then: (r: (v: unknown) => unknown) => Promise.resolve({ data: [], error: null }).then(r),
};
return { delete: () => q };
};
return {
// Poda sem nada a fazer: o que este arquivo mede é a varredura.
rpc: async () => ({ data: 0, error: null }),
from: (t: string) => cascata.from(t),
from: (t: string) => (t === "conversation_drafts" ? rascunhosVazios() : cascata.from(t)),
};
},
}));
@@ -104,12 +119,19 @@ function banco(linhas: Linha[]) {
const casar = (
filtros: Array<[string, unknown]>,
dentro: [string, string[]] | null,
faixas: Array<[string, string]> = [],
): Linha[] =>
linhas.filter((l) => {
if ((l.id.split(":")[0] ?? "") !== tabela) return false;
for (const [col, val] of filtros) {
if ((l as unknown as Record<string, unknown>)[col] !== val) return false;
}
// `.lt()` da DÉCIMA poda (issue #1686): o corte do rascunho vencido.
// `expires_at` é ISO-8601, e em ISO o lexical É o cronológico.
for (const [col, val] of faixas) {
const atual = (l as unknown as Record<string, string | undefined>)[col];
if (atual === undefined || !(atual < val)) return false;
}
// O `.in()` da cascata vem em DUAS colunas — `id` no UPDATE das
// atividades, `contact_id` na detecção em bloco. Um dublê que
// ignorasse a coluna casaria as duas na errada e daria verde falso.
@@ -122,10 +144,11 @@ function banco(linhas: Linha[]) {
});
const construir = (
modo: "select" | "update",
modo: "select" | "update" | "delete",
patch: Record<string, unknown>,
) => {
const filtros: Array<[string, unknown]> = [];
const faixas: Array<[string, string]> = [];
let dentro: [string, string[]] | null = null;
let teto: number | null = null;
const q: Record<string, unknown> = {
@@ -133,21 +156,37 @@ function banco(linhas: Linha[]) {
filtros.push([col, val]);
return q;
},
lt: (col: string, val: string) => {
faixas.push([col, val]);
return q;
},
in: (col: string, vals: string[]) => {
dentro = [col, vals];
return q;
},
select: () => q,
order: () => q,
limit: (n: number) => {
teto = n;
return q;
},
then: (r: (v: unknown) => unknown) => {
let achadas = casar(filtros, dentro);
let achadas = casar(filtros, dentro, faixas);
if (teto !== null) achadas = achadas.slice(0, teto);
if (modo === "update") {
aplicar(tabela, patch, achadas);
return Promise.resolve({ error: null }).then(r);
}
if (modo === "delete") {
// A décima poda apaga MESMO: sem remover da lista, "rodar duas
// vezes" mediria dublê amnésico em vez de idempotência — a
// segunda passada devolveria as mesmas linhas.
for (const alvo of achadas) {
const i = linhas.indexOf(alvo);
if (i >= 0) linhas.splice(i, 1);
}
return Promise.resolve({ data: achadas, error: null }).then(r);
}
return Promise.resolve({ data: achadas, error: null }).then(r);
},
};
@@ -157,6 +196,7 @@ function banco(linhas: Linha[]) {
return {
select: () => construir("select", {}),
update: (patch: Record<string, unknown>) => construir("update", patch),
delete: () => construir("delete", {}),
};
},
} as unknown as ClienteDaCascata;
+162 -7
View File
@@ -25,6 +25,8 @@ import {
RETENCAO_OBSERVACOES_DO_JEV_DIAS_PISO,
RETENCAO_PROSPECCAO_DIAS_PADRAO,
RETENCAO_PROSPECCAO_DIAS_PISO,
RETENCAO_RASCUNHO_DIAS_PADRAO,
RETENCAO_RASCUNHO_DIAS_PISO,
interpretarRetencao,
} from "@/lib/retencao/politica";
@@ -51,6 +53,13 @@ let respostaRpc: { data: number | null; error: { message: string } | null } = {
* varredura faz é medido em `lgpd-varredura-completa-a-cascata.test.ts`.
*/
let contatosAnonimizados: Array<{ id: string; organization_id: string }> = [];
/**
* As linhas que o DELETE da décima poda (`conversation_drafts`) devolve nesta
* rodada. Vazio por padrão: os casos deste arquivo medem a PODA das irmãs, e um
* expurgo com trabalho a fazer mudaria a contagem de auditoria. O caso em que
* ele apaga está no fim deste arquivo — é o que prova `houveEfeito` contando.
*/
let rascunhosApagados: Array<{ id: string }> = [];
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
rpc: async () => respostaRpc,
@@ -62,7 +71,25 @@ vi.mock("@/lib/supabase/admin", () => ({
then: (r: (v: unknown) => unknown) =>
Promise.resolve({ data: contatosAnonimizados, error: null }).then(r),
};
return { select: () => q, update: () => q };
// A superfície do DELETE da décima poda: `.delete().lt().select().order().limit()`.
// O dublê recusa `limit` sem `order` antes, como o PostgREST 12.2 recusa
// (400 PGRST109): tirar o `.order()` do handler reprova este arquivo.
let ordenado = false;
const apagando: Record<string, unknown> = {
lt: () => apagando,
select: () => apagando,
order: () => {
ordenado = true;
return apagando;
},
limit: () => {
if (!ordenado) throw new Error("PGRST109: A 'limit' was applied without an explicit 'order'");
return apagando;
},
then: (r: (v: unknown) => unknown) =>
Promise.resolve({ data: rascunhosApagados, error: null }).then(r),
};
return { select: () => q, update: () => q, delete: () => apagando };
},
}),
}));
@@ -90,17 +117,33 @@ vi.mock("@/lib/supabase/admin", () => ({
function bancoQueDevolve(sequencias: {
fila: number[];
auditoria: number[];
}): { db: PodaDb; chamadas: { nome: string; dias: number; limite: number }[] } {
/** A décima poda (issue #1686) — um lote por posição, como as irmãs. */
rascunhos?: number[];
}): {
db: PodaDb;
chamadas: { nome: string; dias: number; limite: number }[];
/** Os cortes que `apagarRascunhos` recebeu, em ordem — é a régua do relógio. */
cortes: string[];
} {
const chamadas: { nome: string; dias: number; limite: number }[] = [];
const restante = { fila: [...sequencias.fila], auditoria: [...sequencias.auditoria] };
const cortes: string[] = [];
const restante = {
fila: [...sequencias.fila],
auditoria: [...sequencias.auditoria],
rascunhos: [...(sequencias.rascunhos ?? [0])],
};
const db: PodaDb = {
async rpc(nome, args) {
chamadas.push({ nome, dias: args.p_retencao_dias, limite: args.p_limite });
const balde = nome === "fn_podar_fila_de_jobs" ? restante.fila : restante.auditoria;
return { data: balde.shift() ?? 0, error: null };
},
async apagarRascunhos(corte) {
cortes.push(corte);
return { data: restante.rascunhos.shift() ?? 0, error: null };
},
};
return { db, chamadas };
return { db, chamadas, cortes };
}
describe("interpretarRetencao — o knob nunca derruba o produto", () => {
@@ -225,11 +268,94 @@ describe("podarHistorico — o laço de lotes", () => {
async rpc() {
return { data: null, error: { message: "permission denied for table api_audit_log" } };
},
async apagarRascunhos() {
return { data: null, error: { message: "permission denied for table conversation_drafts" } };
},
};
await expect(podarHistorico(db, {})).rejects.toThrow(/permission denied/);
});
});
describe("a décima poda — o rascunho sugerido vencido (issue #1686)", () => {
it("sem knob, corta 30 dias atrás do VENCIMENTO e reporta o prazo", async () => {
// O relógio é `expires_at`, e isto se mede pelo CORTE: ele tem de estar ~30
// dias para trás, não "agora" (que seria cortar por `created_at` e apagar
// rascunho cuja janela ainda está aberta).
const { db, cortes } = bancoQueDevolve({ fila: [0], auditoria: [0] });
const antes = Date.now();
const r = await podarHistorico(db, {});
expect(cortes).toHaveLength(1);
const corte = Date.parse(cortes[0] as string);
const esperado = antes - RETENCAO_RASCUNHO_DIAS_PADRAO * 86_400_000;
expect(Math.abs(corte - esperado)).toBeLessThan(10_000);
expect(corte).toBeLessThan(antes - 29 * 86_400_000);
expect(r.retencao_rascunho_dias).toBe(RETENCAO_RASCUNHO_DIAS_PADRAO);
expect(r.rascunhos_apagados).toBe(0);
expect(r.lotes_rascunhos).toBe(1);
expect(r.rascunhos_tem_resto).toBe(false);
// `.env` intocado = caminho padrão de toda instalação: sem aviso.
expect(r.avisos).toEqual([]);
});
it("knob abaixo do piso é ELEVADO para 7, com aviso; lixo cai no padrão", async () => {
const baixo = bancoQueDevolve({ fila: [0], auditoria: [0] });
const elevado = await podarHistorico(baixo.db, { DRAFT_RETENTION_DAYS: "1" });
const corteElevado = Date.parse(baixo.cortes[0] as string);
expect(Math.abs(corteElevado - (Date.now() - RETENCAO_RASCUNHO_DIAS_PISO * 86_400_000))).toBeLessThan(
10_000,
);
expect(elevado.retencao_rascunho_dias).toBe(RETENCAO_RASCUNHO_DIAS_PISO);
expect(elevado.avisos).toEqual([expect.stringContaining("DRAFT_RETENTION_DAYS")]);
// "trezentos" é lixo, não escolha: cai no PADRÃO com aviso, nunca num
// número que o operador não escreveu.
const lixo = bancoQueDevolve({ fila: [0], auditoria: [0] });
const r = await podarHistorico(lixo.db, { DRAFT_RETENTION_DAYS: "trezentos" });
expect(r.retencao_rascunho_dias).toBe(RETENCAO_RASCUNHO_DIAS_PADRAO);
expect(r.avisos).toEqual([expect.stringContaining("DRAFT_RETENTION_DAYS")]);
});
it("para no lote incompleto e DECLARA resto quando o teto fecha", async () => {
const cheio = bancoQueDevolve({
fila: [0],
auditoria: [0],
rascunhos: Array.from({ length: MAX_LOTES + 3 }, () => TAMANHO_DO_LOTE),
});
const r = await podarHistorico(cheio.db, {});
expect(cheio.cortes).toHaveLength(MAX_LOTES);
expect(r.rascunhos_apagados).toBe(MAX_LOTES * TAMANHO_DO_LOTE);
expect(r.rascunhos_tem_resto).toBe(true);
const parcial = bancoQueDevolve({
fila: [0],
auditoria: [0],
rascunhos: [TAMANHO_DO_LOTE, 12],
});
const r2 = await podarHistorico(parcial.db, {});
expect(parcial.cortes).toHaveLength(2);
expect(r2.rascunhos_apagados).toBe(TAMANHO_DO_LOTE + 12);
expect(r2.rascunhos_tem_resto).toBe(false);
expect(r2.lotes_rascunhos).toBe(2);
});
it("erro do banco sobe — a décima poda também não engole falha", async () => {
// Mesmo contrato das irmãs: falha ABERTA na ação e ABERTA na informação.
// Uma poda que falha em silêncio vira "o rascunho não some e ninguém sabe
// por quê" seis meses depois.
const db: PodaDb = {
async rpc() {
return { data: 0, error: null };
},
async apagarRascunhos() {
return { data: null, error: { message: "permission denied for table conversation_drafts" } };
},
};
await expect(podarHistorico(db, {})).rejects.toThrow(/conversation_drafts/);
});
});
describe("houveEfeito — as duas direções", () => {
const base = {
jobs_apagados: 0,
@@ -271,7 +397,12 @@ describe("houveEfeito — as duas direções", () => {
lotes_observacoes_do_jev: 0,
observacoes_do_jev_tem_resto: false,
retencao_observacoes_do_jev_dias: RETENCAO_OBSERVACOES_DO_JEV_DIAS_PADRAO,
// Décima poda (migration 0428, issue #1695): o candidato ao golden set.
// Décima poda (issue #1686): o rascunho sugerido por integração vencido.
rascunhos_apagados: 0,
lotes_rascunhos: 0,
rascunhos_tem_resto: false,
retencao_rascunho_dias: RETENCAO_RASCUNHO_DIAS_PADRAO,
// Décima primeira poda (migration 0428, issue #1695): o candidato ao golden set.
candidatos_do_golden_apagados: 0,
lotes_candidatos_do_golden: 0,
candidatos_do_golden_tem_resto: false,
@@ -303,9 +434,17 @@ describe("houveEfeito — as duas direções", () => {
expect(houveEfeito({ ...base, observacoes_do_jev_apagadas: 1 })).toBe(true);
});
it("...e apagou rascunho vencido → TAMBÉM audita (issue #1686)", () => {
// A décima poda entra em `houveEfeito` NO MESMO commit em que entra no
// laço — a lição da quarta, da quinta e das demais. E é a única que apaga
// TEXTO escrito para uma pessoa: apagaria dado pessoal sem trilha.
expect(houveEfeito({ ...base, rascunhos_apagados: 1 })).toBe(true);
});
it("...e apagou candidato ao golden set vencido → TAMBÉM audita (0428)", () => {
// A décima poda entra em `houveEfeito` no MESMO commit em que entra no laço
// — é a mesma lição das nove anteriores: o predicado esquecido é mudo.
// A décima primeira poda entra em `houveEfeito` no MESMO commit em que
// entra no laço — a mesma lição das dez anteriores: o predicado esquecido
// é mudo.
expect(houveEfeito({ ...base, candidatos_do_golden_apagados: 1 })).toBe(true);
});
@@ -407,6 +546,7 @@ describe("o handler HTTP — a falha entra na trilha, o vazio não", () => {
beforeEach(() => {
auditou.mockClear();
contatosAnonimizados = [];
rascunhosApagados = [];
});
it("rodada que não apagou nada responde 200 e NÃO audita", async () => {
@@ -429,6 +569,21 @@ describe("o handler HTTP — a falha entra na trilha, o vazio não", () => {
});
});
it("rodada que só apagou RASCUNHO vencido também AUDITA (issue #1686)", async () => {
// A décima poda entra no laço e no relatório; sem ela nesta asserção, uma
// rodada que só expurgasse rascunho apagaria TEXTO de uma pessoa sem deixar
// registro — o silêncio que a doutrina proíbe.
respostaRpc = { data: 0, error: null };
rascunhosApagados = [{ id: "rascunho-1" }];
const resposta = await GET(requisicaoAutorizada());
expect(resposta.status).toBe(200);
expect(auditou).toHaveBeenCalledTimes(1);
expect(auditou.mock.calls[0]?.[0]).toMatchObject({
action: "retention.sweep_run",
metadata: { rascunhos_apagados: 1, jobs_apagados: 0 },
});
});
it("rodada que FALHOU responde 500 e AUDITA a falha", async () => {
// O laço de retorno: uma poda que parou de funcionar num clone (grants que
// não vieram no `update.sh`) não pode ficar idêntica, na trilha, a uma poda
@@ -77,6 +77,12 @@ const DONO_NO_SQL: Record<string, string> = {
*/
const SEM_FUNCAO_NO_SQL: Record<string, string> = {
CAPTACAO: "admin client",
// migration 0419 — o rascunho sugerido por integração (issue #1686). A décima
// poda do cron `data-retention` é um DELETE do admin client: a tabela nunca
// teve função de expurgo, então não há corpo onde enfiar o `greatest` do piso.
// A razão está escrita em `politica.ts`, ao lado da declaração — a captação é
// a mesma exceção, com a mesma frase.
RASCUNHO: "admin client",
};
function paresDeclarados(): string[] {