mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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:
@@ -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).
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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í" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user