Merge triagem/lote-e-consertos — os quatro defeitos do lote da Agenda (#782 #784 #789 #794, @423313)

O que entra, e por que cada um importa:

  #789 — o portão de MFA de `fn_agenda_settings` tinha sido APAGADO ao recriar a
         função em vez de derivá-la. O alcance passava do teste: o apêndice do
         baseline repetia a versão sem o portão, então o `update.sh` REMOVERIA a
         proteção de quem já a tinha. Restaurado derivando, e nasceu junto uma
         varredura de CLASSE (`tests/unit/mfa-nao-some-em-funcao-recriada.test.ts`):
         num arquivo aplicado de cima para baixo, a última definição vence; se
         alguma anterior tinha `fn_session_mfa_proven()`, a última tem.
  #794 — `kind` entrava na query do PostgREST e não na projeção escrita à mão:
         todo caso renderizava "Outro", para sempre, com os gates verdes.
  #784 — `nomeDoTipo: "Agendamento"` cravado deixava a condição que a tela
         oferece morta em 3 dos 4 gatilhos. Controle decorativo é pior que
         controle ausente: a pessoa acredita que configurou.
  #782 — `onCriado(resposta.data)` onde o certo é `.data.contact` (o `ok()` já
         embrulha): o contato nascia e a marcação ficava sem ninguém.

DESKCOMM_GOV_MIGRATION_EDIT=1 — falso positivo de merge nos DOIS números,
medido antes de escapar (passe 8-quater do TRIAGEM.md).

O hook acusou 0248 e 0249 "já existem na branch 'triagem/lote-e-consertos'" —
que é a branch sendo mergeada. Ele varre todas as branches locais e não
distingue "outra branch reivindica" de "a branch que estou trazendo é dona".
Confirmado antes de usar o escape:

  0248 -> origin/main: 0 · integracao/triagem-14set-l2: 0 · nenhuma outra branch
  0249 -> origin/main: 0 · integracao/triagem-14set-l2: 0 · nenhuma outra branch

