feat(funil): a etapa pode avisar a equipe na Central quando um negócio entra nela

Migration 0436: crm_stages.avisar_na_central (boolean, default false).
Negócio que entra numa etapa marcada abre um aviso na Central (kind other,
ref lead, botão «Abrir negócio»), no idioma da organização e sem dado
pessoal no texto — só o nome da etapa. Chave por etapa no editor de
etapas, desligada por padrão. Falha do banco no handler conta tentativa
no dreno (error), não gira como reagendamento benigno (retry).

Numa venda com pagamento na entrega, o momento que pede ação da equipe é
o pedido confirmado, não o ganho (que só vem com a entrega).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
jmpo
2026-09-27 02:52:59 -03:00
co-authored by Claude Opus 5.5
parent 97bd5ab36c
commit bbb40e1183
24 changed files with 709 additions and 2 deletions
+17
View File
@@ -0,0 +1,17 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A etapa do funil pode avisar a equipe na Central quando um negócio entra nela
---
Em Configurações › Funis › Etapas, cada etapa ganhou a chave «Avisar a equipe
na Central quando um negócio entrar aqui». Ligada, todo negócio que entra
nela — pelo arrasto no quadro, pelo mover em lote, por uma automação ou pelo
assistente de IA — abre um aviso na Central com o botão «Abrir negócio».
Serve para o momento que pede ação e não é o fechamento: numa venda com
pagamento na entrega, o cliente confirmar o pedido com todos os dados é a hora
de separar e despachar, e o ganho só vem com a entrega. O aviso diz a etapa,
nunca o nome ou o telefone do cliente, sai no idioma da organização e não se
repete enquanto o anterior do mesmo negócio estiver aberto. Vem desligada em
todas as etapas: nada muda para quem não ligar. Contribuição de @jmpo.
+1
View File
@@ -737,6 +737,7 @@ jobs:
escopo-de-funil-do-agente.spec.ts
followup-tempo-adaptativo.spec.ts
gatilho-de-etapa.spec.ts
etapa-avisa-na-central.spec.ts
gatilho-de-caso.spec.ts
conversa-do-caso.spec.ts
aviso-de-caso-no-whatsapp.spec.ts
@@ -260,6 +260,9 @@ describe("GET /api/v1/pipelines/[id]/agent-mapping", () => {
name: "Novo",
is_won: false,
is_lost: false,
// Migration 0436: a tela de etapas lê daqui a chave «avisar na Central».
// Ausente na fixture = etapa anterior à coluna = desligada.
avisar_na_central: false,
last_change_actor_kind: null,
last_change_at: null,
});
@@ -90,6 +90,7 @@ const bodySchema = z.object({
* a regra do mapeamento carregar um campo que ela nunca lê.
*/
type EtapaComAutoria = EtapaDoMapa & {
avisar_na_central?: boolean | null;
last_change_actor_kind: string | null;
last_change_at: string | null;
};
@@ -114,7 +115,7 @@ async function lerFunil(
// consulta só para ela seria um round-trip por render numa tela de
// configuração — e um caminho a mais para a lista e a autoria divergirem.
.select(
"id, name, is_won, is_lost, win_probability, agent_stage_hint, last_change_actor_kind, last_change_at",
"id, name, is_won, is_lost, win_probability, agent_stage_hint, avisar_na_central, last_change_actor_kind, last_change_at",
)
.eq("organization_id", orgId)
.eq("pipeline_id", pipelineId)
@@ -147,6 +148,7 @@ function corpo(etapas: EtapaComAutoria[]) {
name: e.name,
is_won: e.is_won,
is_lost: e.is_lost,
avisar_na_central: e.avisar_na_central === true,
last_change_actor_kind: e.last_change_actor_kind ?? null,
last_change_at: e.last_change_at ?? null,
})),
@@ -51,6 +51,8 @@ const bodySchema = z
* para a mensagem sair em português, antes de tocar no banco.
*/
win_probability: z.number().int().min(0).max(100).nullable().optional(),
/** Negócio que entra nesta etapa abre um aviso na Central (migration 0436). */
avisar_na_central: z.boolean().optional(),
})
.strict()
.refine((b) => Object.keys(b).length > 0, { message: "Nada para alterar." });
@@ -602,3 +602,37 @@ describe("StagesSection — arquivar", () => {
expect(aviso).toHaveTextContent("Não deu para salvar");
});
});
describe("StagesSection — a etapa que avisa na Central (migration 0436)", () => {
it("a chave vem desligada e ligá-la manda só `avisar_na_central: true` para AQUELA etapa", async () => {
const user = userEvent.setup();
vi.mocked(apiClient.patch).mockResolvedValue({ data: { etapas: [] } });
montar();
const chave = await screen.findByTestId("avisar-e2");
expect(chave).toHaveAttribute("aria-checked", "false");
// Rótulo com o NOME da etapa: num funil de doze colunas, "Avisar" sozinho
// não diz qual chave é qual para quem usa leitor de tela.
expect(chave).toHaveAccessibleName(/«Aguardando pagamento»/);
await user.click(chave);
await waitFor(() => expect(apiClient.patch).toHaveBeenCalledTimes(1));
expect(vi.mocked(apiClient.patch).mock.calls[0]![0]).toContain(`/stages/e2`);
expect(vi.mocked(apiClient.patch).mock.calls[0]![1]).toEqual({ avisar_na_central: true });
});
it("a etapa marcada no servidor aparece ligada, e desligar manda `false`", async () => {
const user = userEvent.setup();
vi.mocked(apiClient.get).mockResolvedValue({
data: estado({}, ETAPAS.map((e) => (e.id === "e2" ? { ...e, avisar_na_central: true } : e))),
});
vi.mocked(apiClient.patch).mockResolvedValue({ data: { etapas: [] } });
montar();
const chave = await screen.findByTestId("avisar-e2");
expect(chave).toHaveAttribute("aria-checked", "true");
expect(screen.getByTestId("avisar-e1")).toHaveAttribute("aria-checked", "false");
await user.click(chave);
await waitFor(() => expect(apiClient.patch).toHaveBeenCalledTimes(1));
expect(vi.mocked(apiClient.patch).mock.calls[0]![1]).toEqual({ avisar_na_central: false });
});
});
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -497,6 +498,20 @@ export function StagesSection({
className={`etapa-autoria-${etapa.id}`}
/>
{/* Numa venda com pagamento na entrega o momento que pede ação é o
pedido confirmado, não o ganho — e quem sabe qual etapa é essa é
a organização. Ver a migration 0436. */}
<label className="flex items-center gap-2 text-xs text-text-muted">
<Switch
checked={etapa.avisar_na_central === true}
onCheckedChange={(v) => aplicar(etapa.id, { avisar_na_central: v })}
disabled={ocupado}
aria-label={`${t("Avisar a equipe na Central quando um negócio entrar em")} «${etapa.name}»`}
data-testid={`avisar-${etapa.id}`}
/>
{t("Avisar a equipe na Central quando um negócio entrar aqui")}
</label>
{passo && (
<p className="text-xs text-text-muted" data-testid={`passo-de-${etapa.id}`}>
{t("O assistente usa esta etapa para")} «{t(ROTULO_DO_PASSO[passo])}».{" "}
@@ -77,6 +77,20 @@
"col": 6,
"type": "service",
"label": "PATCH separado: resolver ou reabrir + auditoria"
},
{
"id": "etapa_que_avisa",
"lane": "humano",
"col": 0,
"type": "ui",
"label": "Funis › Etapas: «Avisar a equipe na Central» (crm_stages.avisar_na_central, 0436)"
},
{
"id": "aviso_de_etapa",
"lane": "app",
"col": 0,
"type": "service",
"label": "leads.aviso-de-etapa: lead.stage_changed → aviso other/lead"
}
],
"edges": [
@@ -124,6 +138,16 @@
"from": "status",
"to": "central",
"label": "reconsultar abertos/resolvidos"
},
{
"from": "etapa_que_avisa",
"to": "aviso_de_etapa",
"label": "marca lida a cada entrada na etapa"
},
{
"from": "aviso_de_etapa",
"to": "produtores",
"label": "um aviso por negócio e etapa, só o nome da etapa no texto"
}
]
}
+16
View File
@@ -3003,3 +3003,19 @@ Port do #1130 (@vgamkt), PR 3 de 4. Spec: `tests/e2e/fluxo-de-atendimento.spec.t
| J33.4 | Três mensagens pelo webhook do WAHA; a ficha mostra o roteiro «Concluído» com CPF e modelo (caixa medida por `boundingBox` e estilo computado) |
**NÃO coberto por esta spec:** o turno do agente roda com o worker e o modelo de verdade — no CI não há nenhum dos dois, e a spec chama as mesmas funções do motor (`prepararRoteiroDoTurno`, `garantirPerguntaDoRoteiro`) com o validador devolvendo `indefinido`. A pergunta enviada ao cliente pelo WhatsApp e a leitura pelo validador de modelo ficam para a prova do PR 4.
## Avisos que pedem gente — a etapa que avisa na Central `[P1]` (2026-09-27)
Migration 0436. Spec: `tests/e2e/etapa-avisa-na-central.spec.ts` (job e2e, parte 2). Organização, administrador, funil e negócio criados pela service role no Supabase local; o movimento do card pela rota do quadro com a sessão do usuário; o dreno do `event_log` chamado pela rota do cron.
| Caso | Esperado |
|---|---|
| AV.1 | Em Configurações › Funis, cada etapa mostra «Avisar a equipe na Central quando um negócio entrar aqui», desligada |
| AV.2 | Ligar a chave numa etapa grava só ela: recarregar a tela mostra a mesma coisa, e a etapa vizinha segue desligada |
| AV.3 | O negócio que entra na etapa marcada abre na Central «Negócio entrou em «<etapa>»», sem o nome do cliente |
| AV.4 | O negócio que entra numa etapa SEM a marca não abre aviso |
| AV.5 | «Abrir negócio» leva ao negócio dentro do funil (`/app/pipelines/<funil>?lead=<id>`) |
**NÃO coberto por esta spec:** o movimento pelo assistente de IA (o mesmo evento `lead.stage_changed`, emitido por `agent-stage-sync.ts`) e o arrasto do card com o mouse — os dois têm spec própria e chegam ao mesmo handler.
Evidência: `evidence/etapa-avisa-na-central/01-chave-ligada-na-etapa.png` (a chave ligada na etapa), `evidence/etapa-avisa-na-central/02-aviso-na-central.png` (o aviso na Central, sem o nome do cliente) e `evidence/etapa-avisa-na-central/03-abrir-negocio.png` (o negócio aberto pelo botão).
Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+2
View File
@@ -29,6 +29,8 @@ export interface EtapaDoFunil {
* sem calibração. `undefined` em leituras antigas em cache.
*/
win_probability?: number | null;
/** Negócio que entra aqui abre um aviso na Central (migration 0436). */
avisar_na_central?: boolean;
/** Quem mexeu nesta etapa por último (migration 0101). `null` antes dela. */
last_change_actor_kind?: string | null;
last_change_at?: string | null;
+2
View File
@@ -42,6 +42,8 @@ export interface PatchDeEtapa {
* previsão volta a reportar a etapa no balde "sem probabilidade".
*/
win_probability?: number | null;
/** Negócio que entra nesta etapa abre um aviso na Central (migration 0436). */
avisar_na_central?: boolean;
}
function useReler(pipelineId: string) {
+4
View File
@@ -24,6 +24,7 @@ import { mediaDeriveHandler } from "@/workers/media-derive-worker.handler";
import { webPushInboundHandler } from "@/lib/notifications/push.handler";
import { conversaoDeQualificacaoHandler } from "@/lib/conversoes/qualificacao.handler";
import { conversaoDeVendaHandler } from "@/lib/conversoes/envio.handler";
import { avisoDeEtapaHandler } from "@/lib/leads/aviso-de-etapa.handler";
import { avisoDeCasoAoSuporteHandler } from "@/lib/escalacao/aviso-ao-suporte.handler";
import { registerHandler } from "@/lib/event-log/dispatcher";
@@ -50,6 +51,9 @@ export function ensureHandlersRegistered(): void {
registerHandler(lgpdRedactHandler);
registerHandler(automationRulesHandler);
registerHandler(followupGatilhoEtapaHandler);
// Escrita curta no banco (um item na Central), vizinha do gatilho de etapa
// que consome o mesmo evento.
registerHandler(avisoDeEtapaHandler);
registerHandler(followupGatilhoLeadHandler);
registerHandler(followupGatilhoCasoHandler);
registerHandler(followupGatilhoPresencaHandler);
+11
View File
@@ -9030,6 +9030,17 @@ export const DICIONARIO: Traducoes = {
es: "Las respuestas a los clientes están esperando. Recarga el saldo en la cuenta del proveedor: salen solas cuando vuelva el saldo, durante hasta 6 horas. Después de eso, la conversación que no se respondió aparece aquí en la Central.",
},
"Revisar credencial": { es: "Revisar credencial" },
// ─── lib/leads/aviso-de-etapa.ts + editor de etapas (migration 0436) ───
"Negócio entrou em": { es: "Negocio entró en" },
"Abra o negócio para dar o próximo passo. Este aviso foi pedido na configuração da etapa.": {
es: "Abre el negocio para dar el siguiente paso. Este aviso se pidió en la configuración de la etapa.",
},
"Avisar a equipe na Central quando um negócio entrar aqui": {
es: "Avisar al equipo en la Central cuando un negocio entre aquí",
},
"Avisar a equipe na Central quando um negócio entrar em": {
es: "Avisar al equipo en la Central cuando un negocio entre en",
},
// `informativo` e `crítico` saem iguais nos dois idiomas — sem linha, por isso.
"atenção": { es: "atención" },
// ─── lib/ai/agent-inbox-copy.ts (copyDaPromessaSemDono) ───
+115
View File
@@ -0,0 +1,115 @@
/**
* Aviso na Central quando um negócio ENTRA numa etapa marcada para avisar
* (`crm_stages.avisar_na_central`, migration 0436).
*
* ─── Por que uma etapa, e não o ganho ───────────────────────────────────────
*
* O produto já avisa o ganho (`lead.won` → push ao dono do negócio). Numa
* operação de pagamento na entrega, porém, o ganho é a ENTREGA — e o momento
* que pede ação da equipe vem antes: o cliente confirmou o pedido com todos os
* dados e alguém precisa separar e despachar. Qual etapa é essa é decisão da
* organização; o código só obedece a marca.
*
* ─── Por que `kind = 'other'` e `ref_kind = 'lead'` ─────────────────────────
*
* `other` já existe no vocabulário do banco e já tem destino "Abrir negócio"
* (`POLITICAS_DE_AVISO.other` em `lib/ai/inbox-destino.ts`). Um kind novo
* exigiria reconstruir o CHECK de `agent_inbox_items`, que tem bloco único no
* baseline — custo sem ganho para um aviso que é, por natureza, genérico.
*
* ─── Sem dado pessoal no texto ──────────────────────────────────────────────
*
* O título do negócio costuma ser o nome ou o telefone do cliente. O aviso diz
* só a ETAPA e aponta para o negócio: quem abre vê o resto com a permissão que
* tem, e a anonimização (LGPD) não precisa alcançar esta linha.
*
* O texto sai no idioma da ORGANIZAÇÃO, porque a Central mostra título e corpo
* como foram gravados (`./aviso-de-etapa.ts`).
*/
import type { EventHandler, EventRow, HandlerResult } from "@/lib/event-log/dispatcher";
import { normalizarIdioma } from "@/lib/i18n/idiomas";
import { createAdminClient } from "@/lib/supabase/admin";
import { corpoDoAvisoDeEtapa, tituloDoAvisoDeEtapa } from "./aviso-de-etapa";
const CONSUMER_KEY = "leads.aviso-de-etapa";
const resultado = (status: HandlerResult["status"], detail?: string): HandlerResult => ({
consumer_key: CONSUMER_KEY,
status,
detail,
});
/**
* Falha do banco é `error`, não `retry`: o `retry` do dreno é o reagendamento
* BENIGNO (janela de envio, espera de rede) e não conta tentativa — uma falha
* que não passa ficaria girando para sempre. Com `error`, o dreno aplica o
* backoff, conta a tentativa e, no teto, marca o evento morto e avisa na
* Central (`lib/event-log/drain.ts`).
*/
const falhou = (detail: string): HandlerResult => ({ consumer_key: CONSUMER_KEY, status: "error", detail });
function texto(valor: unknown): string | null {
return typeof valor === "string" && valor.trim() ? valor : null;
}
async function handle(row: EventRow): Promise<HandlerResult> {
const leadId = row.entity_id;
const etapaId = texto(row.payload?.to_stage_id);
if (!leadId || !etapaId) return resultado("skipped", "sem_etapa_de_destino");
if (texto(row.payload?.from_stage_id) === etapaId) return resultado("skipped", "mesma_etapa");
const admin = createAdminClient();
// ⚠️ Organização junto do id: o client é service-role e ignora RLS.
const { data: etapa, error } = await admin
.from("crm_stages")
.select("name, avisar_na_central")
.eq("id", etapaId)
.eq("organization_id", row.organization_id)
.maybeSingle();
if (error) return falhou(`leitura da etapa falhou: ${error.message}`);
const lida = etapa as { name: string; avisar_na_central: boolean | null } | null;
if (!lida?.avisar_na_central) return resultado("skipped", "etapa_sem_aviso");
const { data: org } = await admin
.from("organizations")
.select("locale")
.eq("id", row.organization_id)
.maybeSingle();
const idioma = normalizarIdioma((org as { locale?: string | null } | null)?.locale);
const titulo = tituloDoAvisoDeEtapa(lida.name, idioma);
// Um aviso aberto por negócio e etapa: o mesmo evento reprocessado, ou um
// negócio que sai e volta antes de alguém ler, não empilha itens iguais.
const { data: jaAberto, error: erroDaBusca } = await admin
.from("agent_inbox_items")
.select("id")
.eq("organization_id", row.organization_id)
.eq("kind", "other")
.eq("ref_kind", "lead")
.eq("ref_id", leadId)
.eq("status", "open")
.eq("title", titulo)
.limit(1);
if (erroDaBusca) return falhou(`busca de aviso aberto falhou: ${erroDaBusca.message}`);
if ((jaAberto ?? []).length > 0) return resultado("skipped", "aviso_ja_aberto");
const { error: erroDoInsert } = await admin.from("agent_inbox_items").insert({
organization_id: row.organization_id,
kind: "other",
severity: "info",
title: titulo,
body: corpoDoAvisoDeEtapa(idioma),
ref_kind: "lead",
ref_id: leadId,
});
if (erroDoInsert) return falhou(`aviso não entrou na Central: ${erroDoInsert.message}`);
return resultado("ok", "aviso_aberto");
}
export const avisoDeEtapaHandler: EventHandler = {
key: CONSUMER_KEY,
events: ["lead.stage_changed"],
handle,
};
+28
View File
@@ -0,0 +1,28 @@
/**
* O texto do aviso de etapa (`crm_stages.avisar_na_central`, migration 0436).
*
* Módulo PURO, sem banco: quem escreve o aviso
* (`./aviso-de-etapa.handler.ts`) monta o título por aqui, e é por este mesmo
* título que o handler reconhece um aviso já aberto — as duas pontas não podem
* divergir.
*
* O título diz só a ETAPA, entre aspas angulares: o título do negócio costuma
* ser o nome ou o telefone do cliente, e a Central mostra o texto como foi
* gravado. Quem abre o aviso vê o negócio com a permissão que tem.
*/
import { traduzir } from "@/lib/i18n/dicionario";
import type { Idioma } from "@/lib/i18n/idiomas";
/** A chave do dicionário do começo do título. */
const INICIO_DO_TITULO = "Negócio entrou em";
export function tituloDoAvisoDeEtapa(etapa: string, idioma: Idioma): string {
return `${traduzir(INICIO_DO_TITULO, idioma)} «${etapa}»`;
}
export function corpoDoAvisoDeEtapa(idioma: Idioma): string {
return traduzir(
"Abra o negócio para dar o próximo passo. Este aviso foi pedido na configuração da etapa.",
idioma,
);
}
+9 -1
View File
@@ -51,7 +51,7 @@ export interface DepsDeEtapa {
/** As colunas que a tela e as regras usam. `position` entra: a reordenação calcula em cima dela. */
const COLUNAS =
"id, name, slug, position, is_won, is_lost, is_archived, win_probability, agent_stage_hint, last_change_actor_kind, last_change_at";
"id, name, slug, position, is_won, is_lost, is_archived, win_probability, agent_stage_hint, avisar_na_central, last_change_actor_kind, last_change_at";
/** A etapa como sai para quem lê — inclui a autoria da última mudança de configuração. */
export interface EtapaVisivel {
@@ -69,12 +69,15 @@ export interface EtapaVisivel {
* não aqui: gravar seria um segundo lugar para a mesma verdade divergir.
*/
win_probability: number | null;
/** Negócio que entra aqui abre um aviso na Central (migration 0436). */
avisar_na_central: boolean;
/** `user` | `ai` | `system` — `null` nas etapas anteriores a esta coluna. */
last_change_actor_kind: string | null;
last_change_at: string | null;
}
type EtapaLida = EtapaEditavel & {
avisar_na_central?: boolean | null;
last_change_actor_kind: string | null;
last_change_at: string | null;
};
@@ -128,6 +131,7 @@ export function corpo(etapas: EtapaLida[]): { etapas: EtapaVisivel[] } {
is_won: e.is_won,
is_lost: e.is_lost,
win_probability: e.win_probability ?? null,
avisar_na_central: e.avisar_na_central === true,
last_change_actor_kind: e.last_change_actor_kind ?? null,
last_change_at: e.last_change_at ?? null,
})),
@@ -299,6 +303,8 @@ export interface PedidoDeEdicao {
* duas divergiriam no primeiro ajuste.
*/
depois_de?: string | null;
/** Liga ou desliga o aviso na Central para quem entra nesta etapa (0436). */
avisar_na_central?: boolean;
}
export async function atualizarEtapa(
@@ -366,10 +372,12 @@ export async function atualizarEtapa(
name?: string;
position?: number;
win_probability?: number | null;
avisar_na_central?: boolean;
} = {};
if (pedido.name !== undefined) patchDoAlvo.name = pedido.name.trim();
// `undefined` não viaja; `null` limpa a calibração de propósito.
if (pedido.win_probability !== undefined) patchDoAlvo.win_probability = pedido.win_probability;
if (pedido.avisar_na_central !== undefined) patchDoAlvo.avisar_na_central = pedido.avisar_na_central;
if (pedido.depois_de !== undefined) {
// Só as ativas compõem a régua: arquivada não ocupa lugar no quadro.
+15
View File
@@ -40101,3 +40101,18 @@ alter table public.crm_stages
alter table public.crm_stages
add constraint crm_stages_win_probability_range
check (win_probability is null or win_probability between 0 and 100);
-- ---- a etapa que avisa a equipe na Central (migration 0436) ----
--
-- Marca por etapa, desligada por padrão: negócio que ENTRA numa etapa marcada
-- abre um aviso na Central (kind `other`, ref `lead`, botão «Abrir negócio»).
-- Quem lê é `lib/leads/aviso-de-etapa.handler.ts`, no evento
-- `lead.stage_changed`. Aditiva e idempotente: coluna com default, nenhuma
-- linha existente a corrigir antes.
alter table public.crm_stages
add column if not exists avisar_na_central boolean not null default false;
comment on column public.crm_stages.avisar_na_central is
'Negócio que entra nesta etapa abre um aviso na Central de avisos (0436).';
notify pgrst, 'reload schema';
@@ -0,0 +1,17 @@
-- 0436 — a etapa que AVISA a equipe na Central quando um negócio entra nela.
--
-- Numa operação de pagamento na entrega, o fechamento que importa para a equipe
-- não é o `is_won` (o dinheiro só entra quando o pedido é entregue): é o cliente
-- confirmar o pedido com todos os dados, porque é aí que alguém precisa separar
-- e despachar. Esse momento é uma ETAPA do funil, e quem sabe qual é a
-- organização — não o código. Daí uma marca por etapa, desligada por padrão.
--
-- Quem lê: `lib/leads/aviso-de-etapa.handler.ts` (evento `lead.stage_changed`),
-- que abre um item na Central apontando para o negócio.
--
-- Idempotente e aditiva: coluna com default, nada a corrigir antes.
alter table public.crm_stages
add column if not exists avisar_na_central boolean not null default false;
comment on column public.crm_stages.avisar_na_central is
'Negócio que entra nesta etapa abre um aviso na Central de avisos (0436).';
+1
View File
@@ -442,3 +442,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260926200200` | `0432_poda_de_midia` | **A retenção de mídia passa a existir.** `fn_enfileirar_midia_vencida(p_limite)` (security definer, só `service_role`) enfileira em `storage_redaction_queue` o arquivo de mensagem mais velho que `organizations.media_retention_days` (piso 30; zera `messages.media_storage_path`, a mensagem fica) e o arquivo órfão de `whatsapp-media` (pastas `org/<conversa>/` e `org/avatars/` sem referência, com um dia de carência; `org/templates/` nunca). O cron `storage-redaction` remove pelo Storage API. Medido em 23/09/2026: 1,30 GB órfãos levaram um Supabase gratuito à restrição 402. |
| `20260926223000` | `0434_reenfileiramento_de_midia` | **Reenfileirar um caminho de mídia que já saiu volta a funcionar; linha `deleted` antiga é expurgada (issue #1739, achado na triagem do #1731).** As DUAS inserções de `fn_enfileirar_midia_vencida` usavam `on conflict (bucket, object_path) do nothing` sobre um `unique (bucket, object_path)`, e o worker marca a linha como `deleted` sem nada a remover depois — dois defeitos: um arquivo NOVO gravado num caminho que já saiu era engolido em silêncio (nunca mais podado nem pela retenção nem pela LGPD) e a fila crescia sem teto. **Medido antes de consertar (#1739, passo 1): o reaproveitamento de `object_path` EXISTE** — `app/api/v1/cron/contact-avatars/route.ts:204` grava `${org}/avatars/${contactId}.jpg` com `upsert: true` (caminho estável por contato, regravado a cada refresh de 7 dias, enfileirado de novo pela cascata `lib/lgpd/redact-cascade.ts` e pelo próprio cron) e `workers/media-persist-worker.ts:122` usa `storagePathFor` = `{org}/{conversa}/{mensagem}.{ext}` (determinístico por mensagem, regravado no retry); os outros três emissores (`conversations/[id]/media`, `products/[id]/fotos`, `channels/partner/templates/media`) usam `randomUUID()` e nunca reaproveitam. Logo o item 1 da issue não é teórico e o conserto é o `on conflict ... do update`, não só o expurgo. **Regra:** `do update set status='pending', attempts=0, enqueued_at=now(), processed_at=null, error_message=null `where storage_redaction_queue.status in ('deleted','skipped')` — reabre SÓ o que já terminou; `pending` e `failed` em curso não são tocados (é o `where` que garante). O filtro de órfãos passa a contar só linha em curso como «já na fila» (`status not in ('deleted','skipped')`): sem isso o `not exists` segurava o caminho antes de o conflito acontecer e o `do update` jamais rodaria. **Expurgo:** `delete ... where status='deleted' and request_id is null and coalesce(processed_at, enqueued_at) < now() - 90 days` no CORPO da mesma função, ou seja, no cron diário `media-retention` — sem cron novo, sem coluna, sem índice, sem FK para `storage_redaction_queue` (medido no baseline). `failed` não é expurgada (a issue manda não mexer; é o registro de uma remoção que não passou das 3 tentativas). A linha `deleted` de pedido LGPD (`request_id` não nulo) também fica: é o único registro por objeto de que a mídia do titular saiu do bucket — o worker só troca o `status` e o cron `storage-redaction` não audita a remoção física (ajuste da triagem). Mesma assinatura (1 argumento) e par `revoke`/`grant` repetido; corpo EDITADO NO LUGAR no apêndice do `baseline.sql` (mesmo desenho das 0417/0426). Gate: `tests/invariants/poda-de-midia-reenfileiramento-medido.test.ts` (Postgres real: `deleted`/`skipped` reabrem, `pending`/`failed` ficam, expurgo dos 90 dias e a segunda rodada continua idempotente) e `tests/invariants/poda-de-midia-expurgo-preserva-lgpd.test.ts` (a linha `deleted` de pedido LGPD sobrevive ao expurgo). |
| `20260927015500` | `0435_contagem_do_expurgo_da_fila` | **A contagem do expurgo da fila aparece no retorno e na trilha (issue #1765, continuação da #1739).** A 0434 passou a expurgar, no CORPO de `fn_enfileirar_midia_vencida`, a linha `deleted` da RETENÇÃO com mais de 90 dias (`request_id is null`) — o DELETE está lá, roda todo dia, e a sua contagem não aparecia em lugar nenhum: a função devolvia só `{vencidas, orfas}`, e `app/api/v1/cron/media-retention` auditava `retention.sweep_run` só quando `vencidas + orfas > 0`. As duas lacunas são a MESMA linha de código vista de dois lados: **a rodada que só expurgou (as duas contagens zeradas) não auditava**, sendo que é a rodada em que a fila perde linhas de verdade; e a linha de rastro que existia não dizia quantas saíram da fila. **O conserto:** `get diagnostics v_expurgadas = row_count` logo após o DELETE do passo 0 e a chave `expurgadas` no `jsonb_build_object` de retorno — contagem de linha APAGADA nesta chamada, que é o que permite ao cron decidir se houve efeito (uma contagem do que «será» apagado não permitiria, porque o DELETE já aconteceu quando a função retorna). **No cron:** `expurgadas` entra no acumulado e na soma da condição de auditoria (`vencidas + orfas + expurgadas > 0`), e sai na metadata do `retention.sweep_run` — que é o que o runbook de custo do Supabase lê para saber quanto a fila perdeu. O expurgo NÃO entra na condição de PARADA do laço de tandas: ele é livre de `p_limite` e a segunda chamada da mesma rodada devolve 0 para ele de qualquer jeito, então parar por causa dele seria parar uma tanda antes de enfileirar o que ainda cabe. **O que NÃO muda:** janela de 90 dias, filtro (`status='deleted'`, `request_id is null`, `coalesce(processed_at, enqueued_at)`), ordem dos passos e as demais podas — `failed`, `skipped` e a linha de pedido LGPD continuam de fora, cada uma pelo motivo escrito no cabeçalho da 0434. Mesma assinatura (1 argumento) e par `revoke`/`grant` repetido. A 0434 está aplicada (1.54.0) e não é editada: o corpo no `baseline.sql` é EDITADO NO LUGAR do bloco dela (mesmo desenho das 0417/0426/0434), derivado deste arquivo, não reescrito. Gate: `tests/invariants/poda-de-midia-contagem-do-expurgo.test.ts` (Postgres real: a contagem bate com as linhas que saíram, a rodada que só expurgou devolve `vencidas`/`orfas` zerados com `expurgadas > 0`, a segunda rodada expurga 0, e o `toEqual` congelado do `poda-de-midia.test.ts:102` recebe o campo novo no objeto esperado, porque o `toEqual` do Vitest é exato EM CHAVE — a chave nova é aditiva em VALOR, não em chave, e sem essa edição o `test:invariants` saía != 0 exatamente nos três `toEqual` congelados que comparam o retorno inteiro (`:102`, e o irmão `poda-de-midia-reenfileiramento-medido.test.ts:150` e `:176`); a edição não afrouxa nada — os três continuam exatos e passam a fiscalizar a contagem do expurgo também, e a justificativa está no PR com a válvula `DESKCOMM_GOV_INVARIANTS_EDIT=1` exportada) e `tests/unit/cron-media-retention-audita-o-expurgo.test.ts` (a rota soma, audita a rodada que só expurgou e publica `expurgadas` na metadata). |
| `20260927020100` | `0436_etapa_avisa_na_central` | **A etapa pode avisar a equipe na Central quando um negócio entra nela.** `crm_stages.avisar_na_central boolean not null default false`. Numa venda com pagamento na entrega, o momento que pede ação da equipe é o cliente confirmar o pedido com os dados — uma etapa, não o `is_won` (o ganho só vem com a entrega). Qual etapa é essa é decisão da organização, daí a marca por etapa, desligada por padrão. Quem lê é `lib/leads/aviso-de-etapa.handler.ts`, no evento `lead.stage_changed`: abre um aviso `other` com `ref_kind = lead` (o destino «Abrir negócio» que a Central já tem), sem dado pessoal no texto — só o nome da etapa — e sem empilhar enquanto o anterior do mesmo negócio e etapa estiver aberto. **Por que `other` e não um kind novo:** um kind novo exigiria reconstruir o CHECK de `agent_inbox_items` no bloco único do baseline e na cadeia, para um aviso que é genérico por natureza. Aditiva e idempotente. |
+173
View File
@@ -0,0 +1,173 @@
/**
* A ETAPA QUE AVISA A EQUIPE NA CENTRAL, provada pela tela (migration 0436).
*
* Na ordem em que o dono de uma loja que vende pelo WhatsApp faria: em
* Configurações › Funis › Etapas ele liga «Avisar a equipe na Central quando um
* negócio entrar aqui» na etapa do pedido confirmado; um negócio entra nela; e
* a Central mostra o aviso com o botão «Abrir negócio», que leva ao negócio.
* Sem o nome nem o telefone do cliente no texto. E a etapa vizinha, sem a
* marca, não avisa nada.
*
* ⚠️ O QUE É REAL AQUI. O produtor (`lib/leads/aviso-de-etapa.handler.ts`) é
* event-driven: a rota de movimento emite `lead.stage_changed` no `event_log` e
* o dreno consome. Em produção esse dreno é um cron de um minuto; a spec chama
* a MESMA rota (`/api/v1/cron/event-log-drain`) com o segredo interno, em vez
* de esperar o relógio (o mesmo desenho de `gatilho-de-etapa.spec.ts`).
*
* ⚠️ O QUE É SETUP, e por que não é pela tela: organização, usuário, funil,
* contato e negócio são o cenário, criados pela service role no Supabase local.
* O movimento do card usa a rota do quadro com a sessão do próprio usuário —
* arrastar o card já tem spec própria. O que esta spec dirige pela tela é o
* que a mudança entregou: a chave da etapa e o aviso que ela produz.
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { createClient } from "@supabase/supabase-js";
import { test, expect, type Page } from "./helpers/test";
import { credenciaisSupabaseDeTeste } from "../../scripts/lib/env-de-teste";
const credenciais = credenciaisSupabaseDeTeste();
const db = createClient(credenciais.url, credenciais.serviceRole, { auth: { persistSession: false } });
const SEGREDO_DO_DRENO = process.env.INTERNAL_SECRET ?? "e2e-placeholder-nao-e-segredo";
// `evidence/` é VERSIONADO: evidência citada que não entra no repo é evidência
// que ninguém consegue conferir depois.
const EVIDENCIA = path.join(process.cwd(), "evidence", "etapa-avisa-na-central");
fs.mkdirSync(EVIDENCIA, { recursive: true });
const ESPERA = 60_000;
const SENHA = `Local-${randomUUID()}!`;
const SUFIXO = randomUUID().slice(0, 8);
const CLIENTE = `Maria Souza ${SUFIXO}`;
let userId = "";
let email = "";
let orgId = "";
let funilId = "";
let etapaNovo = "";
let etapaConfirmado = "";
let etapaEnviado = "";
let negocioId = "";
async function inserir(tabela: string, valor: Record<string, unknown>): Promise<string> {
const { data, error } = await db.from(tabela).insert(valor).select("id").single();
if (error) throw error;
return (data as { id: string }).id;
}
async function entrar(page: Page): Promise<void> {
await page.goto("/login");
await page.getByLabel(/e-?mail/i).fill(email);
await page.getByLabel(/senha/i).fill(SENHA);
await page.getByRole("button", { name: "Entrar", exact: true }).click();
await page.waitForURL(/\/app(?:\/|$)/, { timeout: ESPERA });
}
async function drenar(page: Page): Promise<void> {
const r = await page.request.post("/api/v1/cron/event-log-drain", {
headers: { authorization: `Bearer ${SEGREDO_DO_DRENO}` },
});
expect(r.status(), "o dreno tem que responder 200").toBe(200);
// O 200 diz que o dreno RODOU, não que os handlers deram certo.
const resumo = ((await r.json()) as { data: { failed: number; dead: number } }).data;
expect(resumo.failed + resumo.dead, "nenhum handler pode ter falhado no dreno").toBe(0);
}
async function mover(page: Page, etapa: string): Promise<void> {
const { data, error } = await db.from("crm_leads").select("updated_at").eq("id", negocioId).single();
if (error) throw error;
const r = await page.request.post(`/api/v1/leads/${negocioId}/move`, {
data: { stage_id: etapa, position_in_stage: 1, expected_updated_at: (data as { updated_at: string }).updated_at },
});
expect(r.status(), await r.text()).toBe(200);
}
test.beforeAll(async () => {
email = `etapa-avisa-${SUFIXO}@invariant.test`;
const { data, error } = await db.auth.admin.createUser({ email, password: SENHA, email_confirm: true });
if (error || !data.user) throw error ?? new Error("usuário não nasceu");
userId = data.user.id;
orgId = await inserir("organizations", {
slug: `etapa-avisa-${SUFIXO}`,
legal_name: `Loja ${SUFIXO}`,
display_name: `Loja ${SUFIXO}`,
onboarded_at: new Date().toISOString(),
});
const { error: erroDoVinculo } = await db.from("user_organizations").insert({
organization_id: orgId,
user_id: userId,
role: "admin",
accepted_at: new Date().toISOString(),
});
if (erroDoVinculo) throw erroDoVinculo;
funilId = await inserir("crm_pipelines", { organization_id: orgId, name: `Pedidos ${SUFIXO}`, slug: `pedidos-${SUFIXO}` });
etapaNovo = await inserir("crm_stages", { organization_id: orgId, pipeline_id: funilId, name: "Novo", slug: "novo", position: 1 });
etapaConfirmado = await inserir("crm_stages", {
organization_id: orgId, pipeline_id: funilId, name: "Pedido confirmado", slug: "pedido-confirmado", position: 2,
});
etapaEnviado = await inserir("crm_stages", { organization_id: orgId, pipeline_id: funilId, name: "Enviado", slug: "enviado", position: 3 });
const contato = await inserir("contacts", {
organization_id: orgId, display_name: CLIENTE, phone_number: `+55119${String(Date.now()).slice(-8)}`,
});
negocioId = await inserir("crm_leads", {
organization_id: orgId, pipeline_id: funilId, stage_id: etapaNovo, contact_id: contato, title: CLIENTE,
});
});
test.afterAll(async () => {
if (orgId) await db.from("organizations").delete().eq("id", orgId);
if (userId) await db.auth.admin.deleteUser(userId);
});
test("a etapa marcada avisa a equipe na Central; a vizinha, sem a marca, não", async ({ page }) => {
test.setTimeout(240_000);
await page.setViewportSize({ width: 1440, height: 1000 });
await entrar(page);
// ---- 1. a chave, pela tela ----
await page.goto("/app/settings/tenant/pipelines");
const chave = page.getByTestId(`avisar-${etapaConfirmado}`);
await expect(chave).toBeVisible({ timeout: ESPERA });
await expect(chave, "vem desligada").toHaveAttribute("aria-checked", "false");
await expect(page.getByTestId(`avisar-${etapaEnviado}`)).toHaveAttribute("aria-checked", "false");
await expect(page.getByTestId(`etapa-${etapaConfirmado}`)).toContainText(
"Avisar a equipe na Central quando um negócio entrar aqui",
);
await chave.click();
await expect(chave).toHaveAttribute("aria-checked", "true", { timeout: ESPERA });
await page.screenshot({ path: path.join(EVIDENCIA, "01-chave-ligada-na-etapa.png"), fullPage: true });
// A chave ficou gravada: recarregar a tela mostra a mesma coisa.
await page.reload();
await expect(page.getByTestId(`avisar-${etapaConfirmado}`)).toHaveAttribute("aria-checked", "true", { timeout: ESPERA });
await expect(page.getByTestId(`avisar-${etapaEnviado}`)).toHaveAttribute("aria-checked", "false");
// ---- 2. o negócio entra na etapa marcada ----
await mover(page, etapaConfirmado);
await drenar(page);
// ---- 3. a Central mostra o aviso, sem dado do cliente, e o botão leva ao negócio ----
await page.goto("/app/ai/inbox");
const aviso = page.getByTestId("inbox-item").filter({ hasText: "Negócio entrou em «Pedido confirmado»" });
await expect(aviso).toHaveCount(1, { timeout: ESPERA });
await expect(aviso).toContainText("Abra o negócio para dar o próximo passo.");
await expect(aviso).not.toContainText(CLIENTE);
await page.screenshot({ path: path.join(EVIDENCIA, "02-aviso-na-central.png"), fullPage: true });
// ---- 4. a etapa sem a marca não avisa ----
await mover(page, etapaEnviado);
await drenar(page);
await page.reload();
await expect(page.getByTestId("inbox-item").filter({ hasText: "Negócio entrou em" })).toHaveCount(1);
await expect(page.getByTestId("inbox-item").filter({ hasText: "«Enviado»" })).toHaveCount(0);
await aviso.getByRole("link", { name: "Abrir negócio" }).click();
await expect(page).toHaveURL(new RegExp(`/app/pipelines/${funilId}\\?lead=${negocioId}`), { timeout: ESPERA });
await expect(page.getByRole("dialog").getByText(CLIENTE, { exact: true }).first()).toBeVisible({ timeout: ESPERA });
await page.screenshot({ path: path.join(EVIDENCIA, "03-abrir-negocio.png"), fullPage: true });
});
@@ -0,0 +1,217 @@
/**
* A etapa marcada avisa a equipe na Central (migration 0436).
*
* Os modos de falha vigiados: avisar etapa que ninguém marcou (ruído que
* ensina a ignorar a Central), empilhar o mesmo aviso a cada reprocessamento,
* vazar o nome/telefone do cliente no texto do aviso, e uma falha do banco que
* girasse para sempre como reagendamento benigno em vez de contar tentativa.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { avisoDeEtapaHandler } from "@/lib/leads/aviso-de-etapa.handler";
import { atualizarEtapa, corpo } from "@/lib/leads/stage-operations";
import { tituloDoAvisoDeEtapa } from "@/lib/leads/aviso-de-etapa";
import type { EventRow } from "@/lib/event-log/dispatcher";
import { IDIOMAS } from "@/lib/i18n/idiomas";
import { createAdminClient } from "@/lib/supabase/admin";
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
const ORG = "11111111-1111-1111-1111-111111111111";
const LEAD = "22222222-2222-2222-2222-222222222222";
const ETAPA = "33333333-3333-3333-3333-333333333333";
const inseridos: Record<string, unknown>[] = [];
const filtros: Array<[string, string, unknown]> = [];
function fakeAdmin(t: {
avisar: boolean | null;
jaAberto?: boolean;
locale?: string;
erroNaEtapa?: boolean;
erroNoInsert?: boolean;
}) {
return {
from(tabela: string) {
const q = {
select: () => q,
eq: (coluna: string, valor: unknown) => {
filtros.push([tabela, coluna, valor]);
return q;
},
limit: async () => ({ data: t.jaAberto ? [{ id: "x" }] : [], error: null }),
maybeSingle: async () => {
if (tabela === "crm_stages" && t.erroNaEtapa) return { data: null, error: { message: "conexão caiu" } };
return {
data:
tabela === "crm_stages"
? { name: "Pedido confirmado", avisar_na_central: t.avisar }
: tabela === "organizations"
? { locale: t.locale ?? "es" }
: null,
error: null,
};
},
insert: async (linha: Record<string, unknown>) => {
if (t.erroNoInsert) return { error: { message: "check violation" } };
inseridos.push(linha);
return { error: null };
},
};
return q;
},
};
}
const evento = (payload: Record<string, unknown>): EventRow => ({
id: "evt",
organization_id: ORG,
event_type: "lead.stage_changed",
entity_kind: "crm_lead",
entity_id: LEAD,
payload,
metadata: {},
consumed_by: [],
attempts: 0,
created_at: new Date().toISOString(),
});
beforeEach(() => {
inseridos.length = 0;
filtros.length = 0;
});
describe("aviso na Central ao entrar numa etapa marcada", () => {
it("escuta a mudança de etapa", () => {
expect(avisoDeEtapaHandler.events).toContain("lead.stage_changed");
});
it("etapa marcada: abre um aviso que aponta para o negócio, no idioma da organização", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true }) as never);
const r = await avisoDeEtapaHandler.handle(evento({ from_stage_id: "outra", to_stage_id: ETAPA }));
expect(r.status).toBe("ok");
expect(inseridos).toHaveLength(1);
expect(inseridos[0]).toMatchObject({
organization_id: ORG,
kind: "other",
severity: "info",
ref_kind: "lead",
ref_id: LEAD,
title: "Negocio entró en «Pedido confirmado»",
body: "Abre el negocio para dar el siguiente paso. Este aviso se pidió en la configuración de la etapa.",
});
});
it("a etapa é lida COM o filtro da organização do evento (o client ignora RLS)", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true }) as never);
await avisoDeEtapaHandler.handle(evento({ to_stage_id: ETAPA }));
expect(filtros).toContainEqual(["crm_stages", "organization_id", ORG]);
expect(filtros).toContainEqual(["agent_inbox_items", "organization_id", ORG]);
});
it("o texto não carrega nada do cliente — só a etapa", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true, locale: "pt-BR" }) as never);
await avisoDeEtapaHandler.handle(evento({ to_stage_id: ETAPA, lead_title: "Maria Souza +5511999990000" }));
const texto = JSON.stringify(inseridos[0]);
expect(texto).not.toContain("Maria");
expect(texto).not.toContain("99999");
expect(inseridos[0]!.title).toBe("Negócio entrou em «Pedido confirmado»");
});
it("etapa sem a marca não avisa", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: false }) as never);
const r = await avisoDeEtapaHandler.handle(evento({ to_stage_id: ETAPA }));
expect(r.status).toBe("skipped");
expect(inseridos).toHaveLength(0);
});
it("aviso já aberto para o mesmo negócio e etapa não empilha", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true, jaAberto: true }) as never);
const r = await avisoDeEtapaHandler.handle(evento({ to_stage_id: ETAPA }));
expect(r.detail).toBe("aviso_ja_aberto");
expect(inseridos).toHaveLength(0);
});
it("sem etapa de destino, ou movendo para a mesma etapa, não faz nada", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true }) as never);
expect((await avisoDeEtapaHandler.handle(evento({}))).status).toBe("skipped");
expect((await avisoDeEtapaHandler.handle(evento({ from_stage_id: ETAPA, to_stage_id: ETAPA }))).status).toBe("skipped");
expect(inseridos).toHaveLength(0);
});
it("falha do banco é `error` (conta tentativa no dreno), nunca `retry` benigno", async () => {
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true, erroNaEtapa: true }) as never);
const leitura = await avisoDeEtapaHandler.handle(evento({ to_stage_id: ETAPA }));
expect(leitura.status).toBe("error");
vi.mocked(createAdminClient).mockReturnValue(fakeAdmin({ avisar: true, erroNoInsert: true }) as never);
const gravacao = await avisoDeEtapaHandler.handle(evento({ to_stage_id: ETAPA }));
expect(gravacao.status).toBe("error");
expect(gravacao.detail).toContain("aviso não entrou na Central");
});
});
describe("o título do aviso", () => {
it("existe em todo idioma servido e carrega o nome da etapa entre aspas angulares", () => {
expect(IDIOMAS.length).toBeGreaterThan(0);
for (const idioma of IDIOMAS) {
const titulo = tituloDoAvisoDeEtapa("Pedido confirmado", idioma);
expect(titulo.endsWith(" «Pedido confirmado»")).toBe(true);
expect(titulo.length).toBeGreaterThan(" «Pedido confirmado»".length);
}
});
});
describe("a chave da etapa é gravada e lida pela regra de etapas", () => {
const PIPE = "44444444-4444-4444-4444-444444444444";
const etapa = (over: Record<string, unknown> = {}) => ({
id: ETAPA, name: "Pedido confirmado", slug: "pedido-confirmado", position: 2,
is_won: false, is_lost: false, is_archived: false, win_probability: null,
agent_stage_hint: null, last_change_actor_kind: null, last_change_at: null, ...over,
});
function supabaseDeSessao(linhas: Record<string, unknown>[], updates: Record<string, unknown>[]) {
return {
from(tabela: string) {
const q = {
select: () => q,
eq: () => q,
order: async () => ({ data: linhas, error: null }),
maybeSingle: async () => ({ data: tabela === "crm_pipelines" ? { id: PIPE } : null, error: null }),
update: (patch: Record<string, unknown>) => {
updates.push(patch);
const u = { eq: () => u, then: (ok: (v: unknown) => unknown) => Promise.resolve({ error: null }).then(ok) };
return u;
},
};
return q;
},
};
}
it("PATCH com `avisar_na_central` grava a coluna — e só ela, com a autoria", async () => {
const updates: Record<string, unknown>[] = [];
await atualizarEtapa(
{
supabase: supabaseDeSessao([etapa()], updates) as never,
organizationId: ORG,
actor: { type: "user", id: "u1", role: "manager" } as never,
requestId: "r1",
},
{ pipelineId: PIPE, stageId: ETAPA, pedido: { avisar_na_central: true } },
);
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({ avisar_na_central: true, last_change_actor_kind: "user" });
expect(Object.keys(updates[0]!).sort()).toEqual(["avisar_na_central", "last_change_actor_kind", "last_change_at"]);
});
it("a etapa sai para a tela com a chave; etapa anterior à coluna sai desligada", () => {
expect(corpo([etapa({ avisar_na_central: true }) as never]).etapas[0]!.avisar_na_central).toBe(true);
expect(corpo([etapa() as never]).etapas[0]!.avisar_na_central).toBe(false);
});
});