Nada foi renumerado neste commit. Conflitos resolvidos: apêndice contra
apêndice em `supabase/baseline.sql`, entrada contra entrada em
`lib/i18n/dicionario.ts` e em `lib/audit/actions.ts` — os dois lados ficam.
This commit is contained in:
Pessoa
2026-09-14 11:44:38 -03:00
44 changed files with 2351 additions and 43 deletions
+24
View File
@@ -0,0 +1,24 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: As automações agora enxergam a agenda
---
O motor de automações já sabia mandar WhatsApp, esperar, checar condição e
registrar o que fez. O que ele não enxergava era a agenda: nenhum dos gatilhos
disponíveis vinha de um horário marcado. Quem queria avisar a cliente que o
horário foi confirmado tinha o motor, tinha o envio, e não tinha o fato.
Quatro gatilhos novos aparecem no seletor de automações:
- Quando um horário for marcado
- Quando um horário pendente for confirmado
- Quando um horário for remarcado
- Quando um horário for cancelado
As condições podem filtrar pelo tipo de atendimento (com "contém", então
"Manutenção" pega todas as manutenções) e pelas tags do contato. As ações são as
mesmas de sempre, a de mandar mensagem no WhatsApp inclusive.
Quem já tem automações não precisa fazer nada: as regras existentes continuam
como estavam.
+21
View File
@@ -0,0 +1,21 @@
---
impacto: capacidade_nova
secao: corrigido
titulo: A agenda no celular abre no dia, e dá para criar cliente sem sair da marcação
---
Quem abre a agenda no celular via a semana inteira espremida: sete colunas em
uma tela de 360 pixels davam cerca de 44 pixels por dia, e errar o toque era o
normal. Agora o celular abre no dia, com a coluna ocupando a tela toda — o alvo
do toque ficou quase cinco vezes mais largo. No computador nada muda: a semana
continua inteira.
Duas coisas que não funcionavam passam a funcionar:
Tocar num compromisso abre o detalhe dele. Antes o toque não fazia nada, e só
dava para abrir vindo do histórico ou do radar.
Quando você busca um cliente que ainda não está cadastrado, aparece um "Criar"
com o nome que você digitou. O cadastro abre ali mesmo e o cliente volta já
escolhido. Antes era preciso abandonar a marcação, ir até Contatos, cadastrar,
voltar e começar de novo.
+17
View File
@@ -0,0 +1,17 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O atendimento aberto pelo assistente diz do que trata
---
Na lista de atendimentos, cada item agora começa dizendo o assunto: horário,
dúvida, algo deu errado, pagamento, acesso. O assistente classifica ao abrir.
Serve para quem abre a fila separar antes de ler — "alguém quer marcar horário"
e "alguém está reclamando" pedem pessoas e pressas diferentes.
A lista de assuntos é curta de propósito. O detalhe do pedido continua no título
e no resumo, escritos com as palavras do próprio cliente; o assunto é só para
triar.
Atendimentos abertos antes desta versão aparecem como "Outro".
@@ -0,0 +1,20 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O pedido que ninguém confirmou solta o horário
---
Quando um tipo de atendimento pede confirmação, o pedido do cliente já reserva o
horário: ele some da lista de horários livres e ninguém mais consegue marcar ali.
É o que faz o modo "o cliente pede, uma pessoa confirma" funcionar.
Faltava o outro lado disso. Um pedido que ninguém abriu segurava a agenda para
sempre, e o efeito era igualzinho ao de agenda cheia: o próximo cliente ouvia
"não tenho horário" por causa de um pedido esquecido.
Agora existe um prazo. Passado ele sem decisão, o horário volta a ser oferecido.
O padrão é 24 horas, e dá para mudar em Agenda, no mesmo lugar dos outros prazos.
Duas coisas que **não** acontecem quando o prazo vence: o cliente não recebe
nenhum aviso, e o pedido dele continua na fila para ser atendido. O que expira é
a reserva do horário, não o pedido.
+88 -3
View File
@@ -27,10 +27,16 @@ import { horariosLivresDaOrg } from "@/lib/agenda/consulta";
import {
atividadeDaTransicao,
autorParaTimeline,
gatilhoDaTransicao,
type SituacaoAnterior,
type Transicao,
} from "@/lib/agenda/laco";
import { ALVO_DE_VINCULO_DO_AGENDAMENTO, VINCULO_DE_AGENDAMENTO } from "@/lib/agenda/tipos";
import {
ALVO_DE_VINCULO_DO_AGENDAMENTO,
ENTIDADE_DO_AGENDAMENTO,
NOME_GENERICO_DO_TIPO,
VINCULO_DE_AGENDAMENTO,
} from "@/lib/agenda/tipos";
import { ApiError } from "@/lib/api/types";
import type { Actor, HandlerCtx } from "@/lib/api/handlers/types";
import { audit } from "@/lib/audit";
@@ -207,6 +213,7 @@ export async function marcarAgendamentoHandler(
appointmentId: criado.id,
contactId: input.contact_id ?? null,
atividade: atividadeDaTransicao(null, transicao),
gatilho: gatilhoDaTransicao(null, transicao),
transicao,
fusoDoCompromisso: criado.time_zone,
nomeDoTipo: tipo.name,
@@ -361,9 +368,10 @@ export async function alterarAgendamentoHandler(
appointmentId: atual.id as string,
contactId: (atual.contact_id as string | null) ?? null,
atividade: atividadeDaTransicao(atual.status as SituacaoAnterior, transicao),
gatilho: gatilhoDaTransicao(atual.status as SituacaoAnterior, transicao),
transicao,
fusoDoCompromisso: String(salvo.time_zone),
nomeDoTipo: "Agendamento",
nomeDoTipo: await nomeDoTipoDoCompromisso(supabase, ctx, atual.event_type_id as string | null),
outcome: {revision:salvo.revision,source_kind:salvo.outcome_source_kind,message_id:salvo.outcome_message_id,recorded_at:salvo.outcome_recorded_at},
});
@@ -398,6 +406,7 @@ export async function cancelarAgendamentoHandler(
"id",
"revision",
"contact_id",
"event_type_id",
"status",
"time_zone",
]);
@@ -417,9 +426,10 @@ export async function cancelarAgendamentoHandler(
appointmentId: atual.id as string,
contactId: (atual.contact_id as string | null) ?? null,
atividade: atividadeDaTransicao(atual.status as SituacaoAnterior, "cancelled"),
gatilho: gatilhoDaTransicao(atual.status as SituacaoAnterior, "cancelled"),
transicao: "cancelled",
fusoDoCompromisso: atual.time_zone as string,
nomeDoTipo: "Agendamento",
nomeDoTipo: await nomeDoTipoDoCompromisso(supabase, ctx, atual.event_type_id as string | null),
});
void audit({
@@ -436,6 +446,43 @@ export async function cancelarAgendamentoHandler(
}
/** O compromisso, ou 404 — sempre com o filtro de organização. */
/**
* O NOME DO TIPO DE ATENDIMENTO — lido da linha, nunca digitado aqui.
*
* Ele viaja no payload do gatilho de automação (`event.event_type_name`) e é o
* ÚNICO campo por onde uma regra distingue "Limpeza" de "Avaliação": a linha do
* compromisso guarda `event_type_id`, um uuid que ninguém digita numa condição.
* O editor de regras oferece exatamente essa condição ("Tipo de atendimento
* contém …").
*
* ⚠️ ISTO JÁ FOI UM LITERAL, e o literal é o defeito. `alterar` e `cancelar`
* passavam `"Agendamento"` cravado, então três dos quatro gatilhos
* (`confirmed`, `rescheduled`, `cancelled`) emitiam sempre a mesma palavra —
* a condição aparecia na tela, o operador a salvava, e ela não casava nunca.
* Controle decorativo é pior que controle ausente: a pessoa acredita que
* configurou.
*
* Uma consulta a mais por transição, e só quando há transição. `marcar` não
* chama esta função porque já tem a linha do tipo em mãos.
*/
async function nomeDoTipoDoCompromisso(
supabase: SB,
ctx: HandlerCtx,
eventTypeId: string | null,
): Promise<string> {
if (!eventTypeId) return NOME_GENERICO_DO_TIPO;
const { data } = await supabase
.from("calendar_event_types")
.select("name")
.eq("organization_id", ctx.organization_id)
.eq("id", eventTypeId)
.maybeSingle();
const nome = (data as { name?: string | null } | null)?.name;
// O tipo apagado depois do compromisso é o único caminho até aqui. Falhar a
// leitura NÃO pode desfazer um cancelamento já gravado.
return nome?.trim() ? nome : NOME_GENERICO_DO_TIPO;
}
async function exigeAgendamento(
supabase: SB,
ctx: HandlerCtx,
@@ -526,6 +573,8 @@ async function fecharOLaco(
appointmentId: string;
contactId: string | null;
atividade: string | null;
/** Gatilho de automação, ou `null` quando a transição não é notícia para uma regra. */
gatilho: string | null;
transicao: Transicao;
fusoDoCompromisso: string;
nomeDoTipo: string;
@@ -534,6 +583,42 @@ async function fecharOLaco(
): Promise<void> {
// Pendência Google é derivada da revisão publicável; não emite evento sem consumer.
// O gatilho de automação, ANTES de qualquer early-return. Ele não depende de
// haver negócio aberto: uma regra de "avise a cliente que confirmou" vale
// igual para quem não tem lead nenhum — e todo o resto desta função é sobre a
// timeline do lead, que é outra pergunta.
//
// Fire-and-forget, como a atividade: falhar em emitir NÃO pode desfazer um
// compromisso que já está gravado. O consumidor é o motor de regras
// (`lib/automation/engine.ts`), que casa por `trigger_event`.
if (args.gatilho) {
const { error } = await supabase.from("event_log").insert({
organization_id: ctx.organization_id,
event_type: args.gatilho,
entity_kind: ENTIDADE_DO_AGENDAMENTO,
entity_id: args.appointmentId,
payload: {
appointment_id: args.appointmentId,
contact_id: args.contactId,
event_type_name: args.nomeDoTipo,
time_zone: args.fusoDoCompromisso,
transicao: args.transicao,
},
// `request_id` sem o prefixo `rule:` de propósito: ele correlaciona com o
// audit log e NÃO aciona o anti-loop do motor, que só barra o que uma
// regra causou.
metadata: { request_id: ctx.requestId },
});
if (error) {
logger.error("[agenda] gatilho de automação não foi emitido", {
appointment_id: args.appointmentId,
organization_id: ctx.organization_id,
gatilho: args.gatilho,
error: error.message,
});
}
}
const leadId = args.contactId ? await leadAtivoDoContato(supabase, ctx, args.contactId) : null;
// ⚠️ ANTES do early-return de `!args.atividade`. Confirmar um agendamento
@@ -0,0 +1,168 @@
/**
* O pedido que ninguém decidiu solta o horário — e só ele.
*
* O que este arquivo protege, em ordem de gravidade:
*
* 1. Cancelar um compromisso CONFIRMADO seria o pior desfecho possível desta
* rota: cliente com horário marcado perde o horário sozinho, sem ninguém
* saber. Por isso a guarda aparece duas vezes (na leitura e no UPDATE) e
* tem teste nas duas.
* 2. O prazo é POR ORGANIZAÇÃO. Um corte único no SQL seria mais simples e
* aplicaria o prazo errado a metade dos tenants.
* 3. Rodada sem efeito não audita (lei do CLAUDE.md).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/env", () => ({ env: { INTERNAL_SECRET: "segredo", INTERNAL_CRON_SECRET: "" } }));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => undefined) }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
import { audit } from "@/lib/audit";
import { createAdminClient } from "@/lib/supabase/admin";
const ORG_RAPIDA = "11111111-1111-4111-8111-111111111111";
const ORG_LENTA = "22222222-2222-4222-8222-222222222222";
const hAtras = (h: number) => new Date(Date.now() - h * 3600_000).toISOString();
/** Captura o que o UPDATE recebeu, incluindo os filtros encadeados. */
function admin(pendentes: Array<Record<string, unknown>>, capturado: Record<string, unknown>) {
return {
from(tabela: string) {
if (tabela === "organizations") {
return {
select: () => ({
in: async () => ({
data: [
{ id: ORG_RAPIDA, settings: { agenda: { confirmation_delay_minutes: 10, unknown_protection_minutes: 1440, pending_expires_after_minutes: 60 } } },
// Sem o campo: cai no default de 1440 (24h).
{ id: ORG_LENTA, settings: { agenda: { confirmation_delay_minutes: 10, unknown_protection_minutes: 1440 } } },
],
}),
}),
};
}
// calendar_appointments
return {
select: () => ({
eq: (col: string, val: string) => {
capturado.selectEq = { col, val };
return {
order: () => ({ limit: async () => ({ data: pendentes, error: null }) }),
};
},
}),
update: (patch: Record<string, unknown>) => {
capturado.patch = patch;
return {
in: (_c: string, ids: string[]) => {
capturado.ids = ids;
return {
eq: (col: string, val: string) => {
capturado.updateEq = { col, val };
return {
select: async () => ({ data: ids.map((id) => ({ id })), error: null }),
};
},
};
},
};
},
};
},
};
}
function req() {
return new NextRequest("http://localhost/x", {
headers: { authorization: "Bearer segredo" },
});
}
beforeEach(() => vi.clearAllMocks());
describe("agenda-expira-pendentes", () => {
it("expira o que passou do prazo DAQUELA organização, e mantém o resto", async () => {
const capturado: Record<string, unknown> = {};
vi.mocked(createAdminClient).mockReturnValue(
admin(
[
// 2h de vida numa org com prazo de 1h → expira.
{ id: "a", organization_id: ORG_RAPIDA, created_at: hAtras(2), starts_at: hAtras(-48) },
// 2h de vida numa org com prazo de 24h (default) → fica.
{ id: "b", organization_id: ORG_LENTA, created_at: hAtras(2), starts_at: hAtras(-48) },
// 30h de vida na org lenta → expira.
{ id: "c", organization_id: ORG_LENTA, created_at: hAtras(30), starts_at: hAtras(-48) },
],
capturado,
) as never,
);
const { POST } = await import("./route");
const body = (await (await POST(req())).json()) as { data: Record<string, number> };
expect(body.data).toMatchObject({ examinados: 3, expirados: 2, mantidos: 1 });
expect(capturado.ids).toEqual(["a", "c"]);
});
it("a leitura pede só pendentes, e o UPDATE repete a guarda", async () => {
const capturado: Record<string, unknown> = {};
vi.mocked(createAdminClient).mockReturnValue(
admin(
[{ id: "a", organization_id: ORG_RAPIDA, created_at: hAtras(5), starts_at: hAtras(-48) }],
capturado,
) as never,
);
const { POST } = await import("./route");
await POST(req());
// Se alguém confirmar entre a leitura e a escrita, o UPDATE não alcança a
// linha. Sem este `.eq` o compromisso recém-confirmado seria cancelado.
expect(capturado.selectEq).toEqual({ col: "status", val: "pending" });
expect(capturado.updateEq).toEqual({ col: "status", val: "pending" });
expect((capturado.patch as Record<string, string>).status).toBe("cancelled");
});
it("rodada que não expirou nada NÃO audita", async () => {
const capturado: Record<string, unknown> = {};
vi.mocked(createAdminClient).mockReturnValue(
admin(
[{ id: "b", organization_id: ORG_LENTA, created_at: hAtras(1), starts_at: hAtras(-48) }],
capturado,
) as never,
);
const { POST } = await import("./route");
const body = (await (await POST(req())).json()) as { data: Record<string, number> };
expect(body.data.expirados).toBe(0);
expect(audit).not.toHaveBeenCalled();
});
it("rodada que expirou audita", async () => {
const capturado: Record<string, unknown> = {};
vi.mocked(createAdminClient).mockReturnValue(
admin(
[{ id: "a", organization_id: ORG_RAPIDA, created_at: hAtras(9), starts_at: hAtras(-48) }],
capturado,
) as never,
);
const { POST } = await import("./route");
await POST(req());
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({ action: "agenda.pendente_expirado" }),
);
});
it("sem o segredo, 403 — e não lê nada", async () => {
vi.mocked(createAdminClient).mockReturnValue(admin([], {}) as never);
const { POST } = await import("./route");
const res = await POST(new NextRequest("http://localhost/x"));
expect(res.status).toBe(403);
expect(createAdminClient).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,173 @@
/**
* O PEDIDO QUE NINGUÉM DECIDIU LIBERA O HORÁRIO.
*
* Um tipo de agendamento com `requires_confirmation` cria o compromisso em
* `pending`, e `pending` OCUPA o horário — medido: o slot some da lista de
* livres e uma segunda marcação no mesmo horário é recusada com
* `agenda_horario_indisponivel`. É a garantia que faz o modo "o cliente pede, uma
* pessoa confirma" funcionar: entre o pedido e a conferência, ninguém mais leva
* aquele horário.
*
* O que faltava é o outro lado dessa garantia. Sem expiração, **indecisão vira
* horário travado para sempre**: o pedido que ninguém abriu segura a agenda por
* semanas, e o efeito é indistinguível de agenda cheia — o próximo cliente ouve
* "não tenho horário" por causa de um pedido abandonado.
*
* ═══ O PRAZO É EM HORAS, E NÃO "A VIRADA DO DIA" ═══
*
* A especificação de origem dizia "expira na virada do dia". Não segui, e o
* motivo é medido: aquela regra vinha de duas migrations que **nunca foram
* aplicadas** no sistema de origem — ela nunca rodou, então não há comportamento
* a preservar, só uma escolha a fazer.
*
* E ela tem um defeito que só aparece no uso: um pedido feito às 23h expiraria
* em uma hora, de madrugada, antes de qualquer pessoa acordar para decidir. O
* prazo em horas trata todo pedido igual, independentemente da hora em que
* chegou.
*
* `pending_expires_after_minutes` é por organização, com default de 24h — quem
* confere a fila uma vez por dia não perde nada.
*
* ═══ O QUE ESTA ROTA NÃO FAZ ═══
*
* **Não fala com o cliente.** Expirar é operação interna: a pessoa pediu, não
* confirmaram, e o horário voltou para a prateleira. Mandar "seu pedido
* expirou" é uma decisão de produto diferente, e cara — seria a primeira
* mensagem automática do sistema a dar má notícia.
*
* **Não fecha o caso.** O pedido do cliente vive em `agent_cases`, que é outra
* coisa: quem confere continua vendo que alguém pediu horário, e pode remarcar.
* O que expira é a RESERVA, não o pedido.
*
* **Não toca no que já foi decidido.** Só `pending`. `confirmed`, `cancelled`,
* `completed` e `no_show` estão fora do filtro — e o `.eq("status","pending")`
* do UPDATE é a segunda barreira, para o caso de alguém confirmar entre a
* leitura e a escrita.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { env } from "@/lib/env";
import { logger } from "@/lib/logger";
import { agendaSettingsSchema } from "@/lib/schemas/settings";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
/** Teto por rodada. A varredura roda a cada 15 min; sobra volta na seguinte. */
const LIMITE_DA_VARREDURA = 500;
async function handle(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const auth = req.headers.get("authorization") ?? "";
const fornecido = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
const aceitos = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(Boolean);
if (aceitos.length === 0 || !fornecido || !aceitos.includes(fornecido)) {
return fail("forbidden", "Cron secret missing or invalid.", 403, { requestId });
}
const admin = createAdminClient();
const agora = new Date();
// O prazo é POR ORGANIZAÇÃO, então a varredura não pode usar um corte único
// de `created_at` no SQL. Busca os pendentes de todas as orgs e aplica o prazo
// de cada uma — o volume é pequeno por construção (pendente é estado curto).
const { data, error } = await admin
.from("calendar_appointments")
.select("id, organization_id, created_at, starts_at")
.eq("status", "pending")
.order("created_at", { ascending: true })
.limit(LIMITE_DA_VARREDURA);
if (error) {
logger.error("[agenda-expira-pendentes] consulta falhou", { error: error.message, requestId });
return fail("internal_error", "Falha ao buscar pendentes.", 500, { requestId });
}
const linhas = data ?? [];
if (linhas.length === 0) {
return ok({ examinados: 0, expirados: 0, mantidos: 0 }, { requestId });
}
// Um SELECT por organização presente, não um por linha.
const orgs = [...new Set(linhas.map((l) => l.organization_id))];
const { data: configs } = await admin
.from("organizations")
.select("id, settings")
.in("id", orgs);
const prazoPorOrg = new Map<string, number>();
for (const o of configs ?? []) {
const s = (o.settings as { agenda?: unknown } | null)?.agenda;
prazoPorOrg.set(o.id, agendaSettingsSchema.parse(s ?? {}).pending_expires_after_minutes);
}
const expirados: string[] = [];
let mantidos = 0;
for (const linha of linhas) {
const prazo = prazoPorOrg.get(linha.organization_id);
if (prazo === undefined) {
// Organização que sumiu entre as duas consultas: não decide nada por ela.
mantidos += 1;
continue;
}
const nasceu = Date.parse(linha.created_at);
if (Number.isNaN(nasceu) || nasceu + prazo * 60_000 > agora.getTime()) {
mantidos += 1;
continue;
}
expirados.push(linha.id);
}
if (expirados.length === 0) {
return ok({ examinados: linhas.length, expirados: 0, mantidos }, { requestId });
}
// `.eq("status","pending")` de novo: se alguém confirmou entre a leitura e
// aqui, o UPDATE não alcança a linha — e é isso que se quer. Cancelar um
// compromisso que acabou de ser confirmado seria o pior desfecho possível
// desta rota.
const { data: efetivados, error: erroUpdate } = await admin
.from("calendar_appointments")
.update({
status: "cancelled",
cancellation_reason: "Pedido expirado: ninguém confirmou dentro do prazo.",
})
.in("id", expirados)
.eq("status", "pending")
.select("id");
if (erroUpdate) {
logger.error("[agenda-expira-pendentes] update falhou", {
error: erroUpdate.message,
tentados: expirados.length,
requestId,
});
return fail("internal_error", "Falha ao expirar pendentes.", 500, { requestId });
}
const quantos = efetivados?.length ?? 0;
// Rodada que não expirou nada NÃO é mutação e não audita — a lei está no
// CLAUDE.md §Audit log, e `cron-audita-so-quando-ha-efeito.test.ts` varre o
// AST desta pasta atrás de `audit` incondicional.
if (quantos > 0) {
await audit({
action: "agenda.pendente_expirado",
resourceType: "calendar_appointment",
requestId,
metadata: { expirados: quantos, examinados: linhas.length },
});
}
return ok(
{ examinados: linhas.length, expirados: quantos, mantidos },
{ requestId },
);
}
export const GET = handle;
export const POST = handle;
+30
View File
@@ -1,5 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { EntradaDaAgenda } from "@/components/agenda/EntradaDaAgenda";
import { VinculoDaMarcacao } from "@/components/agenda/VinculoDaMarcacao";
import { useLocaleDeData } from "@/hooks/i18n/useLocaleDeData";
@@ -97,6 +99,7 @@ export function AgendaClient({
}) {
const localeDaData = useLocaleDeData();
const t = useT();
const router = useRouter();
const [marcando, setMarcando] = React.useState(false);
// O compromisso criado NESTA abertura do painel. Serve para levar a grade até
// ele quando o painel fechar por qualquer caminho — ver `ancoraAoFecharPainel`.
@@ -163,6 +166,26 @@ export function AgendaClient({
const [tipoId, setTipoId] = React.useState<string | null>(() => tiposIniciais[0]?.id ?? null);
const tipo = tiposIniciais.find((t) => t.id === tipoId) ?? tiposIniciais[0] ?? null;
const [visao, setVisao] = React.useState<VisaoDaAgenda>("semana");
/**
* No CELULAR a agenda abre no DIA, não na semana.
*
* Duas razões, e a segunda é consequência da primeira. A semana em 360px é
* ilegível — por isso a grade esconde as outras colunas abaixo de `md`. Mas o
* passo de navegação da semana é de SETE dias: quem visse um dia só e tocasse
* em avançar pularia a semana inteira, sem alcançar os outros seis. Abrindo no
* dia, o passo é 1 e cada toque anda um dia.
*
* Em `useEffect`, e não no estado inicial, porque `window` não existe no
* servidor: decidir a visão na primeira renderização faria o HTML do servidor
* discordar do cliente. Roda uma vez, na montagem, então não desfaz escolha
* de quem trocou a visão depois.
*/
React.useEffect(() => {
// O aviso da regra é justo em geral; aqui trocar a visão É o ponto do efeito.
// A largura só existe no cliente, e decidir antes divergiria da hidratação.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (window.matchMedia("(max-width: 767px)").matches) setVisao("dia");
}, []);
const [isolada, setIsolada] = React.useState<string | null>(null);
const [ancora, setAncora] = React.useState(() => new Date());
@@ -886,6 +909,13 @@ export function AgendaClient({
// abre uma marcação NOVA, e ela nasce com o vínculo da rota.
abrirMarcacao();
}}
/* Tocar num card abre o detalhe. A prop já atravessava `AgendaInterativa`
e `GradeDaAgenda` e chegava `undefined` aqui: o toque não fazia nada, e
o detalhe só abria por `?compromisso=`, que apenas o Histórico e o Radar
linkavam. Reusa o MESMO parâmetro que `EntradaDaAgenda` já lê — e `push`,
não `replace`, porque é o que o Histórico faz com `<Link>` e é o que faz
o botão voltar do celular fechar o detalhe. */
onAbrirAgendamento={(id) => router.push(`/app/agenda?compromisso=${id}`)}
className="min-h-0 flex-1"
/>
+4 -2
View File
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCases, type CaseListItem } from "@/hooks/ai/useCases";
import { STATUS_BADGE_VARIANT, STATUS_LABEL } from "@/lib/ai/case-copy";
import { STATUS_BADGE_VARIANT, STATUS_LABEL, tipoDeCasoLabel } from "@/lib/ai/case-copy";
import { Robot } from "@/lib/ui/icons";
import { cn } from "@/lib/utils";
import { useT } from "@/hooks/i18n/useT";
@@ -109,7 +109,9 @@ function CaseRow({
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{item.contact_name ?? t("Contato sem nome")} · {when}
{/* O assunto vem ANTES do nome: quem tria a fila decide por ele, e o
nome só importa depois de escolher o caso. */}
{t(tipoDeCasoLabel(item.kind))} · {item.contact_name ?? t("Contato sem nome")} · {when}
</p>
</button>
</li>
@@ -75,6 +75,17 @@ const TAG_ADDED_FIELD: CuratedField = {
op: "contains",
};
/**
* O tipo vem do PAYLOAD, não da linha do compromisso, e é de propósito: a linha
* guarda `event_type_id`, um uuid que ninguém digita numa condição. O nome
* viajou no evento justamente para caber aqui, e `contém` resolve o caso real
* ("Manutenção" pega as três).
*/
const AGENDAMENTO_FIELDS: CuratedField[] = [
{ value: "event.event_type_name", label: "Tipo de atendimento", op: "contains" },
{ value: "contact.tags", label: "Tags do contato", op: "contains" },
];
// ponytail: etapa de destino usa o funil default (cobre o caso comum de 1
// funil); se o produto ganhar múltiplos funis relevantes aqui, trocar por um
// seletor de funil antes do de etapa.
@@ -84,6 +95,10 @@ const CURATED_FIELDS: Record<TriggerEvent, CuratedField[]> = {
"message.received": MESSAGE_FIELDS,
"lead.tag_added": [...LEAD_FIELDS, TAG_ADDED_FIELD],
"contact.tag_added": [TAG_ADDED_FIELD],
"appointment.created": AGENDAMENTO_FIELDS,
"appointment.confirmed": AGENDAMENTO_FIELDS,
"appointment.rescheduled": AGENDAMENTO_FIELDS,
"appointment.cancelled": AGENDAMENTO_FIELDS,
};
const OP_LABELS: Record<Op, string> = { eq: "é", neq: "não é", contains: "contém" };
+7
View File
@@ -20,6 +20,13 @@ export const TRIGGER_LABELS: Record<TriggerEvent, string> = {
"message.received": "Quando chegar mensagem no WhatsApp",
"lead.tag_added": "Quando um lead ganhar uma tag",
"contact.tag_added": "Quando um contato ganhar uma tag",
// A frase evita "agendamento criado", que não diz ao operador o que ele vê na
// agenda: um horário marcado pode nascer pendente (o tipo pede confirmação) ou
// já confirmado, e os dois caem aqui.
"appointment.created": "Quando um horário for marcado",
"appointment.confirmed": "Quando um horário pendente for confirmado",
"appointment.rescheduled": "Quando um horário for remarcado",
"appointment.cancelled": "Quando um horário for cancelado",
};
export const ACTION_LABELS: Record<ActionType, string> = {
@@ -0,0 +1,68 @@
/**
* A grade da semana no CELULAR mostra um dia por vez.
*
* Por que isto tem teste: sete colunas em 360px dão ~44px cada, e a célula de
* meia hora vira um alvo de ~44x24px. Errar o toque passa a ser o caso comum.
* Quem marca horário está com o cliente na frente, no celular, com uma mão.
*
* O teste não mede pixel (jsdom não faz layout): ele prova a REGRA — quais
* colunas carregam a classe que as esconde abaixo de `md`, e quais não. A prova
* visual real é a spec de Playwright em 360px.
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { GradeDaAgenda } from "./GradeDaAgenda";
const QUARTA = new Date("2026-09-16T12:00:00-03:00");
function grade(visao: "dia" | "semana") {
return render(
<GradeDaAgenda
visao={visao}
ancora={QUARTA}
agora={QUARTA}
agendamentos={[]}
pessoas={[]}
/>,
);
}
function escondeNoCelular(dia: string) {
const col = screen.getByTestId(`coluna-dia-${dia}`);
return col.className.includes("max-md:hidden");
}
describe("grade da semana no celular", () => {
it("mostra só o dia âncora (quarta) e esconde os outros seis da semana", () => {
grade("semana");
// a âncora fica
expect(escondeNoCelular("2026-09-16")).toBe(false);
// os demais sete-menos-um somem abaixo de md
for (const outro of [
"2026-09-13",
"2026-09-14",
"2026-09-15",
"2026-09-17",
"2026-09-18",
"2026-09-19",
]) {
expect(escondeNoCelular(outro), `${outro} deveria sumir no celular`).toBe(true);
}
});
it("na visão de dia não esconde nada — já é uma coluna só", () => {
grade("dia");
expect(escondeNoCelular("2026-09-16")).toBe(false);
});
it("o desktop continua com a semana inteira", () => {
grade("semana");
// Todas as sete colunas existem no DOM: o que muda é só a classe de
// visibilidade. Esconder por desmontagem quebraria a rolagem e o arraste.
const colunas = screen.getAllByTestId(/^coluna-dia-/);
expect(colunas).toHaveLength(7);
});
});
+10
View File
@@ -524,6 +524,7 @@ function ColunaDeDia({
pessoas,
onAbrir,
destacado,
soNoDesktop,
interacao,
proposta,
arrasteDoCard,
@@ -534,6 +535,13 @@ function ColunaDeDia({
pessoas: Pessoa[];
onAbrir?: (id: string) => void;
destacado: boolean;
/**
* Some abaixo de `md`. Na semana, o celular mostra UM dia por vez: sete
* colunas em 360px dão ~44px cada, e a célula de meia hora vira um alvo de
* ~44x24 — errar o toque passa a ser o caso comum, não a exceção. Com uma
* coluna só, o mesmo alvo fica com a largura inteira da tela.
*/
soNoDesktop?: boolean;
interacao?: InteracaoDaGrade;
proposta?: PropostaDeRemarcacao | null;
arrasteDoCard?: {
@@ -551,6 +559,7 @@ function ColunaDeDia({
data-testid={`coluna-dia-${format(dia, "yyyy-MM-dd")}`}
className={cn(
"relative min-w-0 flex-1 border-r border-border last:border-r-0",
soNoDesktop && "max-md:hidden",
destacado && "bg-surface-elevated/40",
)}
>
@@ -977,6 +986,7 @@ export function GradeDaAgenda({
pessoas={pessoas}
onAbrir={onAbrirAgendamento}
destacado={visao === "semana" && isSameDay(d, agora)}
soNoDesktop={visao === "semana" && !isSameDay(d, ancora)}
interacao={interacao}
proposta={proposta}
arrasteDoCard={arrasteDoCard}
+30 -1
View File
@@ -5,7 +5,11 @@ import { apiClient } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { useT } from "@/hooks/i18n/useT";
import { showApiError } from "@/components/feedback/ApiErrorToast";
type Config = { confirmation_delay_minutes: number; unknown_protection_minutes: number };
type Config = {
confirmation_delay_minutes: number;
unknown_protection_minutes: number;
pending_expires_after_minutes: number;
};
export function PrazosDePresenca({ podeEditar }: { podeEditar: boolean }) {
const t = useT();
const qc = useQueryClient();
@@ -73,6 +77,31 @@ export function PrazosDePresenca({ podeEditar }: { podeEditar: boolean }) {
"Quando esse prazo acabar, a pendência continua visível. Outro compromisso vivo ainda protege o contato.",
)}
</p>
{/*
Prazo de coisa diferente das duas de cima: elas tratam do DEPOIS do
compromisso (compareceu ou não); esta trata do ANTES — do pedido que
ainda não foi confirmado e está segurando o horário.
*/}
<label className="block">
{t("Soltar o horário de um pedido não confirmado após (minutos)")}
<input
aria-label={t("Soltar o horário de um pedido não confirmado após (minutos)")}
className="ml-2 w-24 rounded-md border p-2"
type="number"
min={15}
max={10080}
disabled={!podeEditar}
value={value.pending_expires_after_minutes}
onChange={(e) =>
setDraft({ ...value, pending_expires_after_minutes: Number(e.target.value) })
}
/>
</label>
<p className="text-sm text-text-muted">
{t(
"Vale só para tipos de atendimento que pedem confirmação. Enquanto o pedido espera, o horário fica reservado e ninguém mais o pega; passado o prazo sem decisão, ele volta a ser oferecido. O cliente não é avisado, e o pedido continua na fila.",
)}
</p>
{podeEditar ? (
<Button disabled={!draft || mutation.isPending} onClick={() => mutation.mutate(value)}>
{t("Salvar prazos")}
@@ -0,0 +1,109 @@
/**
* Criar contato SEM sair da marcação.
*
* O que esta suíte protege: quem marca horário costuma estar com a pessoa na
* frente, e ela nem sempre já é contato. Antes deste atalho o fluxo PARAVA aqui
* — era preciso abandonar a marcação, ir até Contatos, criar, voltar e
* recomeçar. O termo digitado vira o nome, e o contato volta **selecionado**.
*
* O caso 3 é o que mais importa e o mais fácil de quebrar numa refatoração: se
* `onCriado` deixar de propagar o id, o contato nasce e a marcação continua sem
* ninguém — sem erro nenhum na tela.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VinculoDaMarcacao } from "./VinculoDaMarcacao";
const get = vi.fn();
vi.mock("@/lib/api/client", () => ({ apiClient: { get: (...a: unknown[]) => get(...a) } }));
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (s: string) => s }));
// O diálogo real arrasta formulário, toasts e mutation. Aqui interessa o FIO:
// ele recebe o nome digitado e devolve o contato criado?
vi.mock("@/components/contacts/NewContactDialog", () => ({
NewContactDialog: ({
open,
nomeInicial,
onCriado,
}: {
open: boolean;
nomeInicial?: string;
onCriado?: (c: { id: string; name: string }) => void;
}) =>
open ? (
<div>
<span data-testid="nome-recebido">{nomeInicial}</span>
<button type="button" onClick={() => onCriado?.({ id: "c-99", name: "Joana Prado" })}>
simular criação
</button>
</div>
) : null,
}));
function envolver(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
function responderCom(contacts: Array<{ id: string; name: string }>) {
get.mockResolvedValue({ data: { contacts, conversations: [] } });
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("VinculoDaMarcacao", () => {
it("oferece criar quando a busca não encontra ninguém", async () => {
responderCom([]);
const user = userEvent.setup();
envolver(<VinculoDaMarcacao contactId="" conversationId="" onChange={vi.fn()} />);
await user.type(screen.getByLabelText(/Buscar cliente/i), "Joana");
await waitFor(() => expect(screen.getByRole("button", { name: /Criar/i })).toBeInTheDocument());
expect(screen.getByRole("button", { name: /Joana/ })).toBeInTheDocument();
});
it("NÃO oferece criar quando a busca encontra alguém", async () => {
responderCom([{ id: "c-1", name: "Joana Prado" }]);
const user = userEvent.setup();
envolver(<VinculoDaMarcacao contactId="" conversationId="" onChange={vi.fn()} />);
await user.type(screen.getByLabelText(/Buscar cliente/i), "Joana");
await waitFor(() => expect(screen.getByRole("option", { name: "Joana Prado" })).toBeInTheDocument());
expect(screen.queryByRole("button", { name: /Criar/i })).not.toBeInTheDocument();
});
it("leva o nome digitado e devolve o contato JÁ SELECIONADO", async () => {
responderCom([]);
const onChange = vi.fn();
const user = userEvent.setup();
envolver(<VinculoDaMarcacao contactId="" conversationId="" onChange={onChange} />);
await user.type(screen.getByLabelText(/Buscar cliente/i), "Joana");
await waitFor(() => expect(screen.getByRole("button", { name: /Criar/i })).toBeInTheDocument());
await user.click(screen.getByRole("button", { name: /Criar/i }));
// o que foi digitado chega ao diálogo, para não redigitar
expect(screen.getByTestId("nome-recebido")).toHaveTextContent("Joana");
await user.click(screen.getByRole("button", { name: /simular criação/i }));
// e o contato volta selecionado — é isto que evita procurar o que acabou de criar
expect(onChange).toHaveBeenCalledWith("c-99", "");
});
it("não oferece criar antes de digitar", async () => {
responderCom([]);
envolver(<VinculoDaMarcacao contactId="" conversationId="" onChange={vi.fn()} />);
await waitFor(() => expect(get).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /Criar/i })).not.toBeInTheDocument();
});
});
+37
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { useT } from "@/hooks/i18n/useT";
import { NewContactDialog } from "@/components/contacts/NewContactDialog";
type Vinculos = {
contacts: Array<{ id: string; name: string }>;
conversations: Array<{ id: string; created_at: string; status: string }>;
@@ -18,6 +19,7 @@ export function VinculoDaMarcacao({
}) {
const t = useT();
const [search, setSearch] = useState("");
const [criando, setCriando] = useState(false);
const query = useQuery({
queryKey: ["agenda", "vinculos", contactId, search],
queryFn: async () =>
@@ -27,6 +29,14 @@ export function VinculoDaMarcacao({
)
).data,
});
// Quem marca horário costuma estar com a pessoa na frente, e ela nem sempre
// já é contato. Sem esta saída o fluxo PARA aqui: teria que abandonar a
// marcação, ir até Contatos, criar, voltar e recomeçar. O termo já digitado
// vira o nome, e o contato volta selecionado.
const buscou = search.trim().length > 0 && !contactId;
const nadaEncontrado = buscou && !query.isLoading && (query.data?.contacts.length ?? 0) === 0;
return (
<div className="space-y-3 rounded-lg border p-3">
<label className="block">
@@ -40,6 +50,17 @@ export function VinculoDaMarcacao({
}}
/>
</label>
{nadaEncontrado ? (
<button
type="button"
// Alvo de toque generoso: quem marca faz isso no celular, com o
// cliente esperando na frente.
className="min-h-11 w-full rounded-md border border-dashed px-3 text-left text-sm"
onClick={() => setCriando(true)}
>
{t("Criar")} “{search.trim()}”
</button>
) : null}
<label className="block">
{t("Quem será atendido")}
<select
@@ -75,6 +96,22 @@ export function VinculoDaMarcacao({
{query.isError ? (
<p role="alert">{t("Não foi possível carregar os vínculos. Tente novamente.")}</p>
) : null}
{/* `key` pelo termo: `nomeInicial` é defaultValue do formulário e só vale
na montagem. Sem remontar, quem fecha e digita outro nome reabriria com
o anterior. */}
<NewContactDialog
key={search.trim()}
open={criando}
onOpenChange={setCriando}
nomeInicial={search.trim()}
onCriado={(contato) => {
// Volta JÁ SELECIONADO. A busca passa a ser o nome do contato para a
// lista conter quem acabou de nascer — senão o `select` ficaria com um
// valor que ele não sabe desenhar.
setSearch(contato.name ?? search);
onChange(contato.id, "");
}}
/>
</div>
);
}
@@ -0,0 +1,134 @@
/**
* O CONTATO QUE VOLTA DO DIÁLOGO É O CONTATO, NÃO O ENVELOPE.
*
* `POST /api/v1/contacts` responde `ok(createContactHandler(...))`, e `ok()` já
* embrulha — o corpo na rede é `{ data: { contact, action } }`. Quem lê
* `resposta.data` recebe `{ contact, action }` e, se repassar isso como se
* fosse o contato, o `id` sai `undefined`: o contato nasce no banco e a
* marcação que abriu o diálogo continua sem ninguém. Nada na tela reclama.
*
* ⚠️ POR QUE ESTE ARQUIVO EXISTE AO LADO DE `VinculoDaMarcacao.test.tsx`.
* Aquele dubla o `NewContactDialog` inteiro e chama `onCriado` com um objeto
* que ELE mesmo escreve — prova que o fio está ligado do diálogo para cima, e
* é cego justamente para o andar de baixo, que é onde o defeito morava. Aqui
* roda o componente de verdade, sobre o `apiClient` de verdade, com o `fetch`
* devolvendo o corpo EXATO da rota.
*
* A ligação de compilação é o `ApiSuccess<CreateContactResult>` do fixture: se
* a rota mudar de forma, este arquivo para de compilar em vez de continuar
* verde medindo a forma antiga.
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CreateContactResult } from "@/app/api/v1/contacts/_handler";
import type { ApiSuccess } from "@/lib/api/wrappers";
import type { Contact } from "@/lib/types/contacts";
import { NewContactDialog } from "@/components/contacts/NewContactDialog";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (s: string) => s }));
vi.mock("@/components/feedback/ApiErrorToast", () => ({ showApiError: vi.fn() }));
const CONTATO = {
id: "ct-77",
organization_id: "org-1",
name: "Joana Prado",
display_name: null,
email: null,
email_normalized: null,
phone_number: "+5511999998888",
cpf_hash: null,
birthdate: null,
is_blocked: false,
blocked_reason: null,
is_anonymized: false,
anonymized_at: null,
is_merged_into: null,
merged_at: null,
consent: {},
tags: [],
source: "manual",
source_metadata: {},
custom_fields: {},
created_at: "2026-09-14T10:00:00.000Z",
updated_at: "2026-09-14T10:00:00.000Z",
last_activity_at: null,
} satisfies Contact;
/** O corpo que a rota devolve, tipado pelo retorno dela. */
const CORPO_DA_ROTA: ApiSuccess<CreateContactResult> = {
data: { contact: CONTATO, action: "created" },
};
function envolver(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify(CORPO_DA_ROTA), {
status: 201,
headers: { "content-type": "application/json" },
}),
),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("NewContactDialog · onCriado", () => {
it("entrega o CONTATO de dentro do envelope, com id utilizável", async () => {
const onCriado = vi.fn();
const user = userEvent.setup();
envolver(
<NewContactDialog open onOpenChange={vi.fn()} nomeInicial="Joana Prado" onCriado={onCriado} />,
);
await user.type(screen.getByLabelText(/Telefone/i), "+5511999998888");
await user.click(screen.getByRole("button", { name: /Criar contato/i }));
await waitFor(() => expect(onCriado).toHaveBeenCalledTimes(1));
const recebido = onCriado.mock.calls[0]?.[0] as Contact;
// O que quebra na vida real: o chamador usa `.id` para selecionar o contato.
expect(recebido.id).toBe("ct-77");
expect(recebido.name).toBe("Joana Prado");
// E o envelope NÃO pode ter vazado: `{ contact, action }` passaria nos
// testes de "foi chamado" e falharia em toda leitura de campo.
expect(recebido).not.toHaveProperty("contact");
expect(recebido).not.toHaveProperty("action");
});
it("não chama onCriado quando a rota não devolve contato", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ data: { action: "created" } }), {
status: 201,
headers: { "content-type": "application/json" },
}),
),
);
const onCriado = vi.fn();
const user = userEvent.setup();
envolver(<NewContactDialog open onOpenChange={vi.fn()} onCriado={onCriado} />);
await user.type(screen.getByLabelText(/Telefone/i), "+5511999998888");
await user.click(screen.getByRole("button", { name: /Criar contato/i }));
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
expect(onCriado).not.toHaveBeenCalled();
});
});
+24 -3
View File
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { contactCreateSchema, type ContactCreate } from "@/lib/schemas/contacts";
import type { Contact } from "@/lib/types/contacts";
import { useCreateContact } from "@/hooks/contacts/useCreateContact";
interface FormShape {
@@ -28,15 +29,29 @@ interface FormShape {
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
/**
* Nome já digitado por quem chamou, para não redigitar. Quem abre com um termo
* de busca em mãos passa aqui; o resto continua abrindo vazio.
*
* É `defaultValue` do formulário, então só vale na montagem — quem precisa
* trocar o termo com o diálogo já montado remonta com `key`.
*/
nomeInicial?: string;
/**
* Recebe o contato recém-criado. Existe para quem abriu o diálogo NO MEIO de
* outro fluxo (marcar um horário, por exemplo) poder seguir com ele já
* selecionado, em vez de mandar a pessoa procurar de novo o que acabou de criar.
*/
onCriado?: (contato: Contact) => void;
}
export function NewContactDialog({ open, onOpenChange }: Props) {
export function NewContactDialog({ open, onOpenChange, nomeInicial, onCriado }: Props) {
const t = useT();
const create = useCreateContact();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<FormShape>({
defaultValues: { name: "", email: "", phone_number: "", cpf: "", tagsRaw: "" },
defaultValues: { name: nomeInicial ?? "", email: "", phone_number: "", cpf: "", tagsRaw: "" },
});
async function onSubmit(values: FormShape) {
@@ -61,10 +76,16 @@ export function NewContactDialog({ open, onOpenChange }: Props) {
}
try {
await create.mutateAsync(parsed.data as ContactCreate);
const resposta = await create.mutateAsync(parsed.data as ContactCreate);
toast.success(t("Contato criado"));
form.reset();
onOpenChange(false);
// `.data` é o envelope do `ok()`, e dentro dele mora `{ contact, action }`.
// Entregar `resposta.data` aqui devolveria esse envelope como se fosse o
// contato: o `id` sairia `undefined` e a marcação ficaria sem ninguém, em
// silêncio. Quem garante que este caminho não volta a errar é o tipo do
// hook, ligado ao retorno da rota.
if (resposta?.data?.contact) onCriado?.(resposta.data.contact);
} catch {
// error toast already handled by hook
}
+1
View File
@@ -80,6 +80,7 @@ CRONS="
# antes em avisar entre 30 e 45 minutos antes. Barato: só olha compromisso
# confirmado, futuro e ainda não avisado.
*/5 * * * *|45|api/v1/cron/agenda-reminder
*/15 * * * *|45|api/v1/cron/agenda-expira-pendentes
*/15 * * * *|60|api/v1/cron/risk-watcher
# O CASO PARADO. De hora em hora, e não a cada 5 minutos: o prazo é de 24h, e
# uma varredura mais frequente só gastaria consulta para descobrir o mesmo nada.
+20 -9
View File
@@ -1,6 +1,7 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import type { ChamadoDaLista } from "@/lib/escalacao/chamados";
/** Espelha o CHECK de agent_cases.status (migration 0066, spec 15 §7). */
export type CaseStatus = "awaiting_human" | "awaiting_lead" | "resolved" | "escalated" | "cancelled";
@@ -24,16 +25,26 @@ export type CaseActorKind = "agent" | "human" | "system" | "lead";
/** A ação que o humano toma ao responder um caso — POST .../reply. */
export type CaseHumanAction = "resolved" | "need_lead_info" | "escalate";
export interface CaseListItem {
id: string;
title: string;
summary: string;
blocker: string;
/**
* O item da lista — DERIVADO do que a rota devolve, não redigitado ao lado dela.
*
* ⚠️ ESTA HERANÇA É A LIGAÇÃO DE COMPILAÇÃO QUE FALTAVA. `GET /api/v1/ai/cases`
* devolve `listarChamados(...)`, cujo tipo é `ChamadoDaLista`; o cliente
* declarava a mesma forma à mão, e as duas cópias divergiram em silêncio — um
* campo novo entrou na consulta PostgREST e na interface daqui, e não entrou na
* projeção `achatarContato`, que é quem monta o objeto de fato. Resultado: a
* tela lia `undefined` e mostrava o rótulo genérico para todo caso, com
* typecheck, lint e suíte verdes.
*
* Herdando, um campo que a rota não promete não existe aqui, e quem o ler para
* de compilar em vez de ler `undefined` em produção.
*
* `status` é reapertado para a união: o servidor tipa `string` (ele espelha a
* coluna), a tela precisa da união para indexar `STATUS_LABEL`. Estreitar é
* permitido; alargar não seria.
*/
export interface CaseListItem extends ChamadoDaLista {
status: CaseStatus;
opened_at: string;
conversation_id: string;
contact_name: string | null;
contact_phone: string | null;
}
export interface CaseListData {
+15 -2
View File
@@ -2,14 +2,27 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import type { Contact } from "@/lib/types/contacts";
import type { CreateContactResult } from "@/app/api/v1/contacts/_handler";
import type { ContactCreate } from "@/lib/schemas/contacts";
/**
* ⚠️ O ENVELOPE TEM DOIS ANDARES, E O DE DENTRO NÃO É O CONTATO.
*
* `POST /api/v1/contacts` devolve `ok(createContactHandler(...))`, e `ok()` já
* embrulha: o corpo é `{ data: { contact, action } }`. Este genérico dizia
* `{ data: Contact }` — uma afirmação que ninguém checava, porque o tipo é um
* `cast` sobre JSON e não uma ligação com a rota. Quem lesse `resposta.data`
* receberia o envelope do handler e acharia que tinha o contato: `id` e `name`
* saem `undefined`, sem erro nenhum.
*
* O `import type` do handler é a ligação que faltava: se a rota mudar de forma,
* quem lê aqui para de compilar em vez de ler `undefined` em produção.
*/
export function useCreateContact() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: ContactCreate) =>
apiClient.post<{ data: Contact; meta?: { action?: string } }>(
apiClient.post<{ data: CreateContactResult; meta?: { action?: string } }>(
"/api/v1/contacts",
input,
),
+46
View File
@@ -0,0 +1,46 @@
/**
* O gatilho de automação que cada transição da agenda emite.
*
* O caso que justifica o arquivo é o terceiro: `pending → confirmed` devolve
* `null` na timeline e um gatilho AQUI. Reaproveitar `atividadeDaTransicao`
* para as duas perguntas é a simplificação óbvia e errada — apagaria justamente
* o momento em que o horário deixa de ser pedido e vira compromisso, que é o
* gancho de "avise a cliente que está confirmado".
*/
import { describe, expect, it } from "vitest";
import { atividadeDaTransicao, gatilhoDaTransicao } from "./laco";
describe("gatilhoDaTransicao", () => {
it("nascer pendente ou confirmado é o mesmo gatilho: foi marcado", () => {
expect(gatilhoDaTransicao(null, "pending")).toBe("appointment.created");
expect(gatilhoDaTransicao(null, "confirmed")).toBe("appointment.created");
});
it("confirmar um pendente emite gatilho, mesmo sem virar linha do tempo", () => {
expect(gatilhoDaTransicao("pending", "confirmed")).toBe("appointment.confirmed");
// A régua da timeline discorda de propósito — é o ponto deste módulo.
expect(atividadeDaTransicao("pending", "confirmed")).toBeNull();
});
it("confirmar o que já estava confirmado não emite nada", () => {
expect(gatilhoDaTransicao("confirmed", "confirmed")).toBeNull();
});
it("remarcar e cancelar emitem os seus", () => {
expect(gatilhoDaTransicao("confirmed", "rescheduled")).toBe("appointment.rescheduled");
expect(gatilhoDaTransicao("confirmed", "cancelled")).toBe("appointment.cancelled");
expect(gatilhoDaTransicao("pending", "cancelled")).toBe("appointment.cancelled");
});
it("compareceu e faltou NÃO emitem: já têm appointment.outcome_confirmed", () => {
// Dois eventos para o mesmo fato fariam a regra rodar duas vezes.
expect(gatilhoDaTransicao("confirmed", "completed")).toBeNull();
expect(gatilhoDaTransicao("confirmed", "no_show")).toBeNull();
});
it("nascer já cancelado ou concluído não é gatilho de nada", () => {
expect(gatilhoDaTransicao(null, "cancelled")).toBeNull();
expect(gatilhoDaTransicao(null, "completed")).toBeNull();
});
});
+41
View File
@@ -68,6 +68,47 @@ export function atividadeDaTransicao(
}
}
/**
* O gatilho de automação que a transição emite, ou `null` quando ela não é
* notícia para uma regra.
*
* Existe porque o motor de regras (`lib/automation/engine.ts`) é forte —
* condições, runs, auditoria, e a ação de mandar WhatsApp já pronta — e não
* enxergava a agenda: nenhum dos seus gatilhos vinha de um compromisso. Um
* estúdio que quisesse avisar "amanhã é seu horário" tinha o motor, tinha o
* envio, e não tinha o fato.
*
* ⚠️ NÃO É A MESMA RÉGUA DA TIMELINE, e a diferença é o ponto:
* `atividadeDaTransicao` devolve `null` em `pending → confirmed` porque a
* história do lead já contou "foi marcado". Para uma regra, confirmar é
* EXATAMENTE o momento que interessa — é quando o horário deixa de ser pedido e
* vira compromisso, e é o gancho de "mandar a confirmação para a cliente".
* Reaproveitar a função da timeline aqui apagaria o gatilho mais útil dos três.
*
* `completed` e `no_show` ficam de fora porque já têm emissor próprio:
* `appointment.outcome_confirmed`, consumido por
* `lib/followup/gatilho-presenca.handler.ts`. Dois eventos para o mesmo fato
* fariam a regra rodar duas vezes.
*/
export function gatilhoDaTransicao(de: SituacaoAnterior, para: Transicao): string | null {
if (de === null) {
return para === "pending" || para === "confirmed" ? "appointment.created" : null;
}
switch (para) {
case "confirmed":
// Só sobe quando VEIO de pendente: `atualizarAgendamento` só chama com
// transição quando o status mudou de fato.
return de === "pending" ? "appointment.confirmed" : null;
case "rescheduled":
return "appointment.rescheduled";
case "cancelled":
return "appointment.cancelled";
default:
return null;
}
}
/**
* O compromisso precisa ser empurrado para o Google?
*
+28
View File
@@ -277,6 +277,34 @@ export type SituacaoExterna = (typeof SITUACOES_EXTERNAS)[number];
export const ALVO_DE_VINCULO_DO_AGENDAMENTO = "appointment" as const;
export const VINCULO_DE_AGENDAMENTO = "scheduled" as const;
/**
* O `entity_kind` do compromisso no `event_log`.
*
* É a TABELA no singular (`calendar_appointments` → `calendar_appointment`), e
* não o `appointment` do vínculo acima: são vocabulários de tabelas diferentes,
* e o motor de regras compara este valor contra `EXPECTED_ENTITY_KIND` para
* decidir se o evento é dele. Aproximar as duas strings porque "parecem a mesma
* coisa" faria o motor descartar todo gatilho de agenda em silêncio, como
* `entity_kind_mismatch` — que é exatamente o defeito que aquele guard existe
* para pegar em `lead` vs `crm_lead`.
*/
export const ENTIDADE_DO_AGENDAMENTO = "calendar_appointment" as const;
/**
* O nome a usar quando o tipo do compromisso não pôde ser lido.
*
* ⚠️ É FALLBACK, NÃO PADRÃO. Ele descreve a categoria, não o atendimento, e
* nenhuma condição de automação escrita por um operador vai casar com ele de
* propósito. Existe só para o caso real de o tipo ter sido apagado depois do
* compromisso ter nascido — o caminho normal lê `calendar_event_types.name`.
*
* Enquanto esta string era digitada como literal dentro dos handlers, ela ERA o
* caminho normal em três dos quatro gatilhos, e o efeito foi uma condição
* decorativa: a tela oferece "Tipo de atendimento contém …", a pessoa configura,
* salva, e a regra nunca dispara porque o payload sempre dizia "Agendamento".
*/
export const NOME_GENERICO_DO_TIPO = "Agendamento" as const;
/**
* O tipo de atividade que o agendamento emite na timeline do lead.
*
+21 -2
View File
@@ -1,4 +1,5 @@
import { currentExecutionBoundary, guardServiceEffect } from "@/lib/atendimento/fronteira-server";
import { TIPOS_DE_CASO, type TipoDeCaso } from "@/lib/ai/case-copy";
/**
* Casos humanos (spec 15) — o loop assíncrono IA↔humano quando o agente esbarra
* num bloqueio que só um humano resolve (aprovar desconto, confirmar política,
@@ -58,11 +59,25 @@ export type CaseEventKind =
| 'cancelled'
| 'agent_noted';
/**
* A tupla que o `z.enum` exige, derivada de `TIPOS_DE_CASO` — a fonte única do
* vocabulário. Escrever a lista de novo aqui criaria a segunda cópia, e é assim
* que o seletor da tela e o que a IA pode escolher divergem.
*/
const TIPOS_DE_CASO_KEYS = Object.keys(TIPOS_DE_CASO) as [TipoDeCaso, ...TipoDeCaso[]];
/** Whitelist EXATA do payload de open_human_case — mesmo padrão .strict() da F2-10/F3-02. */
export const openHumanCaseInputSchema = z.strictObject({
title: z.string().min(1).max(200),
summary: z.string().min(1).max(4_000),
blocker: z.string().min(1).max(1_000),
/**
* Do que o caso trata. OPCIONAL e com default: um modelo antigo, um clone com
* prompt diferente ou o fail-safe do guardrail continuam abrindo caso sem ele,
* e o caso cai em `outro` em vez de ser recusado. Classificação é conveniência
* de triagem — nunca pode ser motivo para o pedido do cliente não chegar.
*/
kind: z.enum(TIPOS_DE_CASO_KEYS).optional(),
});
export type OpenHumanCaseInput = z.infer<typeof openHumanCaseInputSchema>;
@@ -156,6 +171,7 @@ export async function openCase(
blocker: string;
contextSnapshot?: Record<string, unknown>;
source?: 'agent' | 'guardrail_autofallback';
kind?: string;
},
): Promise<OpenCaseResult> {
await guardServiceEffect();
@@ -165,8 +181,8 @@ export async function openCase(
const { rows } = await db.query<{ case_id: string }>(
`with new_case as (
insert into agent_cases
(organization_id, conversation_id, agent_id, title, summary, blocker, context_snapshot, source)
select $1, $2, $3, $4, $5, $6, $7::jsonb, $8
(organization_id, conversation_id, agent_id, title, summary, blocker, context_snapshot, source, kind)
select $1, $2, $3, $4, $5, $6, $7::jsonb, $8, $11
where not exists (
select 1 from agent_cases
where organization_id = $1 and conversation_id = $2
@@ -189,6 +205,9 @@ export async function openCase(
source,
OPEN_STATUSES,
actorKind,
// O default mora aqui e no banco: se um caminho novo esquecer de passar, a
// linha nasce classificada como 'outro' em vez de nula.
input.kind ?? 'outro',
],
);
+14
View File
@@ -1,4 +1,5 @@
import { setExecutionAgentOperation } from '@/lib/atendimento/fronteira-server';
import { TIPOS_DE_CASO, TIPOS_DE_CASO_PARA_A_IA } from "@/lib/ai/case-copy";
import { DEFAULT_CHANNEL_PROVIDER } from '@/lib/channels/capabilities';
import { applyPreviewPolicy, previewGateContext, type TurnPreview } from './preview';
import { claimOfJob } from '../queue/claim';
@@ -336,6 +337,19 @@ export const AGENT_TOOL_DEFS = {
title: z.string().describe('título curto, ex.: "Liberar acesso ao painel"'),
summary: z.string().describe('o que o lead precisa, em pt-br'),
blocker: z.string().describe('por que você não consegue resolver sozinho'),
// O assunto serve para quem TRIA a fila separar antes de ler. O detalhe
// continua no título e no resumo — este campo não os substitui, e por
// isso a lista é curta: muitas opções produzem classificação
// inconsistente, e aí o filtro atrapalha em vez de ajudar.
kind: z
.enum(Object.keys(TIPOS_DE_CASO) as [string, ...string[]])
.describe(
'do que o caso trata, para a equipe triar: ' +
Object.entries(TIPOS_DE_CASO_PARA_A_IA)
.map(([k, o]) => `${k} (${o})`)
.join('; ') +
'. Na dúvida entre dois, escolha o que descreve o PEDIDO, não o obstáculo.',
),
})
.passthrough(),
},
+73
View File
@@ -0,0 +1,73 @@
/**
* O vocabulário de assunto do caso vive num lugar só.
*
* A coluna `agent_cases.kind` é `text` SEM CHECK (doutrina de vocabulário aberto
* do CLAUDE.md), então o banco não segura nada — quem segura é `TIPOS_DE_CASO`.
* Se a lista que a IA pode escolher divergir da que a tela sabe rotular, o
* sintoma é um caso classificado que aparece como "Outro" para sempre, sem erro
* em lugar nenhum.
*/
import { describe, expect, it } from "vitest";
import { openHumanCaseInputSchema } from "@/lib/agent-engine/agent/human-cases";
import { TIPOS_DE_CASO, TIPOS_DE_CASO_PARA_A_IA, tipoDeCasoLabel } from "./case-copy";
describe("vocabulário de assunto do caso", () => {
it("todo tipo tem rótulo para a tela E orientação para a IA", () => {
for (const k of Object.keys(TIPOS_DE_CASO)) {
expect(TIPOS_DE_CASO[k as keyof typeof TIPOS_DE_CASO], k).toBeTruthy();
expect(TIPOS_DE_CASO_PARA_A_IA[k as keyof typeof TIPOS_DE_CASO], k).toBeTruthy();
}
// Sem tipo órfão do outro lado.
expect(Object.keys(TIPOS_DE_CASO_PARA_A_IA).sort()).toEqual(Object.keys(TIPOS_DE_CASO).sort());
});
it("a tool aceita exatamente os tipos da fonte — nem mais, nem menos", () => {
for (const k of Object.keys(TIPOS_DE_CASO)) {
const r = openHumanCaseInputSchema.safeParse({
title: "t",
summary: "s",
blocker: "b",
kind: k,
});
expect(r.success, `a tool recusou "${k}", que está no vocabulário`).toBe(true);
}
});
it("tipo inventado pelo modelo é recusado", () => {
// Vocabulário aberto no BANCO não significa aberto na TOOL: o que a IA
// escolhe tem que estar na lista, senão a triagem vira texto livre.
const r = openHumanCaseInputSchema.safeParse({
title: "t",
summary: "s",
blocker: "b",
kind: "reclamacao_de_unha",
});
expect(r.success).toBe(false);
});
it("o tipo é OPCIONAL — classificar nunca pode impedir o pedido de chegar", () => {
// Modelo antigo, clone com prompt diferente, ou o fail-safe do guardrail:
// todos continuam abrindo caso. Recusar por falta de classificação seria
// perder o pedido do cliente por causa de uma conveniência de triagem.
const r = openHumanCaseInputSchema.safeParse({ title: "t", summary: "s", blocker: "b" });
expect(r.success).toBe(true);
});
it("valor desconhecido no banco cai no genérico, nunca quebra a tela", () => {
// Um clone com engine mais novo, ou um caso classificado antes de alguém
// encurtar a lista.
expect(tipoDeCasoLabel("tipo_que_nao_existe")).toBe(TIPOS_DE_CASO.outro);
expect(tipoDeCasoLabel("")).toBe(TIPOS_DE_CASO.outro);
expect(tipoDeCasoLabel("agendamento")).toBe(TIPOS_DE_CASO.agendamento);
});
it("a lista é curta — muitas categorias produzem classificação inconsistente", () => {
// Não é preciosismo: o detalhe já mora em `title`/`summary`. Uma lista longa
// faria o modelo escolher diferente para o mesmo pedido em dias diferentes,
// e aí o filtro atrapalha em vez de ajudar. Se alguém quiser crescer, que
// seja uma decisão consciente — e não um item por pedido de cliente.
expect(Object.keys(TIPOS_DE_CASO).length).toBeLessThanOrEqual(8);
});
});
+55
View File
@@ -7,6 +7,61 @@
*/
import type { CaseEvent, CaseHumanAction, CaseStatus } from "@/hooks/ai/useCases";
/**
* DO QUE O CASO TRATA — o vocabulário que a IA escolhe e por onde a fila se tria.
*
* ⚠️ ESTA CONSTANTE É A FONTE. A coluna `agent_cases.kind` é `text` **sem
* CHECK**, pela doutrina de vocabulário aberto do CLAUDE.md: o que serve muda
* com o nicho, e um CHECK fixo exigiria migration por negócio e quebraria o
* `update.sh` de um clone com valor próprio. Quem prende o vocabulário é isto
* aqui — e quem escreve usa a constante, nunca uma string literal.
*
* ⚠️ A LISTA É CURTA DE PROPÓSITO. Ela existe para TRIAR, não para descrever: o
* que o caso é em detalhe já está no `title` e no `summary`, escritos pela IA
* com as palavras daquele cliente. Vinte categorias dariam a ilusão de precisão
* e produziriam classificação inconsistente — o modelo escolheria diferente para
* o mesmo pedido em dias diferentes, e o filtro pioraria em vez de ajudar.
*
* Medido no CRM de origem (102 pedidos): agendamento 53, atendimento humano 33,
* remarcação 4, pagamento 4, curso 3, cancelamento 2, dúvida 2, outro 1. As três
* primeiras são a mesma pergunta ("mexer no horário de alguém") e colapsam em
* `agendamento`; "atendimento humano" não entra porque no destino pedir uma
* pessoa é handoff, não caso.
*/
export const TIPOS_DE_CASO = {
agendamento: "Horário",
duvida: "Dúvida",
problema: "Algo deu errado",
financeiro: "Pagamento",
acesso: "Acesso ou cadastro",
outro: "Outro",
} as const;
export type TipoDeCaso = keyof typeof TIPOS_DE_CASO;
/** O que a IA lê para escolher. Uma frase por tipo, sem exemplo de nicho. */
export const TIPOS_DE_CASO_PARA_A_IA: Record<TipoDeCaso, string> = {
agendamento: "marcar, remarcar ou cancelar um horário",
duvida: "uma pergunta que você não conseguiu responder",
problema: "algo deu errado, uma reclamação, um atendimento insatisfatório",
financeiro: "pagamento, cobrança, valor, reembolso",
acesso: "liberar acesso, corrigir cadastro, senha",
outro: "não se encaixa em nenhum dos acima",
};
/**
* O rótulo de um tipo vindo do banco.
*
* Aceita `string` e não `TipoDeCaso` de propósito: o valor chega em runtime, e
* um clone com engine mais novo (ou um caso classificado antes de alguém
* encurtar a lista) pode trazer algo que este build não conhece. Cair no
* genérico é o desfecho certo — vocabulário aberto sem fallback é vocabulário
* que quebra a tela.
*/
export function tipoDeCasoLabel(kind: string): string {
return (TIPOS_DE_CASO as Record<string, string>)[kind] ?? TIPOS_DE_CASO.outro;
}
export const STATUS_LABEL: Record<CaseStatus, string> = {
awaiting_human: "Aguardando você",
awaiting_lead: "Aguardando o cliente",
+6
View File
@@ -450,6 +450,12 @@ export const AUDIT_ACTIONS = [
// o que se quer responder depois é "o sistema cobrou?", e uma linha por caso
// faria do audit log a própria fila.
"ai.caso_parado_cobrado",
// Um pedido não confirmado soltou o horário que estava segurando. Audita
// porque é CANCELAMENTO — o compromisso deixa de existir para quem o pediu —,
// e sem esta linha a única explicação para o horário ter voltado a aparecer
// seria "sumiu". Só a rodada que expirou alguma coisa; varredura vazia não é
// mutação.
"agenda.pendente_expirado",
// A rodada de renovação — e ela só audita quando FEZ algo, como manda a regra
// do cron desta base. Uma linha por rodada com efeito, carregando a contagem:
// é o que permite responder "quantas agendas precisaram reconectar esta
+6 -1
View File
@@ -1,12 +1,17 @@
import { createAdminClient } from "@/lib/supabase/admin";
import type { EventHandler } from "@/lib/event-log/dispatcher";
import { AUTOMATION_CONSUMER_KEY, runAutomationForEvent } from "@/lib/automation/engine";
import { TRIGGER_EVENTS } from "@/lib/schemas/webhooks";
// Importa os executores para que se registrem (side-effect imports — Tasks 9-11):
import "@/lib/automation/actions/register-all";
export const automationRulesHandler: EventHandler = {
key: AUTOMATION_CONSUMER_KEY,
events: ["lead.created", "lead.stage_changed", "message.received", "lead.tag_added", "contact.tag_added"],
// Assina EXATAMENTE o que a tela deixa escolher. Enquanto esta lista era
// escrita à mão ao lado de `TRIGGER_EVENTS`, um gatilho novo podia existir no
// seletor e não chegar aqui — e aí a regra é salva, o evento acontece e nada
// roda, sem erro nem log.
events: [...TRIGGER_EVENTS],
async handle(row) {
return runAutomationForEvent(createAdminClient(), row);
},
+24 -7
View File
@@ -20,17 +20,12 @@ import { evaluateConditions, type RuleCondition } from "@/lib/automation/conditi
import { getAction } from "@/lib/automation/actions";
import type { ActionResultDetail } from "@/lib/automation/types";
import { audit } from "@/lib/audit";
import { ENTIDADE_ESPERADA_POR_GATILHO } from "@/lib/schemas/webhooks";
import { logger } from "@/lib/logger";
export const AUTOMATION_CONSUMER_KEY = "automation-rules";
const EXPECTED_ENTITY_KIND: Record<string, string> = {
"lead.created": "crm_lead",
"lead.stage_changed": "crm_lead",
"lead.tag_added": "crm_lead",
"contact.tag_added": "contact",
"message.received": "message",
};
const EXPECTED_ENTITY_KIND: Record<string, string> = ENTIDADE_ESPERADA_POR_GATILHO;
interface RuleRow {
id: string;
@@ -72,6 +67,28 @@ export async function buildContext(admin: SupabaseClient, row: EventRow): Promis
.eq("organization_id", org)
.maybeSingle();
if (contact) context.contact = contact;
} else if (row.entity_kind === "calendar_appointment" && row.entity_id) {
const { data: appointment } = await admin
.from("calendar_appointments")
.select("*")
.eq("id", row.entity_id)
.eq("organization_id", org)
.maybeSingle();
if (appointment) {
context.appointment = appointment;
// O contato sai do COMPROMISSO, não do payload: quem escreve a regra vai
// querer `contact.name` no texto da mensagem, e a linha do banco é a
// versão de agora — o payload é a de quando o evento nasceu.
if (appointment.contact_id) {
const { data: contact } = await admin
.from("contacts")
.select("*")
.eq("id", appointment.contact_id)
.eq("organization_id", org)
.maybeSingle();
if (contact) context.contact = contact;
}
}
} else if (row.entity_kind === "message" && row.entity_id) {
const contactId = row.payload.contact_id as string | undefined;
if (contactId) {
@@ -0,0 +1,53 @@
/**
* Os gatilhos de automação vivem numa fonte só, e as três pontas concordam.
*
* Eram três listas escritas à mão: o enum do Zod (a tela), o mapa de entidade
* (o guard do motor) e o `events` do handler (a assinatura no dispatcher).
* Esquecer a terceira é o defeito mais caro dos três, porque ele é MUDO: a
* regra aparece no seletor, o operador a salva, o evento acontece — e nada
* roda. Sem erro, sem log, sem run. Só um cliente que não recebeu a mensagem.
*
* Este teste não repete a lista: ele prova que as pontas derivam da mesma.
*/
import { describe, expect, it } from "vitest";
import { automationRulesHandler } from "./engine.handler";
import { gatilhoDaTransicao } from "@/lib/agenda/laco";
import { ENTIDADE_ESPERADA_POR_GATILHO, TRIGGER_EVENTS } from "@/lib/schemas/webhooks";
describe("gatilhos de automação", () => {
it("o handler assina exatamente o que a tela deixa escolher", () => {
expect([...automationRulesHandler.events].sort()).toEqual([...TRIGGER_EVENTS].sort());
});
it("todo gatilho declara a entidade que o guard do motor espera", () => {
for (const gatilho of TRIGGER_EVENTS) {
expect(ENTIDADE_ESPERADA_POR_GATILHO[gatilho], gatilho).toBeTruthy();
}
});
it("todo gatilho que a agenda emite é reconhecido pelo motor", () => {
// A ponta emissora, contra a ponta consumidora. Um `appointment.x` emitido
// e não declarado é evento sem consumer — anti-pattern nº 3 da doutrina.
const daAgenda = new Set(
(
[
[null, "pending"],
[null, "confirmed"],
["pending", "confirmed"],
["confirmed", "rescheduled"],
["confirmed", "cancelled"],
] as const
)
.map(([de, para]) => gatilhoDaTransicao(de, para))
.filter((g): g is string => g !== null),
);
expect(daAgenda.size).toBeGreaterThan(0);
for (const gatilho of daAgenda) {
expect(TRIGGER_EVENTS as readonly string[], gatilho).toContain(gatilho);
expect(ENTIDADE_ESPERADA_POR_GATILHO[gatilho as keyof typeof ENTIDADE_ESPERADA_POR_GATILHO])
.toBe("calendar_appointment");
}
});
});
+20 -2
View File
@@ -22,6 +22,18 @@ export interface ChamadoDaLista {
summary: string;
blocker: string;
status: string;
/**
* Do que o caso trata — o corte por onde a fila se tria.
*
* ⚠️ ESTE CAMPO PRECISA APARECER EM TRÊS LUGARES, e os três são o contrato:
* na `COLUNAS_*` (o que o PostgREST traz), aqui (o que a rota promete) e em
* `achatarContato` (o que a rota de fato devolve). Ele já entrou na consulta
* sem entrar na projeção uma vez: a coluna vinha do banco e morria no `map`,
* e a tela renderizava "Outro" para todo caso, para sempre, com os gates
* verdes. `string` e não a união porque o vocabulário é ABERTO no banco —
* quem resolve valor desconhecido é `tipoDeCasoLabel`.
*/
kind: string;
opened_at: string;
conversation_id: string;
contact_name: string | null;
@@ -45,11 +57,11 @@ export interface ChamadoDetalhado extends ChamadoDaLista {
}
const COLUNAS_LISTA =
"id, title, summary, blocker, status, opened_at, conversation_id, " +
"id, title, summary, blocker, status, kind, opened_at, conversation_id, " +
"conversations:conversation_id(contacts:contact_id(name, phone_number))";
const COLUNAS_DETALHE =
"id, title, summary, blocker, status, source, opened_at, closed_at, conversation_id, " +
"id, title, summary, blocker, status, kind, source, opened_at, closed_at, conversation_id, " +
"conversations:conversation_id(contacts:contact_id(name, phone_number))";
interface LinhaComContato {
@@ -58,6 +70,7 @@ interface LinhaComContato {
summary: string;
blocker: string;
status: string;
kind: string | null;
opened_at: string;
conversation_id: string;
source?: string;
@@ -72,6 +85,11 @@ function achatarContato(r: LinhaComContato): ChamadoDaLista {
summary: r.summary,
blocker: r.blocker,
status: r.status,
// `?? "outro"` e não `r.kind` cru: a coluna é `not null default 'outro'`,
// mas uma linha lida por um caminho que ainda não a traga viraria
// `undefined` no JSON — e `undefined` some na serialização, devolvendo à
// tela exatamente o buraco que este campo existe para fechar.
kind: r.kind ?? "outro",
opened_at: r.opened_at,
conversation_id: r.conversation_id,
contact_name: r.conversations?.contacts?.name ?? null,
+2
View File
@@ -369,6 +369,8 @@ export const DICIONARIO: Traducoes = {
"Abrir atendimento": { es: "Abrir atención" },
"Abra o atendimento e diga o que fazer: concluir, pedir informação ao cliente ou passar para uma pessoa.": { es: "Abre la atención y di qué hacer: concluir, pedir información al cliente o pasarla a una persona." },
"Um atendimento espera decisão da equipe": { es: "Una atención espera decisión del equipo" },
"Soltar o horário de um pedido não confirmado após (minutos)": { es: "Liberar el horario de una solicitud no confirmada después de (minutos)" },
"Vale só para tipos de atendimento que pedem confirmação. Enquanto o pedido espera, o horário fica reservado e ninguém mais o pega; passado o prazo sem decisão, ele volta a ser oferecido. O cliente não é avisado, e o pedido continua na fila.": { es: "Vale solo para tipos de atención que piden confirmación. Mientras la solicitud espera, el horario queda reservado y nadie más lo toma; pasado el plazo sin decisión, vuelve a ofrecerse. El cliente no recibe aviso, y la solicitud sigue en la fila." },
// ─── Navegação (a barra lateral, presente em toda tela) ───
Inbox: { es: "Inbox" },
+14 -1
View File
@@ -229,5 +229,18 @@ export type MarcaDaOrganizacaoInput = z.infer<typeof marcaDaOrganizacaoSchema>;
export const agendaSettingsWriteSchema = z.strictObject({
confirmation_delay_minutes: z.number().int().min(1).max(10080),
unknown_protection_minutes: z.number().int().min(1).max(10080),
/**
* Quanto tempo um pedido não confirmado segura o horário.
*
* ⚠️ `.default()` e não obrigatório: este schema é `strictObject`, e torná-lo
* exigido faria TODO PATCH já escrito (que manda só os dois campos de cima)
* passar a falhar — o tipo de mudança que a doutrina de packaging proíbe,
* porque quebra quem já instalou sem nenhum aviso.
*
* 24h é o default porque quem confere a fila uma vez por dia não pode perder
* pedido. O mínimo é 15 minutos: abaixo disso a expiração corre com quem está
* decidindo naquele instante.
*/
pending_expires_after_minutes: z.number().int().min(15).max(10080).default(1440),
}).refine(v => v.unknown_protection_minutes >= v.confirmation_delay_minutes, {message:"O prazo de proteção deve ser maior que o prazo de confirmação."});
export const agendaSettingsSchema = agendaSettingsWriteSchema.catch({confirmation_delay_minutes:10,unknown_protection_minutes:1440});
export const agendaSettingsSchema = agendaSettingsWriteSchema.catch({confirmation_delay_minutes:10,unknown_protection_minutes:1440,pending_expires_after_minutes:1440});
+39 -9
View File
@@ -1,17 +1,47 @@
/**
* Zod schemas for webhook-sources e automation-rules (feature Webhooks, Task 12).
* TRIGGER_EVENTS deve espelhar exatamente os 5 eventos que o motor
* (`lib/automation/engine.ts` → EXPECTED_ENTITY_KIND) reconhece.
*
* `ENTIDADE_ESPERADA_POR_GATILHO`, logo abaixo, é a fonte única dos gatilhos:
* `lib/automation/engine.ts` e `lib/automation/engine.handler.ts` leem daqui.
* (Este cabeçalho já afirmou "exatamente os 5 eventos" — número que envelheceu
* na primeira vez que alguém acrescentou um. Agora não há número a envelhecer.)
*/
import { z } from "zod";
export const TRIGGER_EVENTS = [
"lead.created",
"lead.stage_changed",
"message.received",
"lead.tag_added",
"contact.tag_added",
] as const;
/**
* Os gatilhos que o motor reconhece, e a entidade que cada um tem que trazer.
*
* É UMA FONTE, e não três, porque as três divergiam: este arquivo listava os
* gatilhos para o Zod, `engine.ts` repetia o mapa de entidade, e
* `engine.handler.ts` repetia a lista de novo para se registrar no dispatcher.
* Acrescentar um gatilho exigia lembrar dos três lugares, e esquecer o terceiro
* produz o pior desfecho possível: a regra aparece na tela, o operador a salva,
* o evento acontece — e nada roda, porque o handler não assinou aquele evento.
* Sem erro, sem log, sem run.
*
* A entidade existe porque o trigger legado `fn_emit_event_on_lead_change` emite
* `lead.created` com `entity_kind='lead'` (derivado por `split_part` do
* event_type) enquanto os handlers desta feature emitem `crm_lead`. Sem o guard,
* o motor rodaria a regra duas vezes por mudança de lead.
*/
export const ENTIDADE_ESPERADA_POR_GATILHO = {
"lead.created": "crm_lead",
"lead.stage_changed": "crm_lead",
"message.received": "message",
"lead.tag_added": "crm_lead",
"contact.tag_added": "contact",
"appointment.created": "calendar_appointment",
"appointment.confirmed": "calendar_appointment",
"appointment.rescheduled": "calendar_appointment",
"appointment.cancelled": "calendar_appointment",
} as const;
export type GatilhoDeAutomacao = keyof typeof ENTIDADE_ESPERADA_POR_GATILHO;
export const TRIGGER_EVENTS = Object.keys(ENTIDADE_ESPERADA_POR_GATILHO) as [
GatilhoDeAutomacao,
...GatilhoDeAutomacao[],
];
export const conditionSchema = z.object({
field: z.string().min(1).max(200),
+104
View File
@@ -24286,6 +24286,110 @@ create index if not exists idx_ai_agent_runs_inbound_message_id
create index if not exists idx_ai_agent_runs_outbound_message_id
on public.ai_agent_runs (outbound_message_id)
where outbound_message_id is not null;
-- ---- o caso tem assunto: agent_cases.kind (migration 0248) ----
-- `agent_cases.kind` — do que o caso trata, para quem tria a fila.
--
-- POR QUE: hoje o assunto de um caso vive só em texto livre (`title`, `summary`,
-- `blocker`). Com a fila curta isso basta — dá para ler tudo. Com volume, não:
-- quem abre a fila quer separar "alguém quer marcar horário" de "alguém está
-- reclamando" antes de ler qualquer coisa, porque as duas pedem pessoas e
-- urgências diferentes.
--
-- Medido no CRM de origem: 102 pedidos em poucos meses, distribuídos em
-- agendamento 53, atendimento humano 33, remarcação 4, pagamento 4, curso 3,
-- cancelamento 2, dúvida 2, outro 1. A triagem por assunto era o que a tela de
-- lá oferecia, e é o que falta aqui.
--
-- ⚠️ SEM CHECK, DE PROPÓSITO — e isto é a doutrina de vocabulário ABERTO do
-- CLAUDE.md, não descuido. O vocabulário útil muda com o negócio: clínica tem
-- "remarcação", loja tem "troca". Um CHECK fixo aqui obrigaria uma migration
-- por nicho, e faria o `update.sh` de um clone com valor próprio quebrar. Quem
-- prende o vocabulário é a constante `TIPOS_DE_CASO` no TypeScript, e o emissor
-- usa ela — nunca string literal. A coluna fica FORA do invariante
-- `vocabulario-banco-x-typescript`, que só cobre coluna que JÁ tem CHECK.
--
-- `default 'outro'` e `not null`: caso antigo não fica com buraco, e caso novo
-- sem classificação cai no genérico em vez de num nulo que toda tela precisa
-- tratar. Nenhum backfill: o default resolve as linhas existentes na hora.
alter table public.agent_cases
add column if not exists kind text not null default 'outro';
comment on column public.agent_cases.kind is
'Do que o caso trata, para triagem. Vocabulário ABERTO (sem CHECK): a lista vigente é TIPOS_DE_CASO em lib/ai/case-copy.ts, e quem escreve usa a constante. Valor desconhecido cai no rótulo genérico da tela, nunca quebra.';
-- A fila é sempre lida por organização e por status; o assunto é o terceiro
-- corte. Parcial nos abertos porque é neles que se tria — resolvido vira
-- histórico, e histórico se consulta inteiro.
create index if not exists agent_cases_org_status_kind_idx
on public.agent_cases (organization_id, kind)
where status in ('awaiting_human', 'awaiting_lead');
-- ---- agenda: prazo de expiração do pedido não confirmado (migration 0249) ----
--
-- `fn_agenda_settings` ENUMERA as chaves aceitas e rejeita extras, então o campo
-- novo precisa dela recriada — senão a tela salva e recebe 22023. Opcional de
-- propósito: toda organização já instalada tem duas chaves, e exigir a terceira
-- quebraria o PATCH de uma aba aberta antes da atualização. Ausente = default do
-- lado TypeScript (1440 minutos). Nenhum backfill: a ausência já é estado válido.
--
-- ⚠️ ESTA VERSÃO É DERIVADA DA QUE ESTÁ EM VIGOR, NÃO REESCRITA DO ZERO — e o
-- portão de MFA da linha abaixo é o motivo. Ele entrou pela migration 0229
-- (`0229_mfa_e_lgpd_agenda`), e uma reescrita a partir do corpo ANTIGO o
-- apagaria sem deixar rastro: `create or replace` não avisa o que sumiu, o
-- espelho migration↔baseline continua fiel (fiel carregando o defeito), e o
-- `update.sh` de quem já rodava REMOVERIA a proteção que ele tinha. Recriar
-- função aqui é sempre derivar da que está em vigor.
create or replace function public.fn_agenda_settings(p_org uuid, p_config jsonb)
returns jsonb language plpgsql security definer set search_path = public as $$
begin
if auth.uid() is null
or not public.fn_role_at_least(p_org, 'manager')
or not public.fn_support_write_allowed(p_org) then
raise exception 'agenda_settings_forbidden' using errcode = '42501';
end if;
-- Portão de MFA (migration 0229). Prazos de agenda são configuração que muda
-- o comportamento do produto para a organização inteira.
if not public.fn_session_mfa_proven() then
raise exception 'agenda_mfa_required' using errcode = '42501';
end if;
if jsonb_typeof(p_config->'confirmation_delay_minutes') is distinct from 'number'
or jsonb_typeof(p_config->'unknown_protection_minutes') is distinct from 'number'
or (p_config - 'confirmation_delay_minutes'
- 'unknown_protection_minutes'
- 'pending_expires_after_minutes') <> '{}'::jsonb
or (p_config->>'confirmation_delay_minutes' ~ '^[0-9]{1,5}$') is not true
or (p_config->>'unknown_protection_minutes' ~ '^[0-9]{1,5}$') is not true
or (p_config->>'confirmation_delay_minutes')::int not between 1 and 10080
or (p_config->>'unknown_protection_minutes')::int not between 1 and 10080
or (p_config->>'unknown_protection_minutes')::int
< (p_config->>'confirmation_delay_minutes')::int
then
raise exception 'agenda_settings_invalid' using errcode = '22023';
end if;
if p_config ? 'pending_expires_after_minutes' then
if jsonb_typeof(p_config->'pending_expires_after_minutes') is distinct from 'number'
or (p_config->>'pending_expires_after_minutes' ~ '^[0-9]{1,5}$') is not true
or (p_config->>'pending_expires_after_minutes')::int not between 15 and 10080
then
raise exception 'agenda_settings_invalid' using errcode = '22023';
end if;
end if;
update public.organizations
set settings = jsonb_set(coalesce(settings, '{}'::jsonb), '{agenda}', p_config, true)
where id = p_org;
if not found then
raise exception 'organization_not_found' using errcode = 'P0002';
end if;
return p_config;
end; $$;
revoke all on function public.fn_agenda_settings(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.fn_agenda_settings(uuid, jsonb) to authenticated;
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
--
@@ -0,0 +1,37 @@
-- `agent_cases.kind` — do que o caso trata, para quem tria a fila.
--
-- POR QUE: hoje o assunto de um caso vive só em texto livre (`title`, `summary`,
-- `blocker`). Com a fila curta isso basta — dá para ler tudo. Com volume, não:
-- quem abre a fila quer separar "alguém quer marcar horário" de "alguém está
-- reclamando" antes de ler qualquer coisa, porque as duas pedem pessoas e
-- urgências diferentes.
--
-- Medido no CRM de origem: 102 pedidos em poucos meses, distribuídos em
-- agendamento 53, atendimento humano 33, remarcação 4, pagamento 4, curso 3,
-- cancelamento 2, dúvida 2, outro 1. A triagem por assunto era o que a tela de
-- lá oferecia, e é o que falta aqui.
--
-- ⚠️ SEM CHECK, DE PROPÓSITO — e isto é a doutrina de vocabulário ABERTO do
-- CLAUDE.md, não descuido. O vocabulário útil muda com o negócio: clínica tem
-- "remarcação", loja tem "troca". Um CHECK fixo aqui obrigaria uma migration
-- por nicho, e faria o `update.sh` de um clone com valor próprio quebrar. Quem
-- prende o vocabulário é a constante `TIPOS_DE_CASO` no TypeScript, e o emissor
-- usa ela — nunca string literal. A coluna fica FORA do invariante
-- `vocabulario-banco-x-typescript`, que só cobre coluna que JÁ tem CHECK.
--
-- `default 'outro'` e `not null`: caso antigo não fica com buraco, e caso novo
-- sem classificação cai no genérico em vez de num nulo que toda tela precisa
-- tratar. Nenhum backfill: o default resolve as linhas existentes na hora.
alter table public.agent_cases
add column if not exists kind text not null default 'outro';
comment on column public.agent_cases.kind is
'Do que o caso trata, para triagem. Vocabulário ABERTO (sem CHECK): a lista vigente é TIPOS_DE_CASO em lib/ai/case-copy.ts, e quem escreve usa a constante. Valor desconhecido cai no rótulo genérico da tela, nunca quebra.';
-- A fila é sempre lida por organização e por status; o assunto é o terceiro
-- corte. Parcial nos abertos porque é neles que se tria — resolvido vira
-- histórico, e histórico se consulta inteiro.
create index if not exists agent_cases_org_status_kind_idx
on public.agent_cases (organization_id, kind)
where status in ('awaiting_human', 'awaiting_lead');
@@ -0,0 +1,89 @@
-- Um terceiro prazo em `organizations.settings.agenda`:
-- `pending_expires_after_minutes`, que é quanto tempo um pedido não confirmado
-- segura o horário.
--
-- POR QUE UMA MIGRATION PARA UM CAMPO DE JSONB: `fn_agenda_settings` não faz
-- merge — ela ENUMERA as chaves aceitas e rejeita qualquer extra
-- (`(p_config - 'a' - 'b') <> '{}'` levanta `agenda_settings_invalid`). Sem
-- recriá-la, a tela salvaria o campo novo e receberia 22023, e o operador veria
-- "não foi possível alterar os prazos" sem entender por quê.
--
-- ⚠️ O CAMPO NOVO É OPCIONAL, e isso não é preguiça de validação: toda
-- organização já instalada tem `settings.agenda` com DUAS chaves. Se a função
-- passasse a exigir três, o PATCH da tela de prazos — que ainda pode vir de uma
-- aba aberta antes da atualização — quebraria para todo mundo. Ausente significa
-- "use o default", que o lado TypeScript resolve em `agendaSettingsSchema`
-- (1440 minutos).
--
-- Idempotente por `create or replace`. Não toca em dado nenhum: nenhuma
-- organização precisa de backfill, porque a ausência da chave já é um estado
-- válido e com significado.
--
-- ⚠️ ESTE CORPO É DERIVADO DA VERSÃO EM VIGOR, NÃO REESCRITO A PARTIR DA
-- ORIGINAL. A função ganhou um portão de MFA na migration 0229
-- (`0229_mfa_e_lgpd_agenda`), e partir do corpo antigo o apagaria: `create or
-- replace` troca a definição inteira e não avisa o que sumiu. O estrago passa
-- do teste — o apêndice do `baseline.sql` repete o mesmo corpo, e o `update.sh`
-- de quem já rodava REMOVERIA a proteção que ele tinha. A verificação
-- migration↔baseline não pega: o espelho fica fiel, carregando o defeito.
-- Vigiado por `tests/unit/mfa-nao-some-em-funcao-recriada.test.ts`.
create or replace function public.fn_agenda_settings(p_org uuid, p_config jsonb)
returns jsonb language plpgsql security definer set search_path = public as $$
begin
if auth.uid() is null
or not public.fn_role_at_least(p_org, 'manager')
or not public.fn_support_write_allowed(p_org) then
raise exception 'agenda_settings_forbidden' using errcode = '42501';
end if;
-- Portão de MFA (migration 0229). Prazos de agenda são configuração que muda
-- o comportamento do produto para a organização inteira.
if not public.fn_session_mfa_proven() then
raise exception 'agenda_mfa_required' using errcode = '42501';
end if;
if jsonb_typeof(p_config->'confirmation_delay_minutes') is distinct from 'number'
or jsonb_typeof(p_config->'unknown_protection_minutes') is distinct from 'number'
-- A subtração das TRÊS chaves conhecidas: o que sobrar é campo que esta
-- função não reconhece, e aceitar um desses gravaria configuração que
-- nenhum leitor lê.
or (p_config - 'confirmation_delay_minutes'
- 'unknown_protection_minutes'
- 'pending_expires_after_minutes') <> '{}'::jsonb
or (p_config->>'confirmation_delay_minutes' ~ '^[0-9]{1,5}$') is not true
or (p_config->>'unknown_protection_minutes' ~ '^[0-9]{1,5}$') is not true
or (p_config->>'confirmation_delay_minutes')::int not between 1 and 10080
or (p_config->>'unknown_protection_minutes')::int not between 1 and 10080
or (p_config->>'unknown_protection_minutes')::int
< (p_config->>'confirmation_delay_minutes')::int
then
raise exception 'agenda_settings_invalid' using errcode = '22023';
end if;
-- O terceiro prazo só é validado quando VEM. O piso de 15 minutos existe
-- porque abaixo disso a expiração corre com quem está decidindo naquele
-- instante — o pedido sumiria da frente de quem ia confirmá-lo.
if p_config ? 'pending_expires_after_minutes' then
if jsonb_typeof(p_config->'pending_expires_after_minutes') is distinct from 'number'
or (p_config->>'pending_expires_after_minutes' ~ '^[0-9]{1,5}$') is not true
or (p_config->>'pending_expires_after_minutes')::int not between 15 and 10080
then
raise exception 'agenda_settings_invalid' using errcode = '22023';
end if;
end if;
update public.organizations
set settings = jsonb_set(coalesce(settings, '{}'::jsonb), '{agenda}', p_config, true)
where id = p_org;
if not found then
raise exception 'organization_not_found' using errcode = 'P0002';
end if;
return p_config;
end; $$;
-- As DUAS origens de EXECUTE, como manda a doutrina de migrations: o grant que
-- o Postgres dá a PUBLIC ao criar, e o `alter default privileges ... to anon`
-- do baseline, que alcança toda função criada depois dele.
revoke all on function public.fn_agenda_settings(uuid, jsonb) from public, anon, authenticated;
grant execute on function public.fn_agenda_settings(uuid, jsonb) to authenticated;
+2
View File
@@ -302,3 +302,5 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260914170000` | `0243_chamada_de_api_tem_prazo_de_trava` | `alter role authenticator/authenticated set lock_timeout='4s'`. No Postgres `lock_timeout` é **0 por padrão — esperar para sempre**, e o cliente HTTP do produto desiste em 10s (`DEFAULT_TIMEOUT_MS`). Juntas, as duas coisas produzem um defeito que nenhuma tem sozinha: aos 10s o navegador mostra "Erro inesperado" (sem identificador — o erro é DELE, não do servidor), **a consulta continua viva segurando a fila**, o botão volta a aceitar clique, e o seguinte empilha atrás. Medido em produção em 2026-09-12 pelo "Enviar link ao cliente" (`fn_meet_action`): dez chamadas simultâneas, Postgres a **357% de CPU** com load 7,33, app e worker em 0,16%; cancelá-las devolveu o banco a 1,47%. No PAPEL e não na função porque a varredura que veio junto (`tests/invariants/trava-de-definer-tem-prazo.test.ts`) achou **sete** irmãs com a mesma forma — `fn_reply_action`, `fn_mesclar_contatos`, `fn_reserve_channel_connection`, `fn_set_channel_routing`, `fn_lgpd_anonymize_contact`, `fn_google_resolve`, `fn_google_selection` — e consertar uma a uma deixaria toda função futura nascer com o defeito. `service_role` fica de fora: trabalho de fundo pode esperar. 4s é abaixo dos 10s do cliente (para a recusa chegar à tela) e muito acima de uma operação legítima. Estouro levanta `55P03`, traduzido pelas rotas. |
| `20260914180000` | `0244_tags_de_conversa_em_uso` | `fn_tags_de_conversa_em_uso(p_org)`: o seletor de etiqueta do Inbox passa a oferecer as etiquetas **em uso** nas conversas, unidas ao vocabulário canônico. Medido numa instalação real: o seletor oferecia 8 etiquetas de semente, nenhuma conversa tinha etiqueta, filtrar por qualquer uma devolvia zero, e a etiqueta que a lista **exibia** não estava entre as 8. **`security INVOKER`, e isso é o ponto:** a função recebe a organização por argumento e é concedida a `authenticated`, então `definer` aqui seria leitura cross-tenant — a classe de defeito que a 0149 consertou em `emit_event`/`retrieve_top_k_chunks`, e que o comentário de `fn_gasto_de_ia_do_mes` descreve por extenso. Sob invoker quem isola é a RLS de `conversations`. Como isso tira a função do alcance da varredura de `definer`, o gate dela é `tests/invariants/tags-em-uso-aparecem-no-filtro.test.ts`, com isolamento entre duas organizações e recusa ao `anon`. Sem coluna, sem constraint, sem backfill; reaproveita `idx_conversations_tags_gin`. Baseline idempotente (`create or replace` + `revoke`/`grant`). |
| `20260912210000` | `0247_indices_fks_mensagens_e_runs` | **Índices parciais em foreign keys de `messages` e `ai_agent_runs`.** Previne sequential scans inteiros na tabela filha durante deleções e cascateamentos em contatos, sessões de canal e mensagens (especialmente expurgo LGPD). Índices parciais idempotentes `idx_messages_contact_id`, `idx_messages_channel_session_id`, `idx_ai_agent_runs_contact_id`, `idx_ai_agent_runs_channel_session_id`, `idx_ai_agent_runs_conversation_id`, `idx_ai_agent_runs_inbound_message_id`, `idx_ai_agent_runs_outbound_message_id`. |
| `20260914090000` | `0248_caso_tem_assunto` | `agent_cases.kind` — do que o caso trata, para triar a fila antes de ler. O assunto vivia só em texto livre (`title`/`summary`/`blocker`), o que basta com fila curta e não basta com volume: "quer marcar horário" e "está reclamando" pedem pessoas e urgências diferentes. **Sem CHECK, pela doutrina de vocabulário ABERTO** — o vocabulário útil muda com o nicho (clínica tem "remarcação", loja tem "troca"), e um CHECK fixo exigiria migration por nicho e quebraria o `update.sh` de clone com valor próprio; quem prende é `TIPOS_DE_CASO` no TypeScript, e a coluna fica fora do invariante `vocabulario-banco-x-typescript` (que só cobre coluna com CHECK). `not null default 'outro'` dispensa backfill. Índice parcial `(organization_id, kind) where status in ('awaiting_human','awaiting_lead')`, porque é nos abertos que se tria. |
| `20260914190000` | `0249_agenda_prazo_de_expiracao_do_pendente` | Terceiro prazo em `organizations.settings.agenda`: `pending_expires_after_minutes` — quanto tempo um pedido não confirmado segura o horário. **Por que uma migration para um campo de jsonb:** `fn_agenda_settings` não faz merge, ela ENUMERA as chaves aceitas e rejeita extras (`(p_config - 'a' - 'b') <> '{}'` levanta `22023`); sem recriá-la a tela salvaria o campo e receberia "não foi possível alterar os prazos". O campo é **opcional** de propósito — toda organização instalada tem duas chaves, e exigir três quebraria o PATCH vindo de uma aba aberta antes da atualização; ausente = default do TypeScript (1440). Piso de 15 minutos no corpo, porque abaixo disso a expiração corre com quem está decidindo. Sem backfill: a ausência já é estado válido. Habilita o cron `agenda-expira-pendentes`. **O corpo é DERIVADO da versão em vigor**: a função ganhou o portão `fn_session_mfa_proven()` na 0229, e recriá-la a partir do corpo antigo o apagaria — no baseline isso vira remoção de proteção no `update.sh` de quem já rodava. |
+9 -1
View File
@@ -6,6 +6,14 @@ vi.mock("@/lib/supabase/server",()=>({createClient:async()=>({rpc:deps.rpc})}));
vi.mock("@/lib/audit",()=>({audit:deps.audit}));
import { PATCH } from "@/app/api/v1/agenda/configuracao/route";
const settings={confirmation_delay_minutes:10,unknown_protection_minutes:1440};
/**
* O que a RPC recebe NÃO é o que o cliente mandou: `pending_expires_after_minutes`
* tem `.default(1440)` no schema, então o Zod o completa. É por isso que a rota
* grava três campos mesmo quando o corpo trouxe dois — e é o que mantém um PATCH
* escrito antes desta versão (uma aba aberta, por exemplo) funcionando em vez de
* tomar 422.
*/
const gravado={...settings,pending_expires_after_minutes:1440};
beforeEach(()=>{vi.resetAllMocks();deps.support.mockResolvedValue(null);deps.role.mockResolvedValue({ok:true,user:{id:'human'},org:{orgId:'trusted-org'}});deps.rpc.mockResolvedValue({data:settings,error:null});});
const request=(body:unknown=settings)=>new Request('http://localhost/api/v1/agenda/configuracao',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
it('suporte somente leitura é barrado antes de leitura ou efeito',async()=>{
@@ -14,7 +22,7 @@ it('suporte somente leitura é barrado antes de leitura ou efeito',async()=>{
});
it('gestão autorizada altera a organização da sessão e audita autoria',async()=>{
expect((await PATCH(request())).status).toBe(200);expect(deps.role).toHaveBeenCalledWith('manager',expect.anything());
expect(deps.rpc).toHaveBeenCalledWith('fn_agenda_settings',{p_org:'trusted-org',p_config:settings});expect(deps.audit).toHaveBeenCalledWith(expect.objectContaining({actorUserId:'human',organizationId:'trusted-org',action:'agenda.settings_updated'}));
expect(deps.rpc).toHaveBeenCalledWith('fn_agenda_settings',{p_org:'trusted-org',p_config:gravado});expect(deps.audit).toHaveBeenCalledWith(expect.objectContaining({actorUserId:'human',organizationId:'trusted-org',action:'agenda.settings_updated'}));
});
it('prazo inválido não chega à escrita',async()=>{
expect((await PATCH(request({...settings,unknown_protection_minutes:1}))).status).toBe(422);expect(deps.rpc).not.toHaveBeenCalled();expect(deps.audit).not.toHaveBeenCalled();
@@ -0,0 +1,289 @@
/**
* O GATILHO DE AUTOMAÇÃO LEVA O TIPO DE ATENDIMENTO DE VERDADE — nos QUATRO.
*
* ## O defeito que esta cerca fecha
*
* O editor de regras (`app/app/webhooks/_components/RuleEditor.tsx`) oferece,
* para os quatro gatilhos de agenda, a condição **"Tipo de atendimento contém
* …"**, lida de `event.event_type_name` no payload do evento. É a única
* condição por onde uma regra distingue "Limpeza de pele" de "Avaliação": a
* linha do compromisso guarda `event_type_id`, um uuid que ninguém digita.
*
* Três dos quatro emissores mandavam `nomeDoTipo: "Agendamento"` CRAVADO
* (`alterar` para confirmado e remarcado, `cancelar` para cancelado). Só
* `marcar` mandava o nome real, porque já tinha a linha do tipo em mãos. O
* efeito não é erro: a regra aparece na tela, o operador a salva, o horário é
* confirmado — e nada roda, porque `"Agendamento"` não contém "Limpeza".
* Controle decorativo é pior que controle ausente: a pessoa acredita que
* configurou.
*
* ## Onde a sonda olha
*
* No EFEITO: a linha que chega a `event_log` pelo dublê do Supabase. Não na
* chamada de `fecharOLaco`, não em `gatilhoDaTransicao` — a função pura já tem
* cerca própria (`lib/agenda/laco.gatilho.test.ts`) e decidir o gatilho certo
* não prova que o payload dele serve para alguma coisa.
*
* ## Por que os QUATRO, e não um
*
* Porque as irmãs não se parecem por fora: `marcar` estava CERTO e os outros
* três errados, e um caso feliz sobre `marcar` deixaria o arquivo verde com o
* defeito inteiro de pé. O caso `appointment.created` está aqui como controle —
* ele é o que prova que a sonda enxerga quando o nome chega.
*
* ## Comando
*
* npx vitest run tests/unit/agenda-gatilho-leva-o-tipo-real.test.ts
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { ResultadoDaConsulta } from "@/lib/agenda/consulta";
import { ENTIDADE_DO_AGENDAMENTO } from "@/lib/agenda/tipos";
import type { HandlerCtx } from "@/lib/api/handlers/types";
vi.mock("@/lib/audit", () => ({
audit: vi.fn(async () => undefined),
isServiceRoleConfigured: vi.fn(() => true),
}));
vi.mock("@/lib/agenda/consulta", async (original) => {
const real = await original<typeof import("@/lib/agenda/consulta")>();
return { ...real, horariosLivresDaOrg: vi.fn() };
});
const { horariosLivresDaOrg } = await import("@/lib/agenda/consulta");
const { marcarAgendamentoHandler, alterarAgendamentoHandler, cancelarAgendamentoHandler } =
await import("@/app/api/v1/agenda/agendamentos/_handler");
const ORG = "aaaaaaaa-0000-4000-8000-00000000000a";
const USUARIO = "bbbbbbbb-0000-4000-8000-00000000000b";
const TIPO = "cccccccc-0000-4000-8000-00000000000c";
const CONTATO = "dddddddd-0000-4000-8000-00000000000d";
const AGENDAMENTO = "ffffffff-0000-4000-8000-00000000000f";
const HORARIO = "2026-09-02T13:00:00.000Z";
const OUTRO_HORARIO = "2026-09-02T15:00:00.000Z";
/**
* O nome precisa ser um que NENHUM literal plausível produziria por acaso — e
* que uma condição real usaria: é assim que o estúdio separa a regra de limpeza
* da regra de avaliação.
*/
const NOME_DO_TIPO = "Limpeza de pele";
type Linha = Record<string, unknown>;
interface Banco {
tipo: Linha | null;
contato: Linha | null;
agendamento: Linha | null;
criado: Linha | null;
inserido: Record<string, Linha[]>;
}
let banco: Banco;
let horarioOfertado: string;
function coletaOk(): ResultadoDaConsulta {
const inicio = new Date(horarioOfertado);
return {
ok: true,
slots: [{ inicio, fim: new Date(inicio.getTime() + 30 * 60_000) }],
fusoDaRegra: "America/Sao_Paulo",
publicouHorarios: true,
fusoSuposto: false,
fontesDefasadas: [],
agendaExternaNuncaLida: false,
googleCoberturaParcial: false,
};
}
function dadoDaTabela(tabela: string): unknown {
switch (tabela) {
case "calendar_event_types":
return banco.tipo;
case "contacts":
return banco.contato;
case "calendar_appointments":
return banco.agendamento;
// Sem negócio aberto: o gatilho de automação NÃO depende de haver negócio, e
// deixar a timeline fora mantém este arquivo sobre uma coisa só.
case "crm_leads":
return [];
default:
return null;
}
}
function cliente(): SupabaseClient {
const leitura = (tabela: string) => {
const cadeia: Record<string, unknown> = {};
for (const m of ["eq", "neq", "in", "is", "not", "or", "gte", "lte", "order", "limit"]) {
cadeia[m] = () => cadeia;
}
const resposta = () => ({ data: dadoDaTabela(tabela), error: null });
cadeia.maybeSingle = async () => resposta();
cadeia.single = async () => resposta();
cadeia.then = (r: (v: unknown) => unknown) => r(resposta());
return cadeia;
};
return {
from: (tabela: string) => ({
select: () => leitura(tabela),
insert: (linha: Linha) => {
(banco.inserido[tabela] ??= []).push(linha);
const resposta = { data: banco.criado ?? linha, error: null };
return {
select: () => ({ single: async () => resposta, maybeSingle: async () => resposta }),
then: (r: (v: unknown) => unknown) => r(resposta),
};
},
update: (patch: Linha) => {
const cadeia: Record<string, unknown> = {};
const resposta = () => ({ data: { ...(banco.agendamento ?? {}), ...patch }, error: null });
for (const m of ["eq", "in"]) cadeia[m] = () => cadeia;
cadeia.select = () => cadeia;
cadeia.single = async () => resposta();
cadeia.then = (r: (v: unknown) => unknown) => r(resposta());
return cadeia;
},
}),
rpc: async (fn: string, args: Linha) => {
if (fn === "fn_appointment_change") {
return { data: { ...banco.agendamento, ...(args.p_patch as Linha), revision: 2 }, error: null };
}
return { data: null, error: null };
},
} as unknown as SupabaseClient;
}
const ctx: HandlerCtx = {
organization_id: ORG,
actor: { type: "user", id: USUARIO, role: "admin" },
requestId: "req-1",
};
/** Os gatilhos de agenda que chegaram ao `event_log`. */
function gatilhos(): Linha[] {
return (banco.inserido["event_log"] ?? []).filter(
(l) => l.entity_kind === ENTIDADE_DO_AGENDAMENTO,
);
}
/** O único gatilho da rodada — e a asserção de que houve exatamente um. */
function oGatilho(): { event_type: string; payload: Linha } {
expect(
gatilhos(),
"a transição não emitiu gatilho nenhum: o motor de regras não fica sabendo do compromisso e nenhuma automação de agenda roda — sem erro e sem log",
).toHaveLength(1);
const linha = gatilhos()[0]!;
return { event_type: linha.event_type as string, payload: linha.payload as Linha };
}
beforeEach(() => {
vi.clearAllMocks();
horarioOfertado = HORARIO;
banco = {
tipo: {
id: TIPO,
name: NOME_DO_TIPO,
is_active: true,
duration_minutes: 30,
default_owner_user_id: USUARIO,
requires_confirmation: false,
location_kind: "in_person",
location_details: null,
},
contato: { id: CONTATO },
agendamento: {
id: AGENDAMENTO,
event_type_id: TIPO,
owner_user_id: USUARIO,
contact_id: CONTATO,
starts_at: HORARIO,
status: "confirmed",
time_zone: "America/Sao_Paulo",
},
criado: {
id: AGENDAMENTO,
starts_at: HORARIO,
ends_at: "2026-09-02T13:30:00.000Z",
status: "confirmed",
time_zone: "America/Sao_Paulo",
},
inserido: {},
};
vi.mocked(horariosLivresDaOrg).mockImplementation(async () => coletaOk());
});
describe("os quatro gatilhos de agenda levam o tipo de atendimento real", () => {
it("appointment.created — o controle: aqui o nome SEMPRE chegou", async () => {
await marcarAgendamentoHandler(cliente(), ctx, {
event_type_id: TIPO,
starts_at: HORARIO,
contact_id: CONTATO,
});
const { event_type, payload } = oGatilho();
expect(event_type).toBe("appointment.created");
expect(
payload.event_type_name,
"nem o caminho que já estava certo leva o nome: a sonda está olhando para o lugar errado, e os outros três casos deste arquivo não valem nada",
).toBe(NOME_DO_TIPO);
});
it("appointment.confirmed — a regra de 'avise que confirmou' só serve se souber DE QUÊ", async () => {
banco.agendamento!.status = "pending";
await alterarAgendamentoHandler(cliente(), ctx, { id: AGENDAMENTO, status: "confirmed" });
const { event_type, payload } = oGatilho();
expect(event_type).toBe("appointment.confirmed");
expect(
payload.event_type_name,
"a confirmação anuncia um nome genérico: a condição 'Tipo de atendimento contém …' que o editor de regras oferece nunca casa, e o operador que a configurou acha que configurou",
).toBe(NOME_DO_TIPO);
});
it("appointment.rescheduled — remarcar também diz do que é o horário", async () => {
horarioOfertado = OUTRO_HORARIO;
await alterarAgendamentoHandler(cliente(), ctx, { id: AGENDAMENTO, starts_at: OUTRO_HORARIO });
const { event_type, payload } = oGatilho();
expect(event_type).toBe("appointment.rescheduled");
expect(
payload.event_type_name,
"a remarcação anuncia um nome genérico: quem quis avisar só a cliente de um tipo específico recebe uma regra que não dispara nunca",
).toBe(NOME_DO_TIPO);
});
it("appointment.cancelled — e cancelar é onde mais se quer filtrar por tipo", async () => {
await cancelarAgendamentoHandler(cliente(), ctx, { id: AGENDAMENTO, reason: "cliente pediu" });
const { event_type, payload } = oGatilho();
expect(event_type).toBe("appointment.cancelled");
expect(
payload.event_type_name,
"o cancelamento anuncia um nome genérico: a regra de recuperação por tipo de atendimento não roda, e o horário vago não vira nada",
).toBe(NOME_DO_TIPO);
});
it("o tipo APAGADO cai no genérico em vez de derrubar o cancelamento", async () => {
// O par de vacuidade dos quatro acima: prova que o nome vem da LEITURA, e
// não de um valor que o dublê devolveria de qualquer jeito. E congela a
// decisão: perder o tipo não pode desfazer um cancelamento já gravado.
banco.tipo = null;
await cancelarAgendamentoHandler(cliente(), ctx, { id: AGENDAMENTO, reason: "cliente pediu" });
expect(oGatilho().payload.event_type_name).toBe("Agendamento");
expect(
banco.inserido["event_log"],
"o cancelamento caiu junto com a leitura do tipo: um compromisso fica sem desmarcar porque uma linha de catálogo sumiu",
).toBeDefined();
});
});
@@ -0,0 +1,185 @@
/**
* O ASSUNTO DO CASO SAI DA ROTA E CHEGA NA TELA.
*
* ## O defeito que esta cerca fecha
*
* `agent_cases.kind` entrou na consulta (`COLUNAS_LISTA`, em
* `lib/escalacao/chamados.ts`) e na interface do cliente — e NÃO entrou em
* `achatarContato`, que é a projeção escrita à mão por onde todo caso passa
* antes de virar JSON. A coluna vinha do banco e morria no `map`. Efeito: todo
* caso da fila renderizava "Outro", para sempre.
*
* Nada pegou porque nada LIGAVA as duas pontas: o tipo do cliente era uma
* segunda cópia escrita à mão, e `apiClient.get<…>()` é um cast sobre JSON, não
* uma checagem. Verde nos gates prova que nada que o CI mede quebrou — não que
* a funcionalidade chegou.
*
* ## Por que o dublê do PostgREST HONRA a lista de colunas
*
* Porque `COLUNAS_LISTA` é uma STRING: nenhum compilador a lê. Um dublê que
* devolvesse a linha inteira independentemente do `select` deixaria este
* arquivo verde no dia em que alguém tirasse `kind` da consulta — a metade do
* defeito que o tipo não cobre. Aqui o dublê projeta como o PostgREST projeta.
*
* ## Onde a sonda olha
*
* No TEXTO DA TELA, atravessando as três camadas de verdade: a consulta, a
* projeção da rota e o componente. O valor que o `CaseList` recebe é o que
* `listarChamados` devolveu — não um objeto escrito pelo teste.
*
* ## Comando
*
* npx vitest run tests/unit/caso-tem-tipo-da-rota-ate-a-tela.test.tsx
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listarChamados, type ChamadoDaLista } from "@/lib/escalacao/chamados";
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (s: string) => s }));
vi.mock("@/hooks/i18n/useLocaleDeData", () => ({ useLocaleDeData: () => undefined }));
vi.mock("@/app/app/ai/cases/_components/CaseDetail", () => ({
CaseDetail: () => null,
}));
const { CaseList } = await import("@/app/app/ai/cases/_components/CaseList");
const ORG = "aaaaaaaa-0000-4000-8000-00000000000a";
/** A linha como o banco a tem — com o assunto que a IA classificou. */
const LINHA_DO_BANCO = {
id: "case-1",
title: "Quer marcar para quinta",
summary: "A cliente pediu horário",
blocker: "Preciso confirmar a agenda",
status: "awaiting_human",
kind: "agendamento",
opened_at: "2026-09-14T10:00:00.000Z",
conversation_id: "conv-1",
source: "agent",
closed_at: null,
conversations: { contacts: { name: "Joana Prado", phone_number: "+5511999998888" } },
} as const;
/**
* Projeta a linha pela lista de colunas do `select`, como o PostgREST faz.
* Só o nível de cima importa aqui — o embed de contato vai inteiro.
*/
function projetar(select: string, linha: Record<string, unknown>): Record<string, unknown> {
const topo: string[] = [];
let profundidade = 0;
let atual = "";
for (const c of select) {
if (c === "(") profundidade++;
if (c === ")") profundidade--;
if (c === "," && profundidade === 0) {
topo.push(atual);
atual = "";
continue;
}
atual += c;
}
topo.push(atual);
const saida: Record<string, unknown> = {};
for (const bruto of topo) {
const nome = bruto.trim().split(":")[0]!.trim();
if (nome in linha) saida[nome] = linha[nome];
}
return saida;
}
function clienteFake(): SupabaseClient {
return {
from: () => ({
select: (colunas: string, opts?: { count?: string; head?: boolean }) => {
const cadeia: Record<string, unknown> = {};
const resposta = () =>
opts?.head
? { data: null, error: null, count: 1 }
: { data: [projetar(colunas, LINHA_DO_BANCO as never)], error: null, count: 1 };
for (const m of ["eq", "in", "order", "limit"]) cadeia[m] = () => cadeia;
cadeia.then = (r: (v: unknown) => unknown) => r(resposta());
return cadeia;
},
}),
} as unknown as SupabaseClient;
}
function envolver(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
/** O que a rota devolveria, montado pelo caminho de produção. */
async function corpoDaRota(): Promise<{ cases: ChamadoDaLista[]; open_count: number }> {
const { chamados, abertos } = await listarChamados(clienteFake(), ORG, { estado: "abertos" });
return { cases: chamados, open_count: abertos };
}
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("o assunto do caso atravessa a rota inteira", () => {
it("a projeção da rota devolve o assunto que veio do banco", async () => {
const { cases } = await corpoDaRota();
expect(cases).toHaveLength(1);
expect(
cases[0]!.kind,
"a rota engoliu o assunto entre a consulta e o JSON: a coluna existe no banco, a tela recebe `undefined` e mostra o rótulo genérico para todo caso — sem erro em lugar nenhum",
).toBe("agendamento");
});
it("e a tela escreve o rótulo desse assunto, não o genérico", async () => {
const corpo = await corpoDaRota();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ data: corpo }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
envolver(<CaseList />);
await waitFor(() => expect(screen.getByTestId("case-item")).toBeInTheDocument());
const linha = screen.getByTestId("case-item");
expect(
linha.textContent,
"a fila mostra o rótulo genérico: quem tria não consegue separar 'quer marcar horário' de 'está reclamando' antes de abrir cada caso, que é a única coisa que esta feature serve para fazer",
).toContain("Horário");
expect(linha.textContent).not.toContain("Outro");
});
it("assunto que este build não conhece cai no genérico em vez de quebrar a tela", async () => {
// O par de vacuidade: prova que o texto vem do VALOR e não de um rótulo
// fixo — e congela a decisão de vocabulário aberto (um clone com engine
// mais novo não pode derrubar a fila).
const corpo = await corpoDaRota();
corpo.cases[0]!.kind = "troca-de-produto";
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ data: corpo }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
envolver(<CaseList />);
await waitFor(() => expect(screen.getByTestId("case-item")).toBeInTheDocument());
expect(screen.getByTestId("case-item").textContent).toContain("Outro");
});
});
@@ -0,0 +1,179 @@
/**
* O PORTÃO DE MFA NÃO SOME QUANDO A FUNÇÃO É RECRIADA.
*
* ## O defeito, achado numa triagem (PR #789)
*
* Para aceitar uma chave nova em `settings.agenda`, um PR recriou
* `fn_agenda_settings` — e escreveu o corpo novo a partir da versão ORIGINAL da
* função, não da que estava em vigor. No caminho perdeu a linha
*
* if not public.fn_session_mfa_proven() then raise exception ...
*
* que a migration 0229 tinha acrescentado. `create or replace` troca a
* definição inteira e não avisa o que sumiu.
*
* ⚠️ O ALCANCE PASSA DO TESTE. O apêndice do `supabase/baseline.sql` repetia o
* mesmo corpo, e o baseline é o que o `update.sh` re-aplica: a atualização
* REMOVERIA de quem já rodava uma proteção que ele tinha. E a verificação
* migration↔baseline passava verde, porque o espelho estava fiel — fiel
* carregando o defeito.
*
* ## Por que a cerca é de CLASSE, e não deste caso
*
* Porque as irmãs não se parecem por fora. São 14 portões espalhados por
* funções de agenda, Google, anonimização de contato e conexão de canal, e o
* próximo PR que recriar qualquer uma delas comete o mesmo erro pelo mesmo
* motivo — sem nenhuma semelhança textual com este. Consertar por instância é
* como esse defeito volta.
*
* ## A regra, em uma frase
*
* Num arquivo aplicado de cima para baixo, a ÚLTIMA definição de uma função é a
* que vale. Então: se alguma definição anterior de uma função tinha o portão, a
* última também tem. Vale para o `baseline.sql` (o que o self-hoster aplica) e
* para a cadeia de `migrations/` em ordem de versão (o que um clone replica).
*
* O que esta cerca NÃO faz: ela não exige portão em função nova, e não opina
* sobre QUAIS funções deveriam tê-lo. Ela só proíbe a perda silenciosa — que é
* o modo de falha real, porque ninguém remove um portão de propósito e sem
* dizer.
*
* ## Comando
*
* npx vitest run tests/unit/mfa-nao-some-em-funcao-recriada.test.ts
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const RAIZ = process.cwd();
const BASELINE = join(RAIZ, "supabase", "baseline.sql");
const MIGRATIONS = join(RAIZ, "supabase", "migrations");
/** A prova de sessão que o produto usa como portão dentro de uma função SQL. */
const PORTAO = "fn_session_mfa_proven";
const ABERTURA = /create\s+or\s+replace\s+function\s+public\.([a-z0-9_]+)\s*\(/gi;
interface Definicao {
funcao: string;
origem: string;
/** Ordem de aplicação — é ela que decide quem é a última a valer. */
posicao: number;
temPortao: boolean;
}
/**
* As definições de função de um arquivo SQL, na ordem em que o Postgres as
* executa. O corpo termina no primeiro `$$;` depois da abertura: parar ali
* evita capturar o portão da função SEGUINTE e concluir que esta o tem.
*/
function definicoesDe(sql: string, origem: string, base: number): Definicao[] {
const achados: Definicao[] = [];
ABERTURA.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ABERTURA.exec(sql)) !== null) {
const fim = sql.indexOf("$$;", m.index);
const corpo = sql.slice(m.index, fim === -1 ? sql.length : fim);
achados.push({
funcao: m[1]!,
origem,
posicao: base + m.index,
temPortao: corpo.includes(PORTAO),
});
}
return achados;
}
/**
* As funções que perderam o portão: alguma definição anterior o tinha, e a
* última — a que vale — não tem.
*/
function perdas(definicoes: Definicao[]): Array<{ funcao: string; tinhaEm: string; perdeuEm: string }> {
const porFuncao = new Map<string, Definicao[]>();
for (const d of definicoes) {
(porFuncao.get(d.funcao) ?? porFuncao.set(d.funcao, []).get(d.funcao)!).push(d);
}
const saida: Array<{ funcao: string; tinhaEm: string; perdeuEm: string }> = [];
for (const [funcao, lista] of porFuncao) {
const ordenadas = [...lista].sort((a, b) => a.posicao - b.posicao);
const ultima = ordenadas[ordenadas.length - 1]!;
if (ultima.temPortao) continue;
const anteriorComPortao = ordenadas.slice(0, -1).find((d) => d.temPortao);
if (!anteriorComPortao) continue;
saida.push({ funcao, tinhaEm: anteriorComPortao.origem, perdeuEm: ultima.origem });
}
return saida.sort((a, b) => a.funcao.localeCompare(b.funcao));
}
function relatar(lista: ReturnType<typeof perdas>): string {
return lista
.map(
(p) =>
` public.${p.funcao}: tinha o portão em ${p.tinhaEm} e a definição que VALE (${p.perdeuEm}) não tem`,
)
.join("\n");
}
const baseline = readFileSync(BASELINE, "utf-8");
const definicoesDoBaseline = definicoesDe(baseline, "supabase/baseline.sql", 0);
/** Os arquivos de migration na ordem em que um clone os aplica. */
const arquivosDeMigration = readdirSync(MIGRATIONS)
.filter((f) => f.endsWith(".sql"))
.sort();
const definicoesDasMigrations = arquivosDeMigration.flatMap((arquivo, i) =>
definicoesDe(
readFileSync(join(MIGRATIONS, arquivo), "utf-8"),
`supabase/migrations/${arquivo}`,
// Cada arquivo ocupa uma faixa própria: a ordem entre arquivos é a da
// versão, e dentro do arquivo é a do texto.
(i + 1) * 10_000_000,
),
);
describe("recriar uma função não apaga o portão de MFA dela", () => {
it("o instrumento enxerga: há portões a perder nos dois artefatos", () => {
// CONTROLE DE VACUIDADE. Sem ele, um regex quebrado (ou um rename de
// `fn_session_mfa_proven`) faria os dois casos abaixo passarem medindo o
// conjunto vazio — e "nenhuma perda" leria exatamente como "tudo certo".
expect(
definicoesDoBaseline.filter((d) => d.temPortao).length,
"a sonda não achou portão NENHUM no baseline: ou o parser quebrou, ou o portão mudou de nome — em qualquer dos casos esta cerca está medindo o vazio",
).toBeGreaterThan(5);
expect(
definicoesDasMigrations.filter((d) => d.temPortao).length,
"a sonda não achou portão nenhum nas migrations: mesma cegueira, no artefato que o clone replica",
).toBeGreaterThan(0);
// E que há função REALMENTE recriada — senão a regra nunca é exercida.
const recriadas = new Set(
definicoesDoBaseline
.filter((d, _, todas) => todas.filter((o) => o.funcao === d.funcao).length > 1)
.map((d) => d.funcao),
);
expect(recriadas.size).toBeGreaterThan(0);
});
it("no baseline.sql — é ele que o update.sh re-aplica em quem já instalou", () => {
const lista = perdas(definicoesDoBaseline);
expect(
lista,
"uma função que tinha portão de MFA perdeu-o na definição que vale. Isto NÃO é só um teste: o `update.sh` re-aplica este arquivo, então a atualização REMOVE de quem já rodava uma proteção que ele tinha — e o espelho migration↔baseline continua fiel, carregando o defeito.\n" +
relatar(lista) +
"\nConserte DERIVANDO da definição em vigor em vez de reescrever a partir da original.",
).toEqual([]);
});
it("na cadeia de migrations — é ela que um clone replica em ordem", () => {
const lista = perdas(definicoesDasMigrations);
expect(
lista,
"uma migration recriou uma função e deixou cair o portão de MFA que outra migration anterior tinha posto:\n" +
relatar(lista) +
"\nA forward-fix é derivar da versão em vigor — `create or replace` troca a definição inteira e não avisa o que sumiu.",
).toEqual([]);
});
});