Merge branch 'main' of https://github.com/IanCouto/DeskcommCRM into develop

This commit is contained in:
Ian Couto
2026-09-14 18:21:24 -03:00
131 changed files with 6465 additions and 372 deletions
@@ -0,0 +1,33 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A navegação responde na hora, e a atualização para quando o backup falha
---
Clicar numa aba do menu deixou de parecer que a tela travou. Uma barra fina
aparece no topo no instante do clique e acompanha o carregamento, então você
sabe que o sistema ouviu — antes, entre o clique e a página aparecer não havia
sinal nenhum, e a reação natural era clicar de novo.
As telas de dentro do sistema também abrem mais rápido: as consultas que toda
página precisa fazer (quem é você, de qual empresa, quais conexões estão fora do
ar) passaram a ser feitas ao mesmo tempo em vez de uma esperando a outra, e
deixaram de ser repetidas dentro da mesma página. No banco, as buscas de
histórico por contato e por conexão ganharam índices — quem tem muita mensagem
guardada sente a diferença nas telas de conversa e no expurgo de dados da LGPD.
O `update.sh` ficou mais cuidadoso com os seus dados. Quando o backup preventivo
falha, a atualização agora PARA: se você estiver acompanhando pelo terminal, ela
pergunta e só segue se você digitar `CONTINUAR`; se for o agente do servidor
atualizando sozinho, ela cancela e avisa. Antes ela esperava oito segundos e
seguia sem backup. O `restore.sh` passou a devolver também as sessões do
WhatsApp guardadas no backup, não só o banco — restaurar deixou de exigir parear
o QR Code de novo.
E duas portas ficaram mais firmes: subir imagem para cabeçalho de modelo do
WhatsApp agora confere o conteúdo do arquivo, não o rótulo que o navegador
mandou (um SVG renomeado para `.png` entrava e agora é recusado), e passou a
exigir permissão de atendente; as rotas internas de manutenção comparam a senha
de acesso em tempo constante.
Contribuição de @maugarciasa.
+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.
@@ -0,0 +1,13 @@
---
impacto: capacidade_nova
secao: corrigido
titulo: "Novo agendamento" deixa de vir com o cliente da vez anterior, e a lista de horários volta a rolar
---
Duas coisas na tela de agendamento, medidas numa instalação real.
**O compromisso podia nascer no nome da pessoa errada.** Quem abrisse "Marcar compromisso" de dentro de uma conversa e depois fosse à **Agenda pelo menu** encontrava o campo **Quem será atendido** já preenchido com aquele cliente. O campo parece preenchido de propósito; não há o que estranhar na tela. Agora o painel abre com o cliente que a **página** carrega: vindo do menu, ele abre em **"Compromisso pessoal, sem cliente"**; vindo do link da conversa, ele continua abrindo com aquele cliente, mesmo que você feche o painel para navegar o calendário até a semana certa.
**A lista de horários voltou a rolar.** Numa correção anterior, o painel perdeu o limite de altura para que a janela parasse de **cortar os botões** em telas baixas — e, sem limite, a lista de horários passou a crescer sem fim: um tipo de 45 minutos rende treze horários e uma janela maior que a tela. Agora a lista tem limite próprio, proporcional à altura da janela, e rola dentro de si em telas de computador. No celular nada muda: quem rola continua sendo a janela inteira.
Nada muda para quem opera: sem passo manual, sem mexer em configuração.
+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: Outro sistema já pode enviar mensagem pelo seu WhatsApp, usando um token
---
Até agora, enviar uma mensagem pela API exigia estar logado no navegador. Um
sistema externo não conseguia, mesmo com um token válido: a porta respondia
"não autenticado" antes de olhar o token.
Enviar uma mensagem e abrir uma conversa a partir de um telefone passam a
aceitar também um token de servidor, o mesmo que já era usado para consultar
contatos. A organização continua saindo do token, nunca do que foi enviado no
pedido, então um token de uma empresa não alcança a conversa de outra. Token
de leitura continua sem poder enviar.
Quem usa o sistema pela tela não vê diferença nenhuma.
@@ -0,0 +1,18 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Token de servidor não alcança mais a conversa de outra empresa
---
A porta de saída de mensagem do sistema conferia só o número da conversa, nunca
a empresa dona dela. Para quem envia pela tela isso nunca foi problema: o banco
já filtra por empresa nesse caminho. Mas quem envia por token de servidor — o
agente de IA por MCP, e agora as integrações — entra por um caminho em que esse
filtro do banco não existe, e o único cuidado possível é o do próprio sistema.
Ele faltava.
Na prática: um token de uma empresa, com o número de uma conversa de outra,
gravava e disparava a mensagem pelo WhatsApp da segunda. Agora a conversa de
outra empresa responde "não encontrada", e nada é gravado.
Quem usa o sistema pela tela não vê diferença nenhuma.
@@ -0,0 +1,8 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Gravar espera o servidor em vez de dizer "Erro inesperado"
---
Ação que grava e passava de 10 segundos virava "Erro inesperado" na tela
enquanto o servidor terminava e gravava. Agora espera 30. Leitura segue em 10.
@@ -0,0 +1,17 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O sistema não fica mais preso em "Algo deu errado" quando o Supabase repete requisições antigas
---
Uma instalação inteira ficou dois dias mostrando "Algo deu errado" em todas as
telas. O banco estava saudável; o que travou foi a camada de API do Supabase: o
gateway dela repetia sem parar oito requisições antigas do motor de follow-up
que terminavam em erro, e essas repetições ocuparam todas as conexões da API.
Sem conexão livre, a API não conseguia nem se preparar para atender, e passou a
responder "indisponível" para tudo, inclusive para a tela inicial.
Agora o banco reconhece uma requisição que o gateway está repetindo há mais de
cinco minutos e a recusa de um jeito que o gateway não repete. O loop morre na
hora e a API volta sozinha. Nada muda para quem usa o sistema, e você não
precisa fazer nada ao atualizar: a proteção entra com o próprio `update.sh`.
+19
View File
@@ -0,0 +1,19 @@
---
impacto: nada_mudou
secao: corrigido
titulo: "Logo escuro/colorido não some mais no tema escuro"
---
Um logo pensado para fundo claro (a maioria do que se sobe em `/admin/marca`
e `/app/settings/marca`) ficava ilegível no tema escuro: o fundo da barra
lateral e da tela de entrada é quase preto (`--color-surface` escuro), e um
logo escuro sobre quase-preto não tem contraste nenhum.
Agora a barra lateral, a tela de entrada e a prévia da própria tela de marca
mostram o logo sobre um chip branco arredondado quando o tema é escuro — a
mesma lógica que já existe para o texto dos botões, aplicada ao logo. No tema
claro nada muda: o chip só aparece quando o fundo por trás dele é escuro.
Quem já tinha um logo pensado para fundo escuro (raro, mas possível) passa a
ver uma moldura branca de sobra em vez de nada — troca aceita, porque o pior
caso "moldura desnecessária" é sempre melhor que o pior caso "logo invisível".
@@ -0,0 +1,20 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O identificador da conexão de WhatsApp nasce num lugar só e cabe no limite
---
O botão "Conectar novo WhatsApp", na Central de Conexões, falhava sempre com "Falha na comunicação
com o WhatsApp (WAHA)". O identificador interno que o sistema manda para o WhatsApp saía com 69
caracteres, e o WhatsApp recusa acima de 54, então a conexão nem chegava a ser criada do outro lado
e o card ficava em "Parado" pedindo reparo. O onboarding escapava porque montava o identificador
curto por conta própria, num segundo lugar do código.
A versão anterior já corrigiu o identificador no banco e arrumou as conexões paradas que ainda
tinham o nome longo. Agora o formato curto é um só, usado pelas duas telas — onboarding e
Conexões —, e o sistema confere o limite antes de falar com o WhatsApp: se o identificador ainda
estiver longo, ele é trocado na hora **apenas** quando o número nunca chegou a ser pareado; num
número que já pareou, a conexão para com um aviso próprio em vez de trocar o identificador — trocar
ali desligaria o sistema do WhatsApp que está no ar e exigiria um QR novo.
Nada muda para quem já tem número conectado.
+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.
+5
View File
@@ -47,6 +47,11 @@ jobs:
- name: Channel provider leak
run: pnpm lint:channels
# Auditoria de autorização e papel: impede comparações diretas de papel
# fora de lib/auth/ que contornem o gate de MFA e requireRole().
- name: Role rank audit
run: pnpm lint:role-rank
- name: Unit tests
run: pnpm test:unit
+3
View File
@@ -600,6 +600,9 @@ jobs:
central-avisos-resolver-em-lote.spec.ts
voz-desligada-por-padrao.spec.ts
inbox-busca-e-filtros-dizem-a-verdade.spec.ts
qa-titulos-das-telas.spec.ts
qa-equipe-pinta-na-hora.spec.ts
qa-sugestao-rejeitada-e-motivo.spec.ts
FORA_DO_CI: >-
vps-fresh-onboarding.spec.ts
+14 -7
View File
@@ -71,13 +71,20 @@ export default async function PublicLayout({ children }: { children: React.React
"primeira <img> da página", e uma asserção de negação com seletor
largo passa sozinha assim que outra imagem entra na tela.
*/}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
data-testid="logo-da-fachada"
src={marca.logoUrl}
alt={marca.nome}
className="h-10 w-auto max-w-[12rem] object-contain"
/>
{/* O chip `dark:bg-white` é o mesmo da barra lateral
(`components/shell/Sidebar.tsx`): esta tela também respeita
`data-theme` (o `ThemeProvider` embrulha a raiz inteira, login
incluso), então um logo escuro contra `--color-surface` escuro tem
o mesmo problema de contraste aqui. */}
<div className="rounded-md dark:bg-white dark:px-3 dark:py-2 dark:shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
data-testid="logo-da-fachada"
src={marca.logoUrl}
alt={marca.nome}
className="h-10 w-auto max-w-[12rem] object-contain"
/>
</div>
</div>
) : marcaEhADoProduto({ name: marca.nome, logoUrl: null }) ? (
<div className="flex justify-center">
+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
@@ -35,7 +35,8 @@ import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { z } from "zod";
import { alinharNomeAoTetoWaha, assertWahaConnectionIdle, ChannelConnectionError } from "@/lib/channels/connect-waha";
import { assertWahaConnectionIdle, ChannelConnectionError, renomearSessaoParaOTeto } from "@/lib/channels/connect-waha";
import { nomeDaSessaoCabeNoWaha, podeRenomearSessaoDoWaha } from "@/lib/channels/nome-da-sessao";
import { createAdminClient } from "@/lib/supabase/admin";
import { mfaEmDivida } from "@/lib/auth/server";
import { audit } from "@/lib/audit";
@@ -91,13 +92,14 @@ export async function POST(
// arquivado, e exigir a coluna aqui derrubaria a reconexão inteira — que é o
// socorro de quem está com o número fora do ar.
const { data: sessionRaw } = await queryTolerantToMissingArchived(
() => buscar(`id, waha_session_name, status, ${ARCHIVED_AT}`),
() => buscar("id, waha_session_name, status"),
() => buscar(`id, waha_session_name, status, phone_number, ${ARCHIVED_AT}`),
() => buscar("id, waha_session_name, status, phone_number"),
);
const session = sessionRaw as {
id: string;
waha_session_name: string | null;
status?: string | null;
phone_number?: string | null;
archived_at?: string | null;
} | null;
if (!session) return fail("not_found", t("Canal não encontrado."), 404, { requestId });
@@ -134,19 +136,41 @@ export async function POST(
);
}
// Reconectar com um nome fora do teto do WAHA é pedir 400 três vezes seguidas
// (stop, logout, start). Mesma fronteira do caminho de conectar: cura quem a
// 0232 curaria, recusa o resto. Aqui o `status` vale de verdade — esta linha
// veio da tabela, não da reserva, que sobrescreve o status com `STARTING`.
let nomeParaOTransporte = nomeSessao;
if (!nomeDaSessaoCabeNoWaha(nomeSessao)) {
if (!podeRenomearSessaoDoWaha(session)) {
return fail(
"connection_session_name_too_long",
t("O identificador desta conexão passou do limite que o WhatsApp aceita e não pode ser trocado sem desligar o número. Fale com o suporte antes de reconectar."),
409,
{ requestId },
);
}
try {
nomeParaOTransporte = await renomearSessaoParaOTeto(createAdminClient(), {
id: session.id, organization_id: activeOrg.orgId, waha_session_name: nomeSessao,
});
} catch {
return fail(
"connection_session_name_too_long",
t("O identificador desta conexão passou do limite que o WhatsApp aceita e não pôde ser corrigido agora. Tente novamente em instantes."),
409,
{ requestId },
);
}
}
try {
await assertWahaConnectionIdle(createAdminClient(), activeOrg.orgId, id);
const nome = await alinharNomeAoTetoWaha(createAdminClient(), {
id: session.id,
organization_id: activeOrg.orgId,
waha_session_name: nomeSessao,
status: session.status ?? undefined,
});
await waha.stopSession(nome);
await waha.stopSession(nomeParaOTransporte);
// Só no modo forçado: descartar a credencial é irreversível — obriga a
// reescanear o QR mesmo que ela ainda estivesse boa.
if (force) await waha.logoutSession(nome);
const remote = (await waha.startSession(nome)) as { status?: string };
if (force) await waha.logoutSession(nomeParaOTransporte);
const remote = (await waha.startSession(nomeParaOTransporte)) as { status?: string };
const nextStatus = remote.status ?? "STARTING";
const patch = { status: nextStatus, status_reason: null, last_status_change_at: new Date().toISOString(), consecutive_health_fails: 0 };
const { error: syncError } = await supabase.from("channel_sessions").update(patch).eq("organization_id", activeOrg.orgId).eq("id", id);
@@ -160,7 +184,7 @@ export async function POST(
resourceType: "channel_session",
resourceId: id,
requestId,
metadata: { waha_session_name: nome, force },
metadata: { waha_session_name: nomeParaOTransporte, force },
});
return ok({ id, status: nextStatus, force }, { requestId });
+3 -1
View File
@@ -113,7 +113,9 @@ export async function POST(req: NextRequest): Promise<Response> {
return ok(result.channel, { requestId, status: result.replay ? 200 : 201 });
} catch (error) {
if (error instanceof ChannelConnectionError) return fail(error.code,
error.code === "connection_in_progress" ? t("A conexão ainda está sendo preparada. Aguarde e tente novamente.") : t("Não foi possível concluir a conexão. Abra Conexões para tentar novamente ou reparar o número."),
error.code === "connection_in_progress" ? t("A conexão ainda está sendo preparada. Aguarde e tente novamente.")
: error.code === "connection_session_name_too_long" ? t("O identificador desta conexão passou do limite que o WhatsApp aceita. Nada foi criado no WhatsApp — atualize o sistema e tente novamente.")
: t("Não foi possível concluir a conexão. Abra Conexões para tentar novamente ou reparar o número."),
error.status, { requestId, details: error.technical });
return fail("internal_error", t("Não foi possível concluir a conexão. Tente novamente."), 500, { requestId });
}
@@ -27,7 +27,8 @@ import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { fail, ok } from "@/lib/api/wrappers";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { requireRole } from "@/lib/auth/require-role";
import { extensaoDe, farejarTipo, pareceSvg } from "@/lib/branding/logo-arquivo";
import { traduzir } from "@/lib/i18n/dicionario";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -43,6 +44,12 @@ const VALIDADE_SEGUNDOS = 7 * 24 * 60 * 60;
* Recusar aqui é melhor que deixar subir: o arquivo iria para o storage, a
* definição seria criada, e a recusa chegaria horas depois falando de um
* formato que o operador escolheu porque a tela deixou.
*
* O conjunto é consultado DUAS vezes, e as duas importam: contra o `type` que o
* navegador declarou (barato, descarta o engano honesto) e contra o tipo
* FAREJADO nos bytes (`farejarTipo`), que é o que decide. O rótulo é do cliente
* e mente quando quer — um SVG renomeado para `.png` passava pela primeira
* peneira e chegava ao storage com `contentType: image/png`.
*/
const TIPOS = new Set(["image/jpeg", "image/png"]);
const TAMANHO_MAX = 5 * 1024 * 1024;
@@ -53,11 +60,10 @@ export async function POST(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const user = await loadAuthUser();
if (!user) return fail("unauthenticated", "Faça login.", 401, { requestId });
const authz = await requireRole("agent", { requestId, resource: "channel_templates" });
if (!authz.ok) return authz.response;
const { user, org } = authz;
const t = (texto: string) => traduzir(texto, user.idioma);
const org = await resolveActiveOrg(user);
if (!org) return fail("forbidden", t("Sem organização ativa."), 403, { requestId });
const form = await req.formData().catch(() => null);
const file = form?.get("file");
@@ -65,26 +71,41 @@ export async function POST(req: NextRequest): Promise<Response> {
return fail("validation_failed", t("Campo 'file' (multipart) obrigatório."), 422, { requestId });
}
const mime = file.type || "application/octet-stream";
if (!TIPOS.has(mime)) {
return fail(
"unsupported_media_type",
t("O cabeçalho aceita imagem JPG ou PNG."),
415,
{ requestId },
);
}
if (file.size > TAMANHO_MAX) {
return fail("payload_too_large", t("A imagem precisa ter até 5 MB."), 413, { requestId });
}
const ext = mime === "image/png" ? "png" : "jpg";
const mime = file.type || "application/octet-stream";
if (!TIPOS.has(mime)) {
return fail("unsupported_media_type", t("O cabeçalho aceita imagem JPG ou PNG."), 415, { requestId });
}
const bytes = new Uint8Array(await file.arrayBuffer());
const tipoReal = farejarTipo(bytes);
if (!tipoReal || !TIPOS.has(tipoReal)) {
if (pareceSvg(bytes)) {
return fail(
"unsupported_media_type",
t("Arquivos SVG não são aceitos. O cabeçalho aceita imagem JPG ou PNG."),
415,
{ requestId },
);
}
return fail(
"unsupported_media_type",
t("O cabeçalho aceita imagem JPG ou PNG válida."),
415,
{ requestId },
);
}
const ext = extensaoDe(tipoReal);
const caminho = `${org.orgId}/templates/${randomUUID()}.${ext}`;
const admin = createAdminClient();
const { error: erroUp } = await admin.storage
.from("whatsapp-media")
.upload(caminho, Buffer.from(await file.arrayBuffer()), { contentType: mime, upsert: false });
.upload(caminho, Buffer.from(bytes), { contentType: tipoReal, upsert: false });
if (erroUp) {
logger.error("[partner/templates/media] upload falhou", { detail: erroUp.message, requestId });
return fail("internal_error", "Erro ao subir a imagem.", 500, { requestId });
+20 -11
View File
@@ -8,13 +8,12 @@ import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { fail, ok } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import { resolveAuthDual } from "@/lib/api/auth-dual";
import { IDIOMA_PADRAO } from "@/lib/i18n/idiomas";
import { extFromMime, MAX_MEDIA_BYTES } from "@/lib/messaging/media/types";
import { validateOutboundMedia } from "@/lib/messaging/media/upload-validation";
import { transcodificarNotaDeVoz } from "@/lib/messaging/media/voice-transcode";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -29,22 +28,32 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<Response> {
const requestId = randomUUID();
const { id: conversationId } = await ctx.params;
const supabase = await createClient();
// spec 13 §4: escrita é agent+ (viewer é read-only). Esta rota era a ÚNICA de
// escrita em conversations/[id]/* sem o gate — e como a policy de SELECT deixa
// o viewer enxergar toda conversa da org, o papel mais fraco do tenant tinha
// escrita irrestrita no bucket (50 MB por arquivo, com service_role). A irmã
// claim/route.ts:35 é o modelo literal.
const authz = await requireRole("agent", { requestId, resource: "conversation_media" });
// Sessão de navegador OU token de servidor: é o primeiro passo do envio de
// mídia, e quem envia por token precisa subir o arquivo antes de mandar.
const authz = await resolveAuthDual(req, {
requestId,
resource: "conversation_media",
role: "agent",
scope: "mcp:write",
});
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
const user = authz.user;
const authUser = await loadAuthUser();
const activeOrg = authUser ? await resolveActiveOrg(authUser) : null;
if (!activeOrg) return fail("no_active_org", t("No active organization."), 403, { requestId });
// O ramo do token não carrega idioma de usuário: cai no padrão do produto.
const t = (texto: string) => traduzir(texto, authz.idioma ?? IDIOMA_PADRAO);
const activeOrg = { orgId: authz.organizationId };
// O client vem de `authz`, não de `createClient()`: no ramo do token NÃO HÁ
// cookie de sessão, então um client de sessão seria anônimo e a RLS devolveria
// zero linha — a conversa existente viraria 404 e o upload por token, que é a
// capacidade que este PR entrega, nunca funcionaria. Quem protege aqui é o
// filtro explícito de `organization_id` logo abaixo, que vale nos dois ramos.
const supabase = authz.supabase;
// RLS + filtro explícito: a conversa precisa ser da org ativa.
// RLS (no ramo da sessão) + filtro explícito: a conversa precisa ser da org ativa.
const { data: conv, error: convErr } = await supabase
.from("conversations")
.select("id")
@@ -8,13 +8,14 @@ import { requireSupportWrite } from "@/lib/impersonate/support";
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { resolveAuthDual } from "@/lib/api/auth-dual";
import { ApiError } from "@/lib/api/types";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { openSharedContactConversation } from "@/lib/messaging/open-shared-contact-conversation";
import { openConversationWithContactSchema, validateRequest } from "@/lib/schemas";
import { createAdminClient } from "@/lib/supabase/admin";
import { traduzir } from "@/lib/i18n/dicionario";
import { IDIOMA_PADRAO } from "@/lib/i18n/idiomas";
export const dynamic = "force-dynamic";
@@ -24,9 +25,17 @@ export async function POST(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("agent", { requestId, resource: "conversations" });
// Sessão de navegador OU token de servidor: é o passo que antecede o envio,
// e quem envia por token precisa poder resolver a conversa pelo telefone.
const authz = await resolveAuthDual(req, {
requestId,
resource: "conversations",
role: "agent",
scope: "mcp:write",
});
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
// O ramo do token não carrega idioma de usuário: cai no padrão do produto.
const t = (texto: string) => traduzir(texto, authz.idioma ?? IDIOMA_PADRAO);
let input;
try {
@@ -43,7 +52,7 @@ export async function POST(req: NextRequest): Promise<Response> {
try {
const admin = createAdminClient();
const result = await openSharedContactConversation(admin, authz.org.orgId, input);
const result = await openSharedContactConversation(admin, authz.organizationId, input);
return ok(result, { requestId });
} catch (err) {
const msg = err instanceof Error ? err.message : "open_failed";
@@ -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;
+2 -4
View File
@@ -52,6 +52,7 @@ import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { autorizaCron } from "@/lib/auth/cron-auth";
import { env } from "@/lib/env";
import { logger } from "@/lib/logger";
import {
@@ -221,10 +222,7 @@ export function houveEfeito(resultado: ResultadoDaRetencao): boolean {
async function handle(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const auth = req.headers.get("authorization") ?? "";
const provided = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
const accepted = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(Boolean);
if (accepted.length === 0 || !provided || !accepted.includes(provided)) {
if (!autorizaCron(req)) {
return fail("forbidden", "Cron secret missing or invalid.", 403, { requestId });
}
+2 -6
View File
@@ -14,7 +14,7 @@ import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { env } from "@/lib/env";
import { autorizaCron } from "@/lib/auth/cron-auth";
import { logger } from "@/lib/logger";
import { runRoutingWorker } from "@/lib/routing/worker";
@@ -23,11 +23,7 @@ export const dynamic = "force-dynamic";
async function handle(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const auth = req.headers.get("authorization") ?? "";
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
const provided = bearer || (req.headers.get("x-cron-secret")?.trim() ?? "");
const accepted = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(Boolean);
if (accepted.length === 0 || !provided || !accepted.includes(provided)) {
if (!autorizaCron(req)) {
return fail("forbidden", "Cron secret missing or invalid.", 403, { requestId });
}
+18
View File
@@ -309,18 +309,36 @@ export async function sendMessageHandler(
// consulta certa (ver lib/channels/archived).
const convSelect = (comArchived: boolean) =>
`id, organization_id, contact_id, channel_session_id, is_group, group_chat_id, bot_silenced_until, provider_conversation_id, contacts:contact_id(phone_number, wa_identity, wa_lid, is_blocked), channel_sessions:channel_session_id(${CHANNEL_SESSION_REF_COLUMNS}, status${comArchived ? `, ${ARCHIVED_AT}` : ""})`;
//
// O filtro por `organization_id` NÃO é redundância com a RLS — é a única
// proteção que existe na metade dos chamadores. Este handler é a porta de
// saída de TODOS eles, e eles se dividem em dois mundos:
//
// - rota REST com sessão de navegador → client de RLS, a policy basta;
// - servidor MCP (lib/mcp/server.ts:41) e rota REST por `Bearer dsk_…`
// (lib/api/auth-dual.ts) → `createAdminClient()`, SERVICE ROLE, que
// bypassa RLS. Aqui não há policy nenhuma no caminho.
//
// Sem o filtro, um chamador de service-role com a org A passava um
// `conversation_id` da org B e a linha VINHA — e daí em diante todo o resto
// usa `c.organization_id`, a org da VÍTIMA: a mensagem era inserida na
// conversa dela e enviada pelo canal dela. Medido, não deduzido:
// `tests/invariants/envio-nao-alcanca-conversa-de-outro-tenant.test.ts`
// (anti-pattern 10 do CLAUDE.md).
const { data: conv, error: convErr } = await queryTolerantToMissingArchived(
() =>
supabase
.from("conversations")
.select(convSelect(true))
.eq("id", input.conversation_id)
.eq("organization_id", ctx.organization_id)
.maybeSingle(),
() =>
supabase
.from("conversations")
.select(convSelect(false))
.eq("id", input.conversation_id)
.eq("organization_id", ctx.organization_id)
.maybeSingle(),
);
+16 -9
View File
@@ -5,11 +5,10 @@ import { requireSupportWrite } from "@/lib/impersonate/support";
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { resolveAuthDual } from "@/lib/api/auth-dual";
import { ApiError } from "@/lib/api/types";
import { fail, ok } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { sendMessageSchema, validateRequest, type SendMessageInput } from "@/lib/schemas";
import { createClient } from "@/lib/supabase/server";
import { sendMessageHandler } from "./_handler";
@@ -20,13 +19,21 @@ export async function POST(req: NextRequest): Promise<Response> {
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const supabase = await createClient();
// spec 13 §4: escrita é agent+ (viewer é read-only).
const authz = await requireRole("agent", { requestId, resource: "messages" });
//
// Aceita sessão de navegador OU token de servidor (`dsk_…` com `mcp:write`),
// porque esta rota é a porta de envio de quem não tem navegador: o gateway do
// CRM que está sendo absorvido, e qualquer integração server-to-server. A org
// nunca vem do corpo; no ramo do token ela sai da linha do token.
const authz = await resolveAuthDual(req, {
requestId,
resource: "messages",
role: "agent",
scope: "mcp:write",
});
if (!authz.ok) return authz.response;
const user = authz.user;
const activeOrg = authz.org;
const { supabase, organizationId, actor, idioma } = authz;
let input;
try {
@@ -45,10 +52,10 @@ export async function POST(req: NextRequest): Promise<Response> {
const message = await sendMessageHandler(
supabase,
{
organization_id: activeOrg.orgId,
actor: { type: "user", id: user.id },
organization_id: organizationId,
actor,
requestId,
idioma: user.idioma,
idioma,
},
input as SendMessageInput,
);
@@ -42,7 +42,9 @@ export async function POST(req: Request): Promise<Response> {
return ok({ status: result.channel.status, session: result.channel.waha_session_name, channel_session_id: result.channel.id }, { requestId });
} catch (error) {
if (error instanceof ChannelConnectionError) return fail(error.code,
error.code === "connection_in_progress" ? "A conexão ainda está sendo preparada. Aguarde e tente novamente." : "Não foi possível concluir a conexão. Tente novamente ou repare o número em Conexões.",
error.code === "connection_in_progress" ? "A conexão ainda está sendo preparada. Aguarde e tente novamente."
: error.code === "connection_session_name_too_long" ? "O identificador desta conexão passou do limite que o WhatsApp aceita. Nada foi criado no WhatsApp — atualize o sistema e tente novamente."
: "Não foi possível concluir a conexão. Tente novamente ou repare o número em Conexões.",
error.status, { requestId, details: error.technical });
return fail("internal_error", "Não foi possível concluir a conexão. Tente novamente.", 500, { requestId });
}
+2
View File
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { Sidebar } from "@/components/shell/Sidebar";
import { TopBar } from "@/components/shell/TopBar";
import { BarraDeProgressoNavegacao } from "@/components/shell/BarraDeProgressoNavegacao";
import { useInboundMessageAlerts } from "@/hooks/notifications/useInboundMessageAlerts";
import { useCrmAlerts } from "@/hooks/notifications/useCrmAlerts";
import { useNotifyOpenFromServiceWorker } from "@/lib/notifications/notify_open";
@@ -17,6 +18,7 @@ export function AppShell({ sidebarCollapsed, children }: AppShellProps) {
useNotifyOpenFromServiceWorker();
return (
<div className="flex min-h-screen w-full bg-background">
<BarraDeProgressoNavegacao />
<div className="hidden md:block">
<Sidebar collapsed={sidebarCollapsed} />
</div>
+97 -6
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";
@@ -18,6 +20,8 @@ import { HistoricoDaAgenda } from "@/components/agenda/HistoricoDaAgenda";
import type { Agendamento, HorarioLivre, VisaoDaAgenda } from "@/components/agenda/tipos";
import { EmptyAgenda } from "@/components/empty";
import { rotuloDoLocal } from "@/lib/agenda/locais";
import { ancoraAoFecharPainel } from "@/lib/agenda/ancora-depois-de-marcar";
import { useVinculoDaMarcacao } from "@/lib/agenda/vinculo-da-marcacao";
import { Button } from "@/components/ui/button";
import { PainelDeMarcacao } from "@/components/agenda/PainelDeMarcacao";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
@@ -95,10 +99,38 @@ export function AgendaClient({
}) {
const localeDaData = useLocaleDeData();
const t = useT();
const router = useRouter();
const [marcando, setMarcando] = React.useState(false);
const [contactId,setContactId]=React.useState("");
const [conversationId,setConversationId]=React.useState("");
const onContext=React.useCallback((contact:string,conversation:string)=>{setContactId(contact);setConversationId(conversation);setMarcando(true);},[]);
// O compromisso criado NESTA abertura do painel. Serve para levar a grade até
// ele quando o painel fechar por qualquer caminho — ver `ancoraAoFecharPainel`.
const [marcadoEm, setMarcadoEm] = React.useState<string | null>(null);
// QUEM SERÁ ATENDIDO. A regra inteira — e por que ela não é "limpar ao
// fechar" — está em `lib/agenda/vinculo-da-marcacao.ts`. Em uma frase: o
// painel abre com o vínculo que a ROTA carrega (`?contato=…&conversa=…`, o
// link "Marcar compromisso" do Inbox), e o que a pessoa escolhe dentro dele
// vive só enquanto ele está aberto.
const {
vinculo,
registrarRota: registrarVinculoDaRota,
reiniciar: reiniciarVinculo,
escolher: escolherVinculo,
} = useVinculoDaMarcacao();
const contactId = vinculo.contact;
const conversationId = vinculo.conversation;
const onContext = React.useCallback(
(contact: string, conversation: string) => {
// Só abre sozinho quando a rota TROUXE um cliente: a chamada sem cliente
// é a que avisa que a página deixou de ter contexto, e ela não é um
// pedido para marcar nada.
if (registrarVinculoDaRota({ contact, conversation })) setMarcando(true);
},
[registrarVinculoDaRota],
);
/** Abrir o painel do zero: o vínculo volta a ser o da rota, nunca o da vez anterior. */
const abrirMarcacao = React.useCallback(() => {
reiniciarVinculo();
setMarcando(true);
}, [reiniciarVinculo]);
// O horário que veio de um CLIQUE NA GRADE. Preenchido, o painel abre já em
// "confirmando" naquele instante; vazio, ele abre pedindo o dia, como sempre.
const [horarioEscolhido, setHorarioEscolhido] = React.useState<HorarioLivre | null>(null);
@@ -134,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());
@@ -350,7 +402,7 @@ export function AgendaClient({
// escreveu, e vai anotada no PR.
data-testid="novo-agendamento"
title={tipo ? undefined : t("Cadastre um tipo de agendamento para começar")}
onClick={() => setMarcando(true)}
onClick={abrirMarcacao}
>
<CalendarPlus size={16} weight="bold" aria-hidden />
<span>{t("Novo agendamento")}</span>
@@ -448,6 +500,34 @@ export function AgendaClient({
// não usado reapareceria na PRÓXIMA marcação, que é de outro
// cliente — convite para a pessoa errada, sem ninguém ter pedido.
setEmailConvidado("");
// E o próprio cliente, que é o pior dos quatro a sobrar: medido numa
// instalação real em 2026-09-12, "Novo agendamento" abriu com um
// contato JÁ selecionado, herdado de uma abertura anterior feita a
// partir da conversa dele (`onContext` preenche os dois). Quem não
// reparasse marcaria o compromisso no nome de outra pessoa — e o
// campo parece preenchido de propósito, então não há o que estranhar.
//
// ⚠️ MAS NÃO É `setContactId("")`, e o `e2e` mediu a diferença:
// limpar no fechamento apaga também o contexto que a CONVERSA
// acabou de dar — `agenda-google-meet.spec.ts:196` e
// `agenda-presenca-recuperacao.spec.ts:312` reprovaram com
// `contact_id: null`, porque as duas fecham o painel só para
// navegar a grade até a semana certa, como uma pessoa faz.
//
// `reiniciarVinculo()` devolve o vínculo da ROTA: vazio quando a
// pessoa está na Agenda sem contexto (o defeito relatado), e o
// cliente da conversa quando ela chegou pelo link do Inbox.
reiniciarVinculo();
// ⛔ E LEVAR A GRADE ATÉ O QUE ACABOU DE NASCER.
//
// "Ver na agenda" já fazia isto; fechar no X, clicar fora ou apertar
// Esc, não — e a grade ficava na semana em que estava, sem o
// compromisso recém-criado, que quase sempre é de outra semana.
// ⚠️ O relato que puxou isto NÃO se confirmou (ver o módulo). O que
// sustenta é a simetria com o caso do botão, esse sim relatado.
const destino = ancoraAoFecharPainel(marcadoEm, startOfDay);
if (destino) setAncora(destino);
setMarcadoEm(null);
}
}}
>
@@ -488,7 +568,7 @@ export function AgendaClient({
<SheetHeader>
<SheetTitle>{remarcandoId ? t("Remarcar agendamento") : t("Novo agendamento")}</SheetTitle>
</SheetHeader>
{!remarcandoId?<VinculoDaMarcacao contactId={contactId} conversationId={conversationId} onChange={(contact,conversation)=>{setContactId(contact);setConversationId(conversation);}}/>:null}
{!remarcandoId?<VinculoDaMarcacao contactId={contactId} conversationId={conversationId} onChange={(contact,conversation)=>escolherVinculo({contact,conversation})}/>:null}
{tiposIniciais.length > 1 && (
<div className="mt-4" data-testid="tipos-de-agendamento">
<p className="mb-2 text-xs font-medium text-text-muted">{t("Tipo de agendamento")}</p>
@@ -638,6 +718,8 @@ export function AgendaClient({
})
.then((r) => {
setEmailConvidado("");
// Guardado para o fechamento saber para onde levar a grade.
setMarcadoEm(instante);
return r;
});
}}
@@ -823,8 +905,17 @@ export function AgendaClient({
onMarcarEm={(instante) => {
setHorarioEscolhido({ instante, rotulo: format(new Date(instante), "HH:mm") });
setRemarcandoId(null);
setMarcando(true);
// `abrirMarcacao` e não `setMarcando(true)`: clicar num bloco livre
// 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>
+56 -23
View File
@@ -54,13 +54,57 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// EPIC-02: gate /app/* on completed onboarding.
// EPIC-11: gate /app/* on org not being suspended (S-11.08).
let conexoesCaidas: ConexaoCaida[] = [];
let enrolled = false;
let needsMfaGate = false;
if (activeOrg) {
const admin = createAdminClient();
const { data: orgRow } = await admin
.from("organizations")
.select("onboarded_at, status, settings")
.eq("id", activeOrg.orgId)
.maybeSingle();
/**
* As quatro consultas que TODA página de `/app` paga, disparadas juntas.
*
* Elas eram sequenciais e independentes: cada uma esperava a anterior sem
* precisar do resultado dela, e a soma aparecia como a tela que não reage ao
* clique. Em paralelo, o custo passa a ser o da mais lenta.
*
* Duas consequências que valem estar escritas, porque não são acidente:
*
* - `listarConexoesCaidas` e `requiresMfa` agora rodam ANTES dos `redirect`
* de onboarding e de suspensão. Quem vai ser redirecionado paga duas
* consultas a mais — um caminho raro, que termina numa navegação de
* qualquer forma. O caminho normal, que é todo render de todo usuário,
* deixa de pagar três esperas em fila.
* - A consulta das conexões continua morando no seam
* (`lib/channels/health`), não aqui: tela que monta o select de
* `channel_sessions` à mão foi o que deixou três seletores oferecendo
* canal arquivado (invariante `canais-selecionaveis`), e de quebra o
* filtro de estados fica LITERALMENTE o mesmo que decide o aviso da
* Central. Vigiado por
* `tests/unit/faixa-de-conexao-caida-vem-do-seam.test.tsx`, que EXECUTA
* este layout — a cerca anterior lia o texto-fonte e reprovava esta
* refatoração sem que nada tivesse quebrado.
*/
const [orgRes, conexoes, isEnrolled, mfaRequired] = await Promise.all([
admin
.from("organizations")
.select("onboarded_at, status, settings")
.eq("id", activeOrg.orgId)
.maybeSingle(),
listarConexoesCaidas(admin, activeOrg.orgId),
isMfaEnrolled(),
requiresMfa(
activeOrg.role,
user.is_platform_admin,
user.id,
activeOrg.orgId,
),
]);
const orgRow = orgRes.data;
conexoesCaidas = conexoes;
enrolled = isEnrolled;
needsMfaGate = mfaRequired;
if (orgRow && !orgRow.onboarded_at && !user.support) redirect("/onboarding");
if (orgRow?.status === "suspended") redirect("/account-suspended");
// G4-02: expõe visibility_mode ao client (inbox decide visões visíveis).
@@ -119,17 +163,15 @@ export default async function AppLayout({ children }: { children: React.ReactNod
if (Object.keys(marcaDoTenant).length > 0) {
activeOrg = { ...activeOrg, marca: marcaDoTenant };
}
} else {
const [isEnrolled, mfaRequired] = await Promise.all([
isMfaEnrolled(),
requiresMfa(undefined, user.is_platform_admin, user.id, undefined),
]);
enrolled = isEnrolled;
needsMfaGate = mfaRequired;
}
// A conexão caiu? A consulta mora no seam (`lib/channels/health`), não aqui:
// tela que monta o select de `channel_sessions` à mão foi o que deixou três
// seletores oferecendo canal arquivado, e o invariante `canais-selecionaveis`
// existe por causa disso. De quebra, o filtro de estados fica LITERALMENTE o
// mesmo que decide o aviso da Central — duas listas divergiriam com o tempo.
const conexoesCaidas: ConexaoCaida[] = activeOrg
? await listarConexoesCaidas(createAdminClient(), activeOrg.orgId)
: [];
// Read sidebar collapsed state SSR to avoid flash.
const store = await cookies();
const collapsed = store.get("sidebar_collapsed")?.value === "1";
@@ -139,15 +181,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod
expiresAt: user.support.expires_at, accessMode: user.support.access_mode,
} : null;
const enrolled = await isMfaEnrolled();
// A decisão deixou de ser uma constante de papel: ela lê a política de quem
// pode exigir (a plataforma e a empresa). Ver `lib/auth/politica-mfa.ts`.
const needsMfaGate = await requiresMfa(
activeOrg?.role,
user.is_platform_admin,
user.id,
activeOrg?.orgId,
);
const shell = (
<VoiceCallProvider>
<AppShell sidebarCollapsed={collapsed}>{children}</AppShell>
@@ -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> = {
+2 -1
View File
@@ -1,6 +1,7 @@
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { redirect } from "next/navigation";
import { metaPodeReceber } from "@/lib/channels/meta/webhook";
import { nomeCurtoDaSessao } from "@/lib/channels/nome-da-sessao";
import { getWahaClient } from "@/lib/waha/client";
import { ConnectWhatsappClient } from "./_client";
import { traduzir } from "@/lib/i18n/dicionario";
@@ -37,7 +38,7 @@ export default async function ConnectWhatsappPage() {
</p>
<ConnectWhatsappClient
wahaConfigured={wahaConfigured}
sessionName={`org_${activeOrg.orgId.slice(0, 8)}`}
sessionName={nomeCurtoDaSessao(activeOrg.orgId)}
oficialPodeReceber={oficialPodeReceber}
/>
</div>
+10 -1
View File
@@ -13,8 +13,17 @@ export function EntradaDaAgenda({
const router = useRouter();
const contact = params.get("contato");
const conversation = params.get("conversa");
// ⚠️ SEMPRE avisa, inclusive quando a rota NÃO traz contexto.
//
// Era `if (contact) onContext(...)`, e a guarda era o defeito: quem abria
// "Marcar compromisso" de dentro de uma conversa e depois ia para a Agenda
// pelo menu continuava com aquele cliente no painel. A rota mudava
// (`?contato=` sumia), mas esta é a MESMA rota do App Router — o componente
// não remonta, só a query muda —, e sem a chamada ninguém contava ao painel
// que o contexto tinha acabado. Quem decide abrir é o `onContext`, pelo
// valor de `contact`; aqui é só o recado.
useEffect(() => {
if (contact) onContext(contact, conversation ?? "");
onContext(contact ?? "", conversation ?? "");
}, [contact, conversation, onContext]);
return (
<DetalheDoCompromisso
@@ -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}
+35 -3
View File
@@ -652,12 +652,44 @@ export function PainelDeMarcacao({
`data-testid` para a lista poder ser MEDIDA, e não só vista. O
`overflow-y-auto` aqui sempre esteve certo e era INERTE: um
`overflow-y-auto` cujo pai tem altura `auto` não rola, porque o filho
cresce e `scrollHeight === clientHeight`. Quem fecha a cadeia é o
`_client.tsx`, que dá teto ao Sheet.
cresce e `scrollHeight === clientHeight`.
## Por que o teto mora AQUI, e não mais na cadeia de alturas
Este comentário dizia "quem fecha a cadeia é o `_client.tsx`, que dá
teto ao Sheet". Era verdade e virou o defeito seguinte: para a janela
parar de CORTAR os botões em tela baixa, o teto do Sheet foi removido
— e a lista, sem pai com altura, voltou a crescer sem fim. Medido numa
instalação real em 2026-09-12: um tipo de 45 minutos rendeu treze
horários e uma janela que não cabia na tela. Trocamos "corta" por
"estica", que é o mesmo erro pelo avesso.
`lg:max-h` resolve sem cadeia: `max-height` + `overflow-y-auto` rola
por conta própria, sem depender de o pai ter altura definida — que é
a condição frágil que já falhou nos dois sentidos. Abaixo de `lg` não
há teto de propósito: ali quem rola é o diálogo inteiro, e dois
roladores aninhados no celular prendem o dedo no de dentro.
O teto tem DUAS partes, e cada uma cobre o que a outra não cobre:
- `42vh` para a janela BAIXA — em 1366×768 sobram ~520px reais, e um
teto em pixel escolhido num monitor grande volta a cortar
exatamente onde o defeito original aparecia;
- `380px` para a janela ALTA — sem ele, numa tela de 1440px de altura
a lista teria 600px de teto, o que não é teto nenhum: a janela
volta a ficar maior que o calendário ao lado, que é o que se está
tentando evitar.
⚠️ A primeira tentativa foi `60vh` sozinho, e ela FALHOU na prova de
tela: a barra de rolagem apareceu — o mecanismo estava certo —, mas
numa janela de ~950px isso ainda dava 570px de lista, e o relato foi
"scroll de horas ainda gigante". Rolar não era o objetivo; caber era.
Fica escrito porque o erro não foi o mecanismo, foi o NÚMERO — e é o
tipo de coisa que nenhum gate mede e só a tela mostra.
*/}
<div
data-testid="lista-de-horarios"
className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1"
className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1 lg:max-h-[min(42vh,380px)]"
>
{doDia.map((h) => (
<button
+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>
);
}
+29 -12
View File
@@ -313,18 +313,35 @@ export function CampoDeLogo({
style={{ backgroundColor: fundo }}
>
{emVigor ? (
// <img> e não next/image pelo mesmo motivo da barra lateral e da
// tela de acesso: a URL é do projeto de quem hospeda, e
// `next/image` exige allowlist de domínios fechada em BUILD — a
// imagem pré-buildada do self-host recusaria o domínio do
// operador. Altura fixa e largura livre para não distorcer arte
// de proporção desconhecida.
// eslint-disable-next-line @next/next/no-img-element
<img
src={emVigor}
alt={nomeEmVigor}
className="max-h-12 w-auto max-w-full object-contain"
/>
// O chip claro na aparência escura é o MESMO que a barra
// lateral e a tela de entrada aplicam de verdade
// (`components/shell/Sidebar.tsx`, `app/(public)/layout.tsx`):
// esta prévia deixaria de ser prévia se mostrasse o logo cru
// onde o app real desenha um chip por baixo. Aqui não dá pra
// usar a variante `dark:` do Tailwind — as duas caixas
// renderizam lado a lado no MESMO tema real, simulando os
// dois via `style` — então a condição é o rótulo da caixa, não
// o tema da página.
<span
className={
rotulo === t("Aparência escura")
? "rounded-md bg-white px-2 py-1 shadow-sm"
: undefined
}
>
{/* <img> e não next/image pelo mesmo motivo da barra lateral e da
tela de acesso: a URL é do projeto de quem hospeda, e
`next/image` exige allowlist de domínios fechada em BUILD — a
imagem pré-buildada do self-host recusaria o domínio do
operador. Altura fixa e largura livre para não distorcer arte
de proporção desconhecida. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={emVigor}
alt={nomeEmVigor}
className="max-h-12 w-auto max-w-full object-contain"
/>
</span>
) : (
<span
className="text-sm font-semibold tracking-tight"
@@ -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
}
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* Barra de progresso ultrafina no topo do viewport (0ms).
*
* Fornece resposta tátil imediata no clique de qualquer aba ou link interno do CRM,
* eliminando a sensação de travamento ou tela congelada enquanto o servidor responde.
*/
export function BarraDeProgressoNavegacao() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visivel, setVisivel] = useState(false);
const [progresso, setProgresso] = useState(0);
/**
* A rota atual LIDA DE DENTRO do ouvinte — e por isso num ref, não na closure.
*
* O ouvinte é registrado uma vez só (`[]`), de propósito: um `addEventListener`
* no `document` que se re-registra a cada navegação é caro e ainda perde
* cliques na janela entre remover e adicionar. Mas uma closure criada uma vez
* congela o `pathname` do PRIMEIRO render — e aí a comparação "é o mesmo
* destino?" passa a medir contra uma rota que o usuário já deixou.
*
* O efeito medido (`tests/unit/barra-de-progresso-navegacao.test.tsx`): depois
* da primeira navegação, clicar no link da página em que você JÁ ESTÁ acendia
* a barra. Uma barra que acende sempre não informa nada — é o oposto exato do
* que esta peça existe para fazer.
*/
const rotaAtual = useRef(pathname);
useEffect(() => {
rotaAtual.current = pathname;
}, [pathname]);
// Conclui e reseta a barra quando a rota termina de mudar
useEffect(() => {
const t1 = setTimeout(() => {
setProgresso(100);
}, 0);
const t2 = setTimeout(() => {
setVisivel(false);
setProgresso(0);
}, 200);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [pathname, searchParams]);
// Captura cliques em links internos para disparo imediato (0ms)
useEffect(() => {
// Os avanços graduais agendados por um clique precisam morrer com o
// componente: sem isto, desmontar entre 180ms e 450ms deixa dois
// `setProgresso` pendentes escrevendo num estado que não existe mais.
const agendados: ReturnType<typeof setTimeout>[] = [];
const agendar = (fn: () => void, ms: number) => {
agendados.push(setTimeout(fn, ms));
};
const aoClicar = (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest("a");
if (!target) return;
const href = target.getAttribute("href");
if (!href) return;
// Ignora links externos, novas abas, modificadores de tecla ou hash local
if (
target.target === "_blank" ||
e.ctrlKey ||
e.metaKey ||
e.shiftKey ||
e.altKey ||
href.startsWith("#") ||
href.startsWith("mailto:") ||
href.startsWith("tel:")
) {
return;
}
const urlDestino = new URL(target.href, window.location.href);
if (urlDestino.origin !== window.location.origin) return;
const mesmoDestino =
urlDestino.pathname === rotaAtual.current &&
urlDestino.search === window.location.search;
if (!mesmoDestino) {
setVisivel(true);
setProgresso(25);
// Avanço gradual simulado enquanto a requisição está em trânsito
agendar(() => setProgresso((p) => (p === 25 ? 65 : p)), 180);
agendar(() => setProgresso((p) => (p === 65 ? 85 : p)), 450);
}
};
document.addEventListener("click", aoClicar, { capture: true });
return () => {
document.removeEventListener("click", aoClicar, { capture: true });
for (const id of agendados) clearTimeout(id);
};
}, []);
if (!visivel) return null;
return (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 top-0 z-[9999] h-[2px] bg-transparent"
>
<div
className="h-full bg-primary shadow-[0_0_8px_var(--color-primary)] transition-all duration-200 ease-out"
style={{
width: `${progresso}%`,
opacity: progresso === 100 ? 0 : 1,
}}
/>
</div>
);
}
+24 -7
View File
@@ -124,13 +124,30 @@ export function SidebarContent({
)}
>
{logo && !collapsed ? (
// <img> em vez de next/image de propósito: a URL vem de quem hospeda
// (banco ou .env), e next/image exige allowlist de domínios fechada em
// build — a imagem pré-buildada rejeitaria o domínio do self-hoster.
// Altura fixa e largura livre porque a arte enviada tem proporção
// desconhecida; forçar as duas distorceria o logo de quem configurou.
// eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt={nome} className="h-7 w-auto max-w-[10rem] object-contain" />
// A moldura clara vale SÓ para o logo enviado por quem hospeda. A arte
// do produto (ramo `marcaDoProduto`, logo abaixo) já é desenhada para os
// dois temas e não precisa dela — pôr a moldura ali seria dar o remédio
// a quem não tem a doença.
// Chip claro só no tema escuro: a arte enviada é de quem hospeda, sem
// garantia de que tenha contraste contra `--color-surface` escuro
// (`#1d1c17`). Sem isto, todo logo escuro/colorido — a maioria do que
// se sobe pensando em fundo claro — some no tema escuro (issue: logo
// da Dra. Mariana Nascimento, azul-marinho sobre quase-preto). O chip
// é condicional ao TEMA, não à cor do logo (não dá pra inspecionar
// pixel de uma URL externa em server component), então ele aparece
// para qualquer logo — inclusive um já pensado pra fundo escuro, que
// fica com uma moldura branca de sobra. Troca aceita: pior caso
// "moldura desnecessária" é sempre melhor que pior caso "logo
// invisível".
<div className="rounded-md dark:bg-white dark:px-2 dark:py-1 dark:shadow-sm">
{/* <img> em vez de next/image de propósito: a URL vem de quem hospeda
(banco ou .env), e next/image exige allowlist de domínios fechada em
build — a imagem pré-buildada rejeitaria o domínio do self-hoster.
Altura fixa e largura livre porque a arte enviada tem proporção
desconhecida; forçar as duas distorceria o logo de quem configurou. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={nome} className="h-7 w-auto max-w-[10rem] object-contain" />
</div>
) : marcaDoProduto ? (
// O desenho do produto, inline (ver `components/branding/MarcaDoProduto.tsx`):
// logotipo com a barra aberta, só o símbolo com ela recolhida.
+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.
@@ -0,0 +1,73 @@
# PostgREST em 503 PGRST002 com o banco saudável — replay do gateway do Supabase
Incidente de 2026-09-11 (VPS de produção, projeto Supabase `kkffykiqbjamvopnrhaa`).
Conserto estrutural: migration `0237_guarda_contra_replay_do_gateway`.
## Sintoma
- Toda tela em "Algo deu errado"; o log do app repete em todas as rotas e crons:
`PGRST002 Could not query the database for the schema cache. Retrying.`
- `psql` pelo pooler responde em milissegundos. O banco não é o problema.
- `curl "$SUPABASE_URL/rest/v1/<qualquer tabela>"` → 503 com o mesmo `PGRST002`.
## Diagnóstico (cada linha é comando, não afirmação)
Rollbacks por segundo — a assinatura do replay é "milhares por segundo com o app ocioso":
```bash
psql "$SUPABASE_DB_URL" -Atc "select xact_rollback, xact_commit from pg_stat_database where datname=current_database()"
sleep 10
psql "$SUPABASE_DB_URL" -Atc "select xact_rollback from pg_stat_database where datname=current_database()"
```
Quem está abortando (medido: só `fn_followup_patch` e `fn_google_appointment`, ambas terminando em `40001`):
```bash
psql "$SUPABASE_DB_URL" -Atc "select state, left(query,120) from pg_stat_activity where usename='authenticator' and state<>'idle'"
```
Prova de que a VPS NÃO é a origem (57 pacotes na porta 443 em 14 s contra 31.839 rollbacks):
```bash
timeout 12 tcpdump -ni any -w /tmp/cap.pcap "port 443"; tcpdump -nr /tmp/cap.pcap "tcp dst port 443" | wc -l
```
Quem é a origem — o `sb-request-id` é UUIDv7 e os 48 bits iniciais são o instante em que o gateway aceitou a requisição.
Para vê-lo sem parar nada, exponha-o em `application_name` por dentro da função reexecutada por alguns segundos
(`perform set_config('application_name', left(current_setting('request.headers',true)::jsonb->>'sb-request-id',63), true)`),
amostre `pg_stat_activity` e RESTAURE o corpo original (`pg_get_functiondef` antes, `diff` depois). Decodifique:
```bash
python3 -c 'import datetime;h="01a08690-28dd";print(datetime.datetime.fromtimestamp(int(h.replace("-",""),16)/1000,datetime.UTC))'
```
Se o instante é de horas ou dias atrás e o mesmo `cf-ray` se repete em todas as amostras, é o gateway reexecutando —
o cliente que originou já não existe.
## Mecanismo
1. `raise exception ... using errcode='40001'` (conflito benigno de revisão) → o PostgREST mapeia a classe 40 para HTTP **500**.
2. O gateway do Supabase (Envoy/worker à frente do PostgREST) reexecuta 5xx sem limite, com o mesmo `sb-request-id`.
3. Oito requisições em loop ocupam o pool inteiro; o carregamento do schema cache não obtém conexão e falha.
4. Em `AppState.hs`, falha no carregamento ⇒ cache `Nothing` ⇒ **toda** requisição responde 503 `PGRST002`.
## Conserto
O hook `pgrst.db_pre_request = public.fn_pgrst_recusar_replay_do_gateway` responde `PT409` (HTTP 409) a requisição
cujo `sb-request-id` tem mais de 5 minutos. 4xx não é reexecutado. Conferir que está registrado:
```bash
psql "$SUPABASE_DB_URL" -Atc "select rolconfig from pg_roles where rolname='authenticator'" # contém pgrst.db_pre_request=...
psql "$SUPABASE_DB_URL" -Atc "select sum(calls) from pg_stat_statements s join pg_roles r on r.oid=s.userid where r.rolname='authenticator' and query ilike '%fn_pgrst_recusar_replay_do_gateway%'" # cresce a cada requisição
```
**Instalar o hook NO MEIO da tempestade não basta:** o `NOTIFY pgrst, 'reload config'` também precisa de uma conexão do
pool, e o PostgREST não tenta de novo quando falha. O destravamento de emergência é chamar a guarda por dentro das
funções que estão sendo reexecutadas (uma linha `perform public.fn_pgrst_recusar_replay_do_gateway();` logo após o
`begin`), esperar os rollbacks zerarem, mandar o `NOTIFY` de novo e RESTAURAR os corpos originais com `diff` vazio.
## Follow-up (não feito aqui)
Conflito benigno não deveria sair como 5xx. Trocar `40001` por um código `PTxxx` (409) nas 25 funções / 71 pontos de
`raise` e nos ~20 pontos do cliente que leem `code === "40001"` é a melhoria de classe; o hook é a rede que segura
enquanto isso não acontece — e depois também, porque qualquer 5xx (bug novo, timeout) volta a ser reexecutável.
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+63
View File
@@ -0,0 +1,63 @@
# Prova em tela do lote 1 da triagem — 14/set/2026
As imagens desta pasta são o lastro da afirmação feita no PR **#809**: as mudanças
de interface do lote 1 foram exercitadas **pela tela**, com Playwright dirigindo um
browser de verdade contra um Supabase **local** (a guarda do `playwright.config.ts`
recusa URL fora de localhost e não foi contornada).
`curl` e chamada de API não contam aqui — validam o backend, não o que a pessoa vê,
clica e entende. É a Doutrina de QA Visual com Recursos Reais (DoD 12 do `CLAUDE.md`).
## O que cada imagem prova
### #716 — o botão "Reativar" de tipo de agendamento
- `716-reativado-volta-a-ser-marcavel.png` — o tipo desativado volta a ativo, e a
volta é lida numa **navegação nova** (`/app/agenda`), não em estado local: o que
sobrevive à navegação foi gravado. Na `main` este botão **nunca** funcionou — o
Zod descartava `is_active` em silêncio.
### #717 — a tela de atualização conta em que pé está
- `717-a-pedido-enviado.png` — "Pedido enviado" e o relógio, logo após o clique.
- `717-b-atualizando.png` — o estado intermediário, que antes era mudo.
- `717-c-acabou-de-instalar.png` — o desfecho lido numa **recarga sem heartbeat no
meio**, que é exatamente a janela do defeito: antes, a tela ficava calada ali.
- `717-d-em-dia.png` — "você está na versão 1.1.0", o estado de repouso.
### #719 — revogar e devolver acesso pintam a linha na hora
- `719-revogado-antes-da-resposta.png` — a linha muda com a resposta do POST
**presa** por `page.route`: a pintura é otimista de verdade, não o refetch chegando
rápido.
- `719-rollback-apos-recusa.png` — o desfazer, medido com o refetch **também preso**.
O que volta à tela só pode ter vindo do rollback.
### #727 — cada tela diz o próprio nome na aba
- `727-titulo-equipe.png` — uma das dezessete. As outras foram medidas por
ferramenta (`page.title()` nas 17 rotas: 17 títulos, específicos e distintos,
nenhum com a frase da landing), porque título de aba se lê por API, não a olho.
### #740 — a sugestão rejeitada sai da tela, e a falha diz o motivo
- `740-a-sugestao-na-tela.png` — a sugestão oferecida.
- `740-b-rejeitada-saiu-da-tela.png` — rejeitar esvazia o painel **e a confirmação
fica**.
- `740-c-falha-com-motivo.png` — com o agente despublicado, a tela mostra "Nenhum
agente publicado atende este canal…" (422 `reply_no_agent`), não a frase genérica.
## O que estas imagens NÃO provam
- **#732** (canal oficial da Meta) não tem imagem, e a razão é do código:
`https://graph.facebook.com` está escrito à mão em
`lib/channels/meta/validate-credentials.ts` e em
`lib/channels/adapters/meta-cloud.ts`. Só a **versão** da API é knob; o host não é.
Não há como pôr um receiver local no lugar da Graph, e conectar o número pela tela
exigiria WABA e token reais. A cobertura desse canal é de unidade — backend, não
tela. Fica registrado como dívida nomeada.
- A recusa exercitada no #719 foi **fabricada** (500 via `page.route`): não existe
caminho de tela que faça o `reactivate` recusar de verdade.
## Como refazer
As specs que produzem estas imagens estão em `tests/e2e/qa-titulos-das-telas.spec.ts`,
`tests/e2e/qa-equipe-pinta-na-hora.spec.ts` e
`tests/e2e/qa-sugestao-rejeitada-e-motivo.spec.ts`, e as três estão registradas em
`SPECS_PARTE_3` do `.github/workflows/e2e.yml` — imagem sem spec que a regenere é
captura de tela, não evidência.
+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,
),
+4 -3
View File
@@ -23,13 +23,14 @@ c_grn "✓ banco: $(du -h "$BACKUP_DIR/db-$ts.sql.gz" | awk '{print $1}')"
step "Snapshot das sessões do WhatsApp → $BACKUP_DIR/waha-$ts.tgz"
vol="$(dc config --volumes 2>/dev/null | grep -m1 waha-data || echo '')"
proj="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')"
docker run --rm -v "${proj}_waha-data:/data:ro" -v "$BACKUP_DIR:/out" alpine:3.20 \
vol="${vol:-${proj}_waha-data}"
docker run --rm -v "${vol}:/data:ro" -v "$BACKUP_DIR:/out" alpine:3.20 \
tar czf "/out/waha-$ts.tgz" -C /data . 2>/dev/null \
&& c_grn "✓ sessões WhatsApp salvas" \
|| c_ylw "⚠ não achei o volume waha-data (nome pode variar). Ajuste manualmente se necessário."
# Retenção: mantém os 14 mais recentes de cada tipo.
step "Limpando backups antigos (mantém 14)"
ls -1t "$BACKUP_DIR"/db-*.sql.gz 2>/dev/null | tail -n +15 | xargs -r rm -f
ls -1t "$BACKUP_DIR"/waha-*.tgz 2>/dev/null | tail -n +15 | xargs -r rm -f
(ls -1t "$BACKUP_DIR"/db-*.sql.gz 2>/dev/null || true) | tail -n +15 | xargs -r rm -f 2>/dev/null || true
(ls -1t "$BACKUP_DIR"/waha-*.tgz 2>/dev/null || true) | tail -n +15 | xargs -r rm -f 2>/dev/null || true
c_grn "✓ backup concluído em $BACKUP_DIR"
+15
View File
@@ -17,4 +17,19 @@ step "Restaurando $DUMP"
gunzip -c "$DUMP" | docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" \
&& c_grn "✓ banco restaurado" || die "Falha na restauração — veja o log acima."
# Restaura o estado das sessões do WhatsApp (WAHA) se o snapshot emparelhado existir
WAHA_TAR="${DUMP/db-/waha-}"
WAHA_TAR="${WAHA_TAR%.sql.gz}.tgz"
if [ -f "$WAHA_TAR" ]; then
step "Restaurando sessões do WhatsApp de $WAHA_TAR"
vol="$(dc config --volumes 2>/dev/null | grep -m1 waha-data || echo '')"
proj="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')"
vol="${vol:-${proj}_waha-data}"
WAHA_DIR="$(cd "$(dirname "$WAHA_TAR")" && pwd)"
WAHA_FILE="$(basename "$WAHA_TAR")"
docker run --rm -v "${vol}:/data" -v "${WAHA_DIR}:/in:ro" alpine:3.20 \
sh -c "rm -rf /data/* && tar xzf /in/${WAHA_FILE} -C /data" \
&& c_grn "✓ sessões do WhatsApp restauradas" || c_ylw "⚠ Falha ao restaurar sessões do WhatsApp"
fi
c_ylw "Reinicie o app: docker compose $(dc_files) restart app"
+6 -2
View File
@@ -115,9 +115,13 @@ if [ -z "$SKIP_BACKUP" ]; then
if bash "$(dirname "$0")/backup.sh"; then
c_grn "✓ backup feito — se algo der errado, dá pra restaurar (restore.sh)."
else
if [ -n "${DESKCOMM_AGENT_REPORT:-}" ] || [ ! -t 0 ]; then
die "O backup preventivo falhou. Atualização automática interrompida para proteger os dados."
fi
c_ylw "⚠ o backup falhou. A atualização NÃO apaga dados (só reorganiza os contatos),"
c_ylw " mas o ideal é ter backup. Ctrl+C pra parar e investigar; continuo em 8s…"
sleep 8
c_ylw " mas o ideal é ter backup."
read -r -p "Deseja continuar MESMO SEM BACKUP? Digite 'CONTINUAR': " conf
[ "$conf" = "CONTINUAR" ] || die "Atualização cancelada pelo operador para investigar a falha do backup."
fi
fi
# Avisa o agente do host (se for ele quem está dirigindo) — é o que faz a tela
+70
View File
@@ -0,0 +1,70 @@
/**
* Para onde a grade da Agenda vai quando o painel de marcação fecha.
*
* ## De onde vem a prova — e de onde ELA NÃO VEM
*
* Relato de 2026-09-12: *"fiz um agendamento que não aparece ao sair da agenda"*.
*
* ⚠️ **O compromisso existia** — aparecia nas três visões. Quem relatou estava
* procurando no dia e na semana errados, e disse isso ao conferir. Vale registrar
* a forma exata do engano, porque ela é o defeito e não a desculpa dele: a grade
* ficou onde estava, a pessoa foi procurar de memória, e procurou errado. Um
* minuto perdido e a conclusão "sumiu" — sobre um sistema que funcionava.
*
* "Não apareceu" e "não fui levado até ele" são a mesma experiência para quem
* usa. O produto já sabia disso e escreveu, no botão "Ver na agenda":
*
* > *"o compromisso recém marcado costuma ser de OUTRA semana (o do relato era
* > 8 de setembro), e a grade abre na semana corrente. Voltar para uma grade que
* > não mostra o que acabou de nascer é o mesmo 'nada acontece' com um passo a
* > mais."*
*
* E consertou **um** caminho: quem clica em "Ver na agenda" é levado até o dia.
* Quem fecha no X — ou clica fora, ou aperta Esc — não é. É o mesmo formato de
* erro que a limpeza do painel tinha no mesmo arquivo: o raciocínio certo,
* aplicado a parte dos caminhos.
*
* Dois relatos, meses diferentes, mesma frase — e o conserto anterior cobriu só
* quem clica no botão certo. Quem fecha no X é a maioria.
*
* ## Por que levar a grade, e não avisar
*
* A alternativa seria um aviso ("marcado para 24 de setembro"). Ela é pior: o
* aviso some, a dúvida fica, e a pessoa ainda precisa navegar. Levar a grade
* responde a pergunta que ela vai fazer — *cadê?* — antes de ela fazer.
*
* ## Por que isto é uma função, e não três linhas dentro do componente
*
* Porque assim dá para medir. A regra tem um caso degenerado que decide tudo
* (**fechar sem ter marcado não pode mover a grade**) e ele é invisível em
* revisão de código: mover a âncora sempre que o painel fecha teleporta quem
* abriu, olhou e desistiu — para o dia de um compromisso que ele não criou.
*/
/**
* @param instanteMarcado ISO do compromisso recém-criado nesta abertura do
* painel, ou `null` quando nada foi marcado.
* @param inicioDoDia Normalmente `startOfDay` do date-fns. Recebido como
* parâmetro para esta regra não depender da biblioteca de datas — o que a
* torna testável sem fuso, sem relógio e sem import pesado.
* @returns O novo valor da âncora, ou `null` para **não mexer** na grade.
*/
export function ancoraAoFecharPainel(
instanteMarcado: string | null | undefined,
inicioDoDia: (d: Date) => Date,
): Date | null {
// ⛔ O CASO QUE DECIDE TUDO: abrir o painel, olhar e desistir não pode mover
// a grade. Quem só espiou perderia o lugar onde estava — e é o caminho mais
// comum dos dois.
if (!instanteMarcado) return null;
const quando = new Date(instanteMarcado);
// Data ilegível é o mesmo que não saber: não mexer é sempre recuperável,
// teleportar para `Invalid Date` deixa a grade em branco sem explicação.
if (Number.isNaN(quando.getTime())) return null;
// O DIA, nunca o instante: a âncora é o dia de referência da visão. Mandar o
// instante exato funciona por acidente na visão de semana e escolhe a hora
// errada na de dia — é a mesma razão escrita no `onVerNaAgenda`.
return inicioDoDia(quando);
}
+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.
*
+93
View File
@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
/**
* QUEM É O CLIENTE DESTA MARCAÇÃO — e de ONDE ele veio.
*
* ## O defeito
*
* Medido numa instalação real em 2026-09-12: "Novo agendamento" abriu com um
* contato JÁ selecionado, herdado de uma abertura anterior feita a partir da
* conversa daquele contato. Quem não reparasse marcaria o compromisso no nome
* de outra pessoa — e o campo parece preenchido de propósito, então não há o
* que estranhar na tela.
*
* ## Por que "limpar ao fechar" NÃO é o conserto
*
* O conserto óbvio — zerar o cliente quando o painel fecha — troca um defeito
* por outro, e o `e2e` mediu isso: `agenda-google-meet.spec.ts:196` e
* `agenda-presenca-recuperacao.spec.ts:312` reprovaram com
* `contact_id: null`. As duas specs fazem o mesmo gesto que uma pessoa faz:
* abrem "Marcar compromisso" DE DENTRO da conversa, fecham o painel para
* navegar a grade até a semana certa, e reabrem em "Novo agendamento". Limpar
* no fechamento apaga ali o contexto que a conversa tinha acabado de dar, e o
* compromisso nasce sem dono.
*
* ## A distinção que resolve os dois: DE ONDE veio, não QUANDO limpar
*
* O contexto do Inbox não é estado do painel — ele é da ROTA. O link "Marcar
* compromisso" leva a `/app/agenda?contato=…&conversa=…`, e enquanto a pessoa
* está NESSA página o cliente é o contexto dela, não sobra de uma vez anterior.
* Quando ela vai para a Agenda pelo menu, a rota perde os parâmetros — e é
* exatamente aí que o cliente tinha de sumir e não sumia, porque o componente
* continua montado (mesma rota, só a query muda) e o estado sobrevivia.
*
* Daí a regra, em uma frase: **o painel sempre abre com o vínculo que a ROTA
* carrega; o que a pessoa escolhe DENTRO dele vive só enquanto ele está
* aberto.** Sem contexto na rota, abre vazio — "Compromisso pessoal, sem
* cliente", que é um estado legítimo. Com contexto na rota, abre com ele,
* quantas vezes for reaberto.
*
* Está aqui, e não em três linhas dentro do componente, pelo mesmo motivo de
* `ancora-depois-de-marcar.ts`: assim dá para MEDIR nos dois sentidos. Uma
* cerca que só lesse o texto do `_client.tsx` atrás de um `setContactId("")`
* ficaria verde justamente com o produto quebrado — foi o que aconteceu.
*/
export type VinculoDaMarcacao = {
/** `contacts.id`, ou "" para "compromisso pessoal, sem cliente". */
contact: string;
/** `conversations.id`, ou "" quando não há conversa vinculada. */
conversation: string;
};
export const SEM_VINCULO: VinculoDaMarcacao = { contact: "", conversation: "" };
export function useVinculoDaMarcacao() {
/**
* O vínculo que a ROTA carrega. Ref, e não estado: mudá-lo não deve
* redesenhar nem — pior — apagar o que a pessoa está preenchendo com o
* painel aberto. Ele é consultado nos dois momentos em que o painel volta
* ao estado neutro (abrir e fechar).
*/
const daRota = React.useRef<VinculoDaMarcacao>(SEM_VINCULO);
const [vinculo, setVinculo] = React.useState<VinculoDaMarcacao>(SEM_VINCULO);
/**
* A rota disse qual é o contexto da PÁGINA. Chamado a cada mudança de query,
* inclusive quando ela deixa de trazer contexto — é essa chamada "vazia" que
* faz o cliente da conversa parar de valer quando a pessoa vai para a Agenda
* pelo menu.
*
* @returns `true` quando a rota trouxe um cliente — o sinal de que a página
* foi aberta PARA marcar (o link do Inbox), e o painel deve abrir sozinho.
*/
const registrarRota = React.useCallback((novo: VinculoDaMarcacao) => {
daRota.current = novo;
if (!novo.contact) return false;
setVinculo(novo);
return true;
}, []);
/**
* O painel voltou ao estado neutro (abriu pelo botão, ou fechou sem
* confirmar). O vínculo volta a ser o da rota — que é vazio quando a rota
* não tem contexto, e é o cliente da conversa quando tem.
*/
const reiniciar = React.useCallback(() => setVinculo(daRota.current), []);
/** A pessoa escolheu à mão, dentro do painel. Morre no próximo `reiniciar`. */
const escolher = React.useCallback((novo: VinculoDaMarcacao) => setVinculo(novo), []);
return { vinculo, registrarRota, reiniciar, escolher };
}
+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",
+152
View File
@@ -0,0 +1,152 @@
/**
* `resolveAuthDual` — sessão de navegador OU token de servidor.
*
* O que esta suíte prova, e é o que não pode regredir:
*
* 1. No ramo do token, `organization_id` sai da LINHA DO TOKEN. Nunca do
* corpo, nunca da query. É o que impede um Bearer de uma org escrever
* noutra.
* 2. Escrita exige `mcp:write`. Um token de leitura (`mcp:read`) é recusado
* com 403, não aceito com poderes a mais.
* 3. Sem header `Authorization`, cai na sessão, com o mesmo gate do resto de
* `/api/v1/*`.
* 4. Os caminhos novos estão em `public-paths`. Sem isso o proxy responde 401
* antes de qualquer uma das regras acima ser consultada, e a rota fica
* inalcançável por token mesmo estando correta.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isPublicPath } from "@/lib/auth/public-paths";
import { requireRole } from "@/lib/auth/require-role";
import type { AuthUser } from "@/lib/auth/types";
import { McpAuthError } from "@/lib/mcp/auth";
import { resolveAuthDual } from "./auth-dual";
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn(async () => ({ session: true })) }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn(() => ({ admin: true })) }));
vi.mock("@/lib/mcp/auth", async () => {
const actual = await vi.importActual<typeof import("@/lib/mcp/auth")>("@/lib/mcp/auth");
return { ...actual, validateBearerToken: vi.fn() };
});
const { validateBearerToken } = await import("@/lib/mcp/auth");
const ORG_DO_TOKEN = "22222222-2222-4222-8222-222222222222";
const ORG_DA_SESSAO = "33333333-3333-4333-8333-333333333333";
const USER_ID = "11111111-1111-4111-8111-111111111111";
const OPCOES = {
requestId: "req-1",
resource: "messages",
role: "agent" as const,
scope: "mcp:write",
};
function req(headers?: HeadersInit) {
return new NextRequest("http://localhost/api/v1/messages", { method: "POST", headers });
}
function sessaoOk(): void {
const user: AuthUser = {
id: USER_ID,
email: "a@example.com",
full_name: null,
avatar_url: null,
is_platform_admin: false,
idioma: "pt-BR" as const,
organizations: [
{ organization_id: ORG_DA_SESSAO, organization_name: "Org", role: "agent" },
],
} as AuthUser;
vi.mocked(requireRole).mockResolvedValue({
ok: true,
user,
org: { orgId: ORG_DA_SESSAO, role: "agent" },
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("resolveAuthDual", () => {
it("no ramo do token, a org vem do token e não do corpo", async () => {
vi.mocked(validateBearerToken).mockResolvedValue({
organizationId: ORG_DO_TOKEN,
scopes: ["mcp:read", "mcp:write"],
role: "agent",
actor: { type: "api_token", id: "tok-1" },
} as never);
const r = await resolveAuthDual(req({ authorization: "Bearer dsk_abc" }), OPCOES);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.organizationId).toBe(ORG_DO_TOKEN);
expect(r.via).toBe("token");
// requireRole nem chega a ser consultado quando há Bearer.
expect(requireRole).not.toHaveBeenCalled();
});
it("recusa token de leitura numa rota de escrita", async () => {
vi.mocked(validateBearerToken).mockResolvedValue({
organizationId: ORG_DO_TOKEN,
scopes: ["mcp:read"],
role: "agent",
actor: { type: "api_token", id: "tok-1" },
} as never);
const r = await resolveAuthDual(req({ authorization: "Bearer dsk_abc" }), OPCOES);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.response.status).toBe(403);
});
it("token inválido fecha em 401 e não cai na sessão", async () => {
vi.mocked(validateBearerToken).mockRejectedValue(
new McpAuthError(-32001, 401, "Token inválido."),
);
const r = await resolveAuthDual(req({ authorization: "Bearer dsk_ruim" }), OPCOES);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.response.status).toBe(401);
expect(requireRole).not.toHaveBeenCalled();
});
it("sem Authorization, usa a sessão", async () => {
sessaoOk();
const r = await resolveAuthDual(req(), OPCOES);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.organizationId).toBe(ORG_DA_SESSAO);
expect(r.via).toBe("session");
expect(validateBearerToken).not.toHaveBeenCalled();
});
});
describe("os caminhos de envio passam pelo proxy", () => {
// Sem estas entradas o proxy responde 401 antes do handler, e a dualidade
// acima fica inalcançável por token mesmo estando implementada.
it("libera as tres rotas de envio", () => {
expect(isPublicPath("/api/v1/messages")).toBe(true);
expect(isPublicPath("/api/v1/conversations/open-with-contact")).toBe(true);
// Mídia: sem ela o cartão de fidelidade não sai depois do corte.
expect(isPublicPath("/api/v1/conversations/abc-123/media")).toBe(true);
});
it("não dá carona a sub-paths que não têm suporte a Bearer", () => {
expect(isPublicPath("/api/v1/messages/alguma-mensagem")).toBe(false);
expect(isPublicPath("/api/v1/conversations/alguma-conversa")).toBe(false);
// A entrada de mídia é ancorada: não libera irmãs como /notes ou /claim.
expect(isPublicPath("/api/v1/conversations/abc-123/notes")).toBe(false);
expect(isPublicPath("/api/v1/conversations/abc-123/media/extra")).toBe(false);
});
});
+130
View File
@@ -0,0 +1,130 @@
/**
* Resolução de identidade para rotas que atendem NAVEGADOR e SERVIDOR.
*
* Dois modos, uma fonte de verdade cada:
*
* a) Sessão de navegador (cookie) → `requireRole(...)`, o MESMO gate do resto
* de `/api/v1/*` (rank efetivo do banco + MFA de sessão).
* b) `Authorization: Bearer dsk_…` → `validateBearerToken()` (`lib/mcp/auth.ts`),
* o autenticador de `api_tokens` que o MCP server já usa. `organization_id`
* vem da LINHA DO TOKEN no banco, nunca de query ou body do cliente, então
* não existe caminho para um Bearer de uma org escrever noutra.
*
* Erro de token (ausente, inválido, revogado, expirado) fecha em 401; token
* válido sem o scope exigido, ou com role abaixo do mínimo, fecha em 403.
* Nenhum dos dois ramos loga o header nem o plaintext do token.
*
* ⚠️ NÃO BASTA chamar isto na rota: o `proxy.ts` global roda ANTES de qualquer
* route handler e só reconhece cookie de sessão. Sem uma entrada em
* `lib/auth/public-paths.ts` para o caminho, todo Bearer recebe 401 do proxy
* antes de chegar ao handler. "Público" ali quer dizer "o proxy não decide",
* nunca "sem autenticação".
*
* Este módulo nasceu de `app/api/v1/contacts/route.ts`, que implementou o
* padrão inline primeiro. A lógica é a mesma; o que muda por rota é o scope e
* o rank mínimo, que viram parâmetro em vez de cópia.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { type NextRequest } from "next/server";
import type { Actor } from "@/lib/api/handlers/types";
import { fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import type { Role } from "@/lib/auth/types";
import type { Idioma } from "@/lib/i18n/idiomas";
import {
McpAuthError,
ensureRole,
ensureScope,
extractBearer,
validateBearerToken,
} from "@/lib/mcp/auth";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
export type AuthDual =
| {
ok: true;
organizationId: string;
actor: Actor;
supabase: SupabaseClient;
idioma?: Idioma;
/** Por onde a identidade entrou. Útil para audit e para decidir texto de erro. */
via: "session" | "token";
}
| { ok: false; response: Response };
export interface AuthDualOptions {
/** Correlaciona a resposta com o audit log. */
requestId: string;
/** `resource_type` gravado no audit `authz.denied` (ex.: "messages"). */
resource: string;
/** Rank mínimo exigido nos DOIS modos. */
role: Role;
/** Scope exigido do token. Rotas de escrita usam `mcp:write`. */
scope: string;
}
/**
* Aceita sessão OU token de servidor, devolvendo sempre a mesma forma para o
* handler. O chamador não precisa saber por onde a identidade entrou.
*/
export async function resolveAuthDual(
req: NextRequest,
{ requestId, resource, role, scope }: AuthDualOptions,
): Promise<AuthDual> {
const authHeader = req.headers.get("authorization");
if (extractBearer(authHeader)) {
let auth;
try {
auth = await validateBearerToken(authHeader);
} catch (err) {
if (err instanceof McpAuthError) {
return {
ok: false,
response: fail(
err.httpStatus === 401 ? "unauthenticated" : "forbidden",
err.message,
err.httpStatus,
{ requestId },
),
};
}
throw err;
}
try {
ensureScope(auth.scopes, scope);
ensureRole(auth.role, role);
} catch (err) {
if (err instanceof McpAuthError) {
return {
ok: false,
response: fail("forbidden_role", err.message, err.httpStatus, { requestId }),
};
}
throw err;
}
// organization_id vem do TOKEN (fonte confiável), nunca do cliente.
return {
ok: true,
organizationId: auth.organizationId,
actor: auth.actor,
supabase: createAdminClient(),
via: "token",
};
}
const authz = await requireRole(role, { requestId, resource });
if (!authz.ok) return { ok: false, response: authz.response };
return {
ok: true,
organizationId: authz.org.orgId,
actor: { type: "user", id: authz.user.id },
supabase: await createClient(),
idioma: authz.user.idioma,
via: "session",
};
}
+59 -1
View File
@@ -152,7 +152,13 @@ describe("apiClient", () => {
it("t9b: PATCH e DELETE seguem a mesma regra", async () => {
for (const chamar of [
() => apiClient.patch("/x", { a: 1 }, { timeoutMs: 5 }),
() => apiClient.delete("/x", { timeoutMs: 5 }),
// `delete(path, body?, opts?)` — o `body` opcional entrou no meio quando a
// rota de cancelar agendamento passou a exigir motivo. Escrito como
// `delete("/x", { timeoutMs: 5 })`, este objeto virava CORPO e o `opts`
// ficava vazio: o caso rodava com o prazo padrão e passava porque o
// padrão de então (10s) cabia no `testTimeout` de 15s — media a contagem
// de tentativas, nunca o prazo que dizia estar medindo.
() => apiClient.delete("/x", undefined, { timeoutMs: 5 }),
]) {
fetchMock.mockClear();
fetchMock.mockImplementation(abortaSempre());
@@ -180,6 +186,58 @@ describe("apiClient", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
/**
* O ORÇAMENTO DE ESPERA DA ESCRITA (o vermelho de `followup-dossie:190`).
*
* Enquanto o método mutante era retentado, uma escrita tinha 10s + backoff +
* 10s + backoff + 10s ≈ 30,6s de parede. Parar de repetir era certo; o que
* passou despercebido é que a repetição também era o PRAZO — e ele caiu para
* 10s em toda mutação do produto de uma vez só.
*
* Medido no trace do CI (run 34876435491): `POST …/pause` cortado em
* 9999,558ms com `net::ERR_ABORTED` e UMA tentativa, num job onde os testes
* vizinhos correram mais rápido que na `main`. Quem desistiu foi o navegador.
*
* Estes dois casos prendem os dois prazos, que são diferentes de propósito:
* escrever espera 30s (desistir não cancela nada no servidor — só perde a
* resposta), ler desiste em 10s (a tela não fica presa, e a leitura é
* repetida, então o orçamento dela não mudou).
*/
it("t12: escrita só desiste depois de 30s — aos 10s ela ainda está de pé", async () => {
vi.useFakeTimers();
fetchMock.mockImplementation(abortaSempre());
const desfecho = vi.fn();
void apiClient.post("/x", { a: 1 }).then(desfecho, desfecho);
// 10s é o prazo da LEITURA. Se ele estiver valendo aqui, a escrita já
// morreu neste ponto — que é exatamente o defeito.
await vi.advanceTimersByTimeAsync(10_500);
expect(desfecho).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(20_000);
expect(desfecho).toHaveBeenCalled();
// E continua sem repetir: o prazo mudou, a regra do #787 não.
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("t13: leitura continua desistindo aos 10s — ela é repetida, prender a tela não paga", async () => {
vi.useFakeTimers();
fetchMock.mockImplementation(abortaSempre());
const desfecho = vi.fn();
void apiClient.get("/x").then(desfecho, desfecho);
// Aos 10,5s a primeira já estourou e a segunda tentativa começou.
await vi.advanceTimersByTimeAsync(10_500);
expect(fetchMock).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(30_000);
expect(desfecho).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
/**
+54 -1
View File
@@ -14,6 +14,58 @@ export type RequestOpts = {
};
const DEFAULT_TIMEOUT_MS = 10_000;
/**
* O prazo de uma ESCRITA — o orçamento que o fim do retry tirou sem repor.
*
* ─── A conta ────────────────────────────────────────────────────────────────
*
* Enquanto método mutante era retentado, uma escrita tinha três tentativas de
* `DEFAULT_TIMEOUT_MS` separadas pelo `backoffMs`: 10s + ~0,2s + 10s + ~0,4s +
* 10s ≈ **30,6s** de parede até o cliente desistir de vez. O retry era errado
* (executava a escrita de novo, e foi por isso que caiu — ver o bloco
* `MUTATING_METHODS` no `catch` abaixo), mas ele também era, sem querer, o
* ORÇAMENTO DE ESPERA de toda escrita do produto. Tirar a repetição sem repor a
* espera cortou esse orçamento para 10s num único gesto, em toda mutação da
* base — e ninguém mexeu no número.
*
* ─── O que isso quebrou, medido ─────────────────────────────────────────────
*
* CI `e2e` do lote, run 34876435491, `followup-dossie.spec.ts:190`. No trace:
*
* POST /api/v1/ai/followups/enrollments/…/pause
* time 9999.558ms _failureText net::ERR_ABORTED 1 tentativa
*
* 9999,558ms é o `DEFAULT_TIMEOUT_MS` cravado: quem desistiu foi o NAVEGADOR,
* não o servidor. E a máquina não estava lenta — dos 16 testes `followup-*`
* vizinhos no mesmo job, 15 correram MAIS RÁPIDO que no run verde da `main`
* 34878063927 (builder:414 9,9s contra 11,7s; queue:153 8,9s contra 12,2s;
* dossie:288 11,4s contra 14,6s), e o único mais lento foi builder:287, por
* 1s. Foi uma paralisada isolada daquela escrita, do tipo que os 30s absorviam
* e os 10s não absorvem mais.
*
* O que a pessoa via na tela, no frame seguinte ao corte: "Erro inesperado.
* Tente novamente." sobre um dossiê ainda escrito "Ativo" — exatamente o
* sintoma que o cabeçalho da migration 0243 descreve como o incidente de
* 2026-09-12, e que ela consertou só do lado do banco (`lock_timeout` em
* `authenticator`/`authenticated`; rota que escreve por `service_role`, como
* esta, fica de fora de propósito).
*
* ─── Por que ESPERAR mais é a direção certa ─────────────────────────────────
*
* Pelo mesmo motivo que não se repete: o servidor não cancela nada quando o
* cliente desiste. Abortar uma escrita aos 10s não a impede — só joga fora a
* RESPOSTA que estava a caminho, trocando um resultado conhecido por uma
* dúvida. Numa leitura, desistir cedo é bom (a tela não fica presa, e o GET
* ainda é repetido, então o orçamento dele não mudou); numa escrita, desistir
* cedo não tem nada a ganhar.
*
* 30s não é folga nova: é a mesma parede que a escrita já tinha, entregue como
* UMA tentativa em vez de três. Chamada que precisa de mais passa `timeoutMs`
* (o "Testar agente" pede 120s, e segue mandando).
*/
const MUTATION_TIMEOUT_MS = 30_000;
const MAX_ATTEMPTS = 3;
const RETRYABLE_STATUSES = new Set([429, 503]);
const MUTATING_METHODS = new Set<HttpMethod>(["POST", "PATCH", "PUT", "DELETE"]);
@@ -199,7 +251,8 @@ async function request<T>(
const serializedBody =
body === undefined || body === null ? undefined : JSON.stringify(body);
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutMs =
opts.timeoutMs ?? (MUTATING_METHODS.has(method) ? MUTATION_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
let lastError: unknown;
+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
+44
View File
@@ -0,0 +1,44 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { env } from "@/lib/env";
/**
* Compara duas strings em tempo constante usando SHA-256 e timingSafeEqual.
* O hashing prévio garante comprimento fixo (32 bytes), prevenindo tanto
* timing attacks no conteúdo quanto vazamento do tamanho da string via early return.
*/
export function timingSafeStringEqual(a: string, b: string): boolean {
if (!a || !b) return false;
const hashA = createHash("sha256").update(a).digest();
const hashB = createHash("sha256").update(b).digest();
return timingSafeEqual(hashA, hashB);
}
/**
* Valida a autenticação de chamadas internas de cron.
* Suporta header `Authorization: Bearer <secret>` e fallback para `x-cron-secret: <secret>`.
* Compara em tempo constante contra `INTERNAL_CRON_SECRET` e `INTERNAL_SECRET`.
*
* Fail-closed: se nenhum secret estiver configurado no ambiente ou nenhum token for fornecido,
* recusa imediatamente com false.
*/
export function autorizaCron(req: NextRequest): boolean {
const auth = req.headers.get("authorization") ?? "";
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
const headerSecret = req.headers.get("x-cron-secret")?.trim() ?? "";
const provided = bearer || headerSecret;
if (!provided) {
return false;
}
const accepted = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(
(s): s is string => typeof s === "string" && s.length > 0,
);
if (accepted.length === 0) {
return false;
}
return accepted.some((secret) => timingSafeStringEqual(provided, secret));
}
+14
View File
@@ -49,6 +49,20 @@ export const PUBLIC_PATHS: RegExp[] = [
// `GET` da listagem, não `/api/v1/contacts/[id]` nem `/import`, que ainda
// não têm suporte a Bearer.
/^\/api\/v1\/contacts$/,
// ENVIO SERVER-TO-SERVER. Mesma dualidade de `/api/v1/contacts` acima, com
// `mcp:write` em vez de `mcp:read`: sessão de navegador OU Bearer `dsk_…`,
// resolvidos por `lib/api/auth-dual.ts` DENTRO de cada rota, com a org saindo
// da linha do token e nunca do corpo. Existem porque quem envia por aqui não
// tem navegador: o gateway do CRM em absorção e integrações de servidor.
//
// Ancoradas com `$` de propósito. `/^\/api\/v1\/messages/` sem âncora daria
// carona a `/api/v1/messages/[id]`, que NÃO tem suporte a Bearer.
/^\/api\/v1\/messages$/,
/^\/api\/v1\/conversations\/open-with-contact$/,
// Upload outbound: primeiro passo do envio de MÍDIA por token. Sem ele, o
// cartão de fidelidade (a única das automações que não é texto) não teria
// como sair depois do corte de gateway.
/^\/api\/v1\/conversations\/[^/]+\/media$/,
/^\/_next\//,
/^\/favicon\.ico$/,
// O ícone da aba (`app/icon.tsx`), que o `<head>` de TODA página pede —
+58 -68
View File
@@ -1,3 +1,4 @@
import { cache } from "react";
import { lerInterface } from "@/lib/navigation/interface";
/**
* Server-side auth helpers — load AuthUser, resolve active org, gate routes.
@@ -104,7 +105,7 @@ export function ehSessaoAusente(error: { name?: string } | null | undefined): bo
return error?.name === "AuthSessionMissingError";
}
export async function loadAuthUser(): Promise<AuthUser | null> {
export const loadAuthUser = cache(async (): Promise<AuthUser | null> => {
const supabase = await createClient();
const {
data: { user },
@@ -149,42 +150,29 @@ export async function loadAuthUser(): Promise<AuthUser | null> {
}
if (!user) return null;
// Platform admin? (active = no revoked_at). RLS returns null for non-admins.
//
// ⚠️ O erro é capturado de propósito: aqui `data: null` é AMBÍGUO — significa tanto
// "não é platform admin" (RLS filtrou, estado normal) quanto "a query falhou".
// Sem separar os dois, um banco instável rebaixa silenciosamente um super-admin.
const { data: paRow, error: paErro } = await supabase
.from("platform_admins")
.select("user_id, revoked_at")
.eq("user_id", user.id)
.is("revoked_at", null)
.maybeSingle();
// Org memberships (only active = not revoked, accepted)
// Platform admin e Org memberships consultados em paralelo no Supabase:
// elimina round-trip sequencial a cada requisição.
// ⚠️ `ORDER BY` NÃO É ENFEITE AQUI: esta lista decide QUAL ORGANIZAÇÃO FICA
// ATIVA para quem não tem o cookie `active_org` — `resolveActiveOrg` pega
// `organizations[0]`. Sem ordenação, "a primeira" é o que o Postgres devolver,
// e isso não é estável por especificação: muda com plano de execução, com a
// ordem física das linhas e com qualquer reescrita delas.
//
// O efeito para quem administra DUAS empresas na mesma instalação: entrar sem
// cookie (primeiro acesso, sessão nova, cookie expirado) podia cair numa ou na
// outra sem critério nenhum — e o produto não dava sinal de que escolheu.
//
// `accepted_at` primeiro porque a organização mais ANTIGA é a que a pessoa
// reconhece como "a minha"; `organization_id` como desempate, para o resultado
// ser determinístico mesmo quando as duas entraram no mesmo instante (é o caso
// de quem foi convidado para várias no mesmo lote).
const { data: rawMemberships, error: membErro } = await supabase
.from("user_organizations")
.select(
"organization_id, role, interface_settings, accepted_at, organizations(display_name, locale)",
)
.eq("user_id", user.id)
.is("revoked_at", null)
.order("accepted_at", { ascending: true, nullsFirst: true })
.order("organization_id", { ascending: true });
// `organizations[0]`. Sem ordenação, "a primeira" é o que o Postgres devolver.
const [{ data: paRow, error: paErro }, { data: rawMemberships, error: membErro }] =
await Promise.all([
supabase
.from("platform_admins")
.select("user_id, revoked_at")
.eq("user_id", user.id)
.is("revoked_at", null)
.maybeSingle(),
supabase
.from("user_organizations")
.select(
"organization_id, role, interface_settings, accepted_at, organizations(display_name, locale)",
)
.eq("user_id", user.id)
.is("revoked_at", null)
.order("accepted_at", { ascending: true, nullsFirst: true })
.order("organization_id", { ascending: true }),
]);
/**
* FALHA ALTO, não baixo.
@@ -259,14 +247,14 @@ export async function loadAuthUser(): Promise<AuthUser | null> {
organizations: memberships,
support,
};
}
});
/**
* Resolves the active organization for the current request.
* Priority: cookie `active_org` (if member of) → first membership.
* Returns null if user has zero memberships.
*/
export async function resolveActiveOrg(authUser: AuthUser): Promise<ActiveOrg | null> {
export const resolveActiveOrg = cache(async (authUser: AuthUser): Promise<ActiveOrg | null> => {
if (authUser.support) {
if (authUser.support.status !== "active") redirect("/support-ended");
return {
@@ -284,7 +272,7 @@ export async function resolveActiveOrg(authUser: AuthUser): Promise<ActiveOrg |
role: ativo.role,
interface_settings: ativo.interface_settings,
};
}
});
/**
* For Server Components / Server Actions in /app/(app)/* routes — guarantees
@@ -300,11 +288,11 @@ export async function requireAuth(): Promise<AuthUser> {
* Returns true if the current session has at least one verified TOTP factor.
* Use only in Server Components / Server Actions (cookie session).
*/
export async function isMfaEnrolled(): Promise<boolean> {
export const isMfaEnrolled = cache(async (): Promise<boolean> => {
const supabase = await createClient();
const { data } = await supabase.auth.mfa.listFactors();
return !!data?.totp?.some((f) => f.status === "verified");
}
});
/**
* Quem é OBRIGADO a cadastrar a verificação em duas etapas.
@@ -324,37 +312,39 @@ export async function isMfaEnrolled(): Promise<boolean> {
* Carrega as duas leituras porque o layout precisa delas de qualquer forma; quem
* já tem a política em mãos deve chamar `exigeCadastroDeMfa` direto.
*/
export async function requiresMfa(
role: Role | undefined,
isPlatformAdmin: boolean,
userId?: string,
orgId?: string,
): Promise<boolean> {
const admin = createAdminClient();
export const requiresMfa = cache(
async (
role: Role | undefined,
isPlatformAdmin: boolean,
userId?: string,
orgId?: string,
): Promise<boolean> => {
const admin = createAdminClient();
let plataformaExige: boolean | null = null;
if (isPlatformAdmin && userId) {
const { data } = await admin
.from("platform_admins")
.select("mfa_required")
.eq("user_id", userId)
.is("revoked_at", null)
.maybeSingle();
plataformaExige = (data?.mfa_required as boolean | undefined) ?? null;
}
let plataformaExige: boolean | null = null;
if (isPlatformAdmin && userId) {
const { data } = await admin
.from("platform_admins")
.select("mfa_required")
.eq("user_id", userId)
.is("revoked_at", null)
.maybeSingle();
plataformaExige = (data?.mfa_required as boolean | undefined) ?? null;
}
let empresaExige = false;
if (orgId) {
const { data } = await admin
.from("organizations")
.select("settings")
.eq("id", orgId)
.maybeSingle();
empresaExige = empresaExigeMfa(data?.settings);
}
let empresaExige = false;
if (orgId) {
const { data } = await admin
.from("organizations")
.select("settings")
.eq("id", orgId)
.maybeSingle();
empresaExige = empresaExigeMfa(data?.settings);
}
return exigeCadastroDeMfa({ role, isPlatformAdmin, plataformaExige, empresaExige });
}
return exigeCadastroDeMfa({ role, isPlatformAdmin, plataformaExige, empresaExige });
},
);
/**
* Nível de garantia da SESSÃO atual: `aal2` = o segundo fator foi provado nesta
+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");
}
});
});
+140 -26
View File
@@ -1,21 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import type { SupabaseClient } from "@supabase/supabase-js";
import { connectWahaChannel } from "./connect-waha";
import { connectWahaChannel, renomearSessaoParaOTeto } from "./connect-waha";
vi.mock("@/lib/audit", () => ({ audit: vi.fn() }));
const org = "20000000-0000-4000-8000-000000000001";
const key = "20000000-0000-4000-8000-000000000002";
const channel = { id: key, organization_id: org, waha_session_name: "owned", status: "STARTING", archived_at: null };
function fixture() {
/**
* A linha como o banco a devolve. `reservado` é o que
* `fn_reserve_channel_connection` retorna — e ela sobrescreve `status` com
* `STARTING` antes do `returning`, então o status que chega ao código de
* conexão NUNCA é o status real do canal. `phone_number`, sim.
*/
function fixture(linha: Partial<typeof channel> & { phone_number?: string | null } = {}) {
const finishes: Record<string, unknown>[] = [];
const noBanco = { ...channel, phone_number: null as string | null, ...linha };
const reservado = { ...noBanco, status: "STARTING" };
const nomeInicial = reservado.waha_session_name;
/** Nomes gravados por UPDATE, na ordem. Vazio = ninguém renomeou. */
const renomeios: string[] = [];
const db = { rpc: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "fn_reserve_channel_connection") return { data: { channel, receipt_id: key, lease_token: key, replay: false }, error: null };
if (name === "fn_reserve_channel_connection") return { data: { channel: reservado, receipt_id: key, lease_token: key, replay: false }, error: null };
finishes.push(args);
return { data: { ...channel, status: args.p_status }, error: null };
}) } as unknown as SupabaseClient;
const transport = { getVerifiedSession: vi.fn(async () => null), createSession: vi.fn(async () => ({ created: true, session: { name: "owned", status: "STOPPED" } })),
startExistingSession: vi.fn(async () => ({ name: "owned", status: "SCAN_QR_CODE" })),
return { data: { ...noBanco, waha_session_name: renomeios.at(-1) ?? nomeInicial, status: args.p_status }, error: null };
}),
// Simula o WHERE de verdade: cada filtro é conferido contra `noBanco`, e a
// linha só é atualizada se TODOS casarem. Sem isso o teste não distingue
// "a guarda está no UPDATE" de "a guarda sumiu do UPDATE".
from: vi.fn(() => {
let casa = true;
let pendente: Record<string, unknown> | null = null;
const builder = {
update(row: Record<string, unknown>) { pendente = row; return builder; },
eq(coluna: string, valor: unknown) { casa &&= (noBanco as Record<string, unknown>)[coluna] === valor; return builder; },
is(coluna: string, valor: unknown) { casa &&= ((noBanco as Record<string, unknown>)[coluna] ?? null) === valor; return builder; },
neq(coluna: string, valor: unknown) { casa &&= (noBanco as Record<string, unknown>)[coluna] !== valor; return builder; },
select() { return builder; },
async maybeSingle() {
if (casa && pendente) renomeios.push(pendente.waha_session_name as string);
return { data: casa ? { id: noBanco.id } : null, error: null };
},
};
return builder;
}),
} as unknown as SupabaseClient;
const transport = { getVerifiedSession: vi.fn(async () => null),
createSession: vi.fn(async (nome: string) => ({ created: true, session: { name: nome, status: "STOPPED" } })),
startExistingSession: vi.fn(async (nome: string) => ({ name: nome, status: "SCAN_QR_CODE" })),
deleteSession: vi.fn(async () => {}), stopSession: vi.fn(async () => {}) };
return { db, transport, finishes, input: { organizationId: org, idempotencyKey: key, userId: key, requestId: key } };
return { db, transport, finishes, renomeios, input: { organizationId: org, idempotencyKey: key, userId: key, requestId: key } };
}
describe("conexão recuperável", () => {
it("publica somente o status confirmado pelo transporte e pelo DB", async () => {
@@ -65,23 +97,105 @@ describe("conexão recuperável", () => {
expect((await connectWahaChannel(f.db, f.db, f.transport, f.input)).replay).toBe(true);
expect(f.transport.createSession).not.toHaveBeenCalled();expect(f.transport.deleteSession).not.toHaveBeenCalled();
});
it("nome acima de 54 chars é encurtado antes do create — senão o WAHA responde 400", async () => {
const longo = `org_${org.replaceAll("-", "")}_${key.replaceAll("-", "")}`;
expect(longo.length).toBe(69);
const curto = { ...channel, waha_session_name: longo };
const f = fixture();
const nomes: string[] = [];
const thenable = { eq() { return this; }, then(ok: (v: { error: null }) => unknown) { return Promise.resolve({ error: null }).then(ok); } };
Object.assign(f.db, { from: () => ({ update: (row: { waha_session_name: string }) => { nomes.push(row.waha_session_name); return thenable; } }) });
vi.mocked(f.db.rpc).mockImplementation(((name: string, args?: Record<string, unknown>) => {
if (name === "fn_reserve_channel_connection") return Promise.resolve({ data: { channel: curto, receipt_id: key, lease_token: key, replay: false }, error: null });
return Promise.resolve({ data: { ...curto, waha_session_name: nomes[0], status: args?.p_status }, error: null });
}) as never);
f.transport.createSession.mockImplementation(async (n: string) => ({ created: true, session: { name: n, status: "STOPPED" } }));
f.transport.startExistingSession.mockImplementation(async (n: string) => ({ name: n, status: "SCAN_QR_CODE" }));
const result = await connectWahaChannel(f.db, f.db, f.transport, f.input);
expect(nomes[0]!.length).toBeLessThanOrEqual(54);
expect(f.transport.createSession).toHaveBeenCalledWith(nomes[0]);
expect(result.channel.status).toBe("SCAN_QR_CODE");
});
/**
* O nome de 69 caracteres que a 0228/0230 gerava (`org_<32>_<32>`). É o que o
* WAHA recusa com `400 name must be shorter than or equal to 54 characters`.
*/
const NOME_LEGADO = `org_${org.replaceAll("-", "")}_${key.replaceAll("-", "")}`;
describe("nome de sessão fora do teto do WAHA", () => {
it("o nome legado tem mesmo 69 caracteres", () => {
expect(NOME_LEGADO).toHaveLength(69);
});
it("canal que nunca pareou é CURADO: o transporte recebe um nome dentro do teto", async () => {
const f = fixture({ waha_session_name: NOME_LEGADO, phone_number: null });
const resultado = await connectWahaChannel(f.db, f.db, f.transport, f.input);
expect(f.renomeios).toHaveLength(1);
const novo = f.renomeios[0]!;
expect(novo.length).toBeLessThanOrEqual(54);
expect(novo).toMatch(/^org_[0-9a-f]{8}_[0-9a-f]{32}$/);
expect(f.transport.createSession).toHaveBeenCalledWith(novo);
expect(f.transport.startExistingSession).toHaveBeenCalledWith(novo);
expect(f.transport.createSession).not.toHaveBeenCalledWith(NOME_LEGADO);
expect(resultado.channel.status).toBe("SCAN_QR_CODE");
});
it("canal PAREADO e parado NÃO é renomeado: recusa com motivo, sem tocar o transporte", async () => {
// O caso que a guarda por `status` sozinha perde. A reserva devolve
// `status: "STARTING"` mesmo para um canal que estava parado, então quem
// protege aqui é `phone_number` — e renomear desligaria o CRM da sessão
// que existe no disco do WAHA.
const f = fixture({ waha_session_name: NOME_LEGADO, phone_number: "5511999990000", status: "STOPPED" });
await expect(connectWahaChannel(f.db, f.db, f.transport, f.input)).rejects.toMatchObject({
code: "connection_session_name_too_long", status: 409,
technical: { waha_session_name: NOME_LEGADO, comprimento: 69, teto: 54 },
});
expect(f.renomeios).toEqual([]);
expect(f.transport.createSession).not.toHaveBeenCalled();
expect(f.transport.startExistingSession).not.toHaveBeenCalled();
expect(f.transport.stopSession).not.toHaveBeenCalled();
expect(f.transport.deleteSession).not.toHaveBeenCalled();
expect(f.finishes.at(-1)).toMatchObject({ p_status: "FAILED", p_reason: "session_name_too_long" });
});
it("canal pareado E WORKING também não é renomeado", async () => {
const f = fixture({ waha_session_name: NOME_LEGADO, phone_number: "5511999990000", status: "WORKING" });
await expect(connectWahaChannel(f.db, f.db, f.transport, f.input)).rejects.toMatchObject({ code: "connection_session_name_too_long" });
expect(f.renomeios).toEqual([]);
});
it("nome exatamente no teto (54) segue pelo caminho normal, sem renomeio", async () => {
const nome = "o".repeat(54);
const f = fixture({ waha_session_name: nome });
expect((await connectWahaChannel(f.db, f.db, f.transport, f.input)).channel.status).toBe("SCAN_QR_CODE");
expect(f.transport.createSession).toHaveBeenCalledWith(nome);
expect(f.renomeios).toEqual([]);
});
it("nome que o banco gera desde a 0232 (45) segue pelo caminho normal", async () => {
const nome = `org_${org.replaceAll("-", "").slice(0, 8)}_${key.replaceAll("-", "")}`;
expect(nome).toHaveLength(45);
const f = fixture({ waha_session_name: nome });
expect((await connectWahaChannel(f.db, f.db, f.transport, f.input)).channel.status).toBe("SCAN_QR_CODE");
expect(f.transport.startExistingSession).toHaveBeenCalledWith(nome);
expect(f.renomeios).toEqual([]);
});
});
/**
* A decisão em memória (`podeRenomearSessaoDoWaha`) e a guarda no WHERE do
* UPDATE são DOIS caminhos com a mesma saída — e por isso apagar um deixa os
* testes do caminho de conectar verdes. Estes casos chamam o UPDATE direto,
* sem passar pela decisão, que é o único jeito de a guarda do WHERE ser vigiada
* de fato.
*/
describe("renomearSessaoParaOTeto — a guarda também mora no WHERE", () => {
const alvo = { id: key, organization_id: org, waha_session_name: NOME_LEGADO };
it("linha PAREADA não casa no UPDATE: ninguém é renomeado e a função recusa", async () => {
const f = fixture({ waha_session_name: NOME_LEGADO, phone_number: "5511999990000", status: "STOPPED" });
await expect(renomearSessaoParaOTeto(f.db, alvo)).rejects.toMatchObject({
code: "connection_session_name_too_long", status: 409,
technical: { renomeio_recusado: true },
});
expect(f.renomeios).toEqual([]);
});
it("linha WORKING não casa no UPDATE", async () => {
const f = fixture({ waha_session_name: NOME_LEGADO, phone_number: null, status: "WORKING" });
// A reserva sobrescreve o status com STARTING, então aqui o `neq` precisa
// ver o estado da TABELA — que é o que este dublê guarda.
await expect(renomearSessaoParaOTeto(f.db, alvo)).rejects.toMatchObject({ code: "connection_session_name_too_long" });
expect(f.renomeios).toEqual([]);
});
it("linha que nunca pareou casa e recebe o nome novo", async () => {
const f = fixture({ waha_session_name: NOME_LEGADO, phone_number: null, status: "FAILED" });
const novo = await renomearSessaoParaOTeto(f.db, alvo);
expect(novo).toMatch(/^org_[0-9a-f]{8}_[0-9a-f]{32}$/);
expect(f.renomeios).toEqual([novo]);
});
});
+56 -17
View File
@@ -2,9 +2,11 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import { audit } from "@/lib/audit";
import {
TETO_NOME_DE_SESSAO_WAHA, nomeDaSessaoCabeNoWaha, nomeDaSessaoNovo, podeRenomearSessaoDoWaha,
} from "@/lib/channels/nome-da-sessao";
import type { WahaClient } from "@/lib/waha/client";
import { WahaSessionError } from "@/lib/waha/client";
import { cabeNoWaha, nomeWahaNovo } from "@/lib/waha/nome-da-sessao";
const channelSchema = z.object({
id: z.string().uuid(), organization_id: z.string().uuid(), waha_session_name: z.string(),
@@ -53,15 +55,41 @@ export async function connectWahaChannel(authDb: SupabaseClient, serviceDb: Supa
if (result.error) throw new ChannelConnectionError("connection_checkpoint_failed", 503);
return result.data;
}
// Teto do WAHA conferido AQUI, antes de qualquer chamada ao transporte.
//
// Deixar passar é o defeito da issue #667: o WAHA devolve um 400 opaco no
// meio do fluxo, com a reserva já feita, e o card de Conexões fica preso em
// `Parado`. Nome fora do teto não é falha de transporte — é dado de uma
// instalação cujo banco ainda não recebeu o backfill da 0232.
//
// O que dá para curar, é curado; o que não dá, para aqui. A fronteira é a
// da 0232 (`podeRenomearSessaoDoWaha`), e ela existe porque renomear um
// canal que já pareou o desliga do diretório de sessão do WAHA — o número
// some e só volta com QR novo. A reserva é fechada em `FAILED` para não
// travar a próxima tentativa.
if (!nomeDaSessaoCabeNoWaha(channel.waha_session_name)) {
if (!podeRenomearSessaoDoWaha(channel)) {
await finish("FAILED", "session_name_too_long");
throw new ChannelConnectionError("connection_session_name_too_long", 409, {
waha_session_name: channel.waha_session_name,
comprimento: channel.waha_session_name.length,
teto: TETO_NOME_DE_SESSAO_WAHA,
});
}
try {
channel.waha_session_name = await renomearSessaoParaOTeto(serviceDb, channel);
} catch (cause) {
await finish("FAILED", "session_name_too_long");
throw cause;
}
}
try {
const nome = await alinharNomeAoTetoWaha(serviceDb, channel);
channel.waha_session_name = nome;
if (input.restart) await waha.stopSession(nome);
const creation = await waha.createSession(nome);
if (input.restart) await waha.stopSession(channel.waha_session_name);
const creation = await waha.createSession(channel.waha_session_name);
created = creation.created;
if (created) await finish("remote_created");
const remote = await waha.startExistingSession(nome);
if (remote.name !== nome || !["STARTING", "SCAN_QR_CODE", "WORKING"].includes(remote.status)) {
const remote = await waha.startExistingSession(channel.waha_session_name);
if (remote.name !== channel.waha_session_name || !["STARTING", "SCAN_QR_CODE", "WORKING"].includes(remote.status)) {
throw new Error("connection_postcondition_failed");
}
const persisted = channelSchema.parse(await finish(remote.status));
@@ -81,20 +109,31 @@ export async function connectWahaChannel(authDb: SupabaseClient, serviceDb: Supa
}
/**
* WAHA recusa nome >54 no create (HTTP 400). Canal WORKING não se renomeia:
* se alguém pareou num WAHA sem o teto, trocar o nome desconecta o aparelho.
* Troca o nome fora do teto por um `org_<8>_<32>`, e só quando é seguro.
*
* A guarda de `podeRenomearSessaoDoWaha` é repetida no WHERE de propósito: a
* decisão em memória parte de uma linha lida antes, e o que impede o UPDATE de
* alcançar um canal pareado precisa estar no próprio UPDATE. Nenhuma linha
* casada = ninguém renomeia e ninguém finge que renomeou.
*/
export async function alinharNomeAoTetoWaha(
export async function renomearSessaoParaOTeto(
db: SupabaseClient,
channel: { id: string; organization_id: string; waha_session_name: string; status?: string },
canal: { id: string; organization_id: string; waha_session_name: string },
): Promise<string> {
if (cabeNoWaha(channel.waha_session_name) || channel.status === "WORKING") {
return channel.waha_session_name;
const novo = nomeDaSessaoNovo(canal.organization_id);
const { data, error } = await db.from("channel_sessions")
.update({ waha_session_name: novo })
.eq("organization_id", canal.organization_id).eq("id", canal.id)
.is("phone_number", null).neq("status", "WORKING")
.select("id").maybeSingle();
if (error || !data) {
throw new ChannelConnectionError("connection_session_name_too_long", 409, {
waha_session_name: canal.waha_session_name,
comprimento: canal.waha_session_name.length,
teto: TETO_NOME_DE_SESSAO_WAHA,
renomeio_recusado: true,
});
}
const novo = nomeWahaNovo(channel.organization_id);
const { error } = await db.from("channel_sessions").update({ waha_session_name: novo })
.eq("organization_id", channel.organization_id).eq("id", channel.id);
if (error) throw new ChannelConnectionError("connection_repair_required", 502);
return novo;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* O nome da sessão que o CRM manda para o WAHA, num lugar só.
*
* ─── Por que este arquivo existe ───────────────────────────────────────────
*
* O formato curto (`org_` + os 8 primeiros caracteres do uuid da organização —
* 12 no total) nasceu dentro da tela de onboarding. O banco montava o dele por
* conta própria, e por um tempo montou `org_<32>_<32>`: 69 caracteres, acima do
* `@MaxLength(54)` do WAHA, que responde
* `400 name must be shorter than or equal to 54 characters`.
*
* O onboarding escapava porque gastava o formato curto. O botão "Conectar novo
* WhatsApp" da tela de Conexões, que gasta o nome vindo do banco, falhava
* SEMPRE — e o operador só via o card preso em `Parado`.
*
* A migration 0232 consertou o gerador no banco e renomeou as linhas que já
* estavam fora do teto. Três regras ficaram, e são as três deste arquivo:
*
* 1. superfície não monta o nome da sessão à mão; deriva daqui;
* 2. o teto é conferido do lado do CRM — descobrir o limite por um 400 opaco
* do transporte, depois da reserva feita, é o que prendia o card;
* 3. o que dá para curar em runtime é curado sob a MESMA condição da 0232, e
* só ela. O resto para aqui, com motivo próprio.
*/
/**
* Teto do `name` de sessão no WAHA (`@MaxLength(54)`).
*
* Não é folga: é o limite do outro lado. Mudar aqui só com o WAHA mudando lá.
*/
export const TETO_NOME_DE_SESSAO_WAHA = 54;
/** O `@Matches` do mesmo DTO. Um nome fora dele toma o mesmo 400. */
const PADRAO_NOME_DE_SESSAO_WAHA = /^[a-zA-Z0-9_-]+$/;
/**
* Formato curto e estável da sessão — o mesmo que o onboarding sempre usou.
*
* O prefixo `org_` não é decorativo: `lib/channels/onboarding-session.ts`
* procura exatamente esta string para achar a linha legada da própria
* organização. Mudar o formato aqui é mudar a busca lá.
*/
export function nomeCurtoDaSessao(organizationId: string): string {
return `org_${organizationId.slice(0, 8)}`;
}
/** O nome cabe no que o WAHA aceita? Falso = não pode chegar ao transporte. */
export function nomeDaSessaoCabeNoWaha(nome: string): boolean {
return nome.length <= TETO_NOME_DE_SESSAO_WAHA && PADRAO_NOME_DE_SESSAO_WAHA.test(nome);
}
/**
* O mesmo formato que a 0232 passou a gerar no banco: `org_<8>_<32>` = 45.
*
* Gera com o `crypto` global (Node 22 e Edge têm), não com `node:crypto`: este
* módulo é importado por `app/onboarding/connect-whatsapp/page.tsx`, e um
* import de builtin aqui amarraria a tela ao runtime Node sem precisar.
*/
export function nomeDaSessaoNovo(organizationId: string, unico = crypto.randomUUID()): string {
return `org_${organizationId.replaceAll("-", "").slice(0, 8)}_${unico.replaceAll("-", "")}`;
}
/**
* Renomear este canal é seguro? É a condição EXATA do backfill da 0232 —
* `phone_number is null and status <> 'WORKING'` — e a igualdade não é
* estética.
*
* O WAHA guarda a credencial da sessão numa pasta com o NOME da sessão
* (`/app/.sessions/<name>`). Renomear um canal que pareou de verdade deixa o
* CRM apontando para uma sessão que não existe e abandona a que existe: o
* número some do sistema e só volta com um QR novo. Ou seja, a guarda frouxa
* não é feiura — é perda de canal em produção.
*
* `phone_number` é quem carrega o peso. `status` entra porque a 0232 o tem,
* mas ele NÃO protege o caminho de conectar: `fn_reserve_channel_connection`
* grava `status='STARTING'` na linha e devolve essa linha, então o status que
* chega aqui é sempre `STARTING`, nunca `WORKING`. Uma guarda que olhasse só o
* status renomearia um canal pareado e parado — exatamente o caso perigoso.
*/
export function podeRenomearSessaoDoWaha(
canal: { phone_number?: string | null; status?: string | null },
): boolean {
return (canal.phone_number ?? null) === null && canal.status !== "WORKING";
}
+2 -1
View File
@@ -1,10 +1,11 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { nomeCurtoDaSessao } from "@/lib/channels/nome-da-sessao";
/** Nome legado só localiza linha da própria org; nunca autoriza acesso remoto. */
export async function loadOnboardingChannel(db: SupabaseClient, organizationId: string) {
const { data, error } = await db.from("channel_sessions")
.select("id, organization_id, waha_session_name, status, archived_at")
.eq("organization_id", organizationId).eq("provider", "waha")
.or(`metadata->>onboarding.eq.true,waha_session_name.eq.org_${organizationId.slice(0, 8)}`)
.or(`metadata->>onboarding.eq.true,waha_session_name.eq.${nomeCurtoDaSessao(organizationId)}`)
.order("created_at").limit(1).maybeSingle();
if (error) throw new Error(error.message);
return data as { id: string; organization_id: string; waha_session_name: string; status: string; archived_at: string | null } | null;
+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),
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { TETO_NOME_WAHA, cabeNoWaha, nomeWahaNovo } from "./nome-da-sessao";
const ORG = "52d2a0d0-b31d-4563-a943-1411da81728f";
describe("nome da sessão WAHA", () => {
it("o formato da 0228/0230 estoura o teto de 54 — é o 400 do create", () => {
const legado = `org_${ORG.replaceAll("-", "")}_${crypto.randomUUID().replaceAll("-", "")}`;
expect(legado.length).toBe(69);
expect(cabeNoWaha(legado)).toBe(false);
});
it("o nome novo cabe no DTO e no pattern do WAHA", () => {
const nome = nomeWahaNovo(ORG, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
expect(nome).toBe("org_52d2a0d0_aaaaaaaabbbbccccddddeeeeeeeeeeee");
expect(nome.length).toBeLessThanOrEqual(TETO_NOME_WAHA);
expect(cabeNoWaha(nome)).toBe(true);
});
it("recusa caractere que o WAHA também recusa", () => {
expect(cabeNoWaha("org/slash")).toBe(false);
expect(cabeNoWaha("org.dot")).toBe(false);
});
});
-24
View File
@@ -1,24 +0,0 @@
import { randomUUID } from "node:crypto";
/**
* Teto do nome da sessão no WAHA (`SessionCreateRequest.name`).
*
* Medido no DTO do WAHA 2026.7.2: `@maxLength 54` + `@pattern /^[a-zA-Z0-9_-]*$/`.
* Estourar isso devolve HTTP 400 no POST /api/sessions — e a tela de Conexões
* mostra `connection_repair_required` sem QR. A reserva da 0228/0230 gerava
* `org_` + uuid da org sem hífen (32) + `_` + uuid novo sem hífen (32) = 69.
*/
export const TETO_NOME_WAHA = 54;
const PADRAO_NOME_WAHA = /^[a-zA-Z0-9_-]+$/;
export function cabeNoWaha(nome: string): boolean {
return nome.length <= TETO_NOME_WAHA && PADRAO_NOME_WAHA.test(nome);
}
/** Mesmo formato da 0232 no banco: `org_` + 8 hex da org + `_` + uuid sem hífen = 45. */
export function nomeWahaNovo(orgId: string, uniq = randomUUID()): string {
const org = orgId.replaceAll("-", "").slice(0, 8);
const sufixo = uniq.replaceAll("-", "");
return `org_${org}_${sufixo}`;
}
+191
View File
@@ -24260,6 +24260,197 @@ $$;
-- funcao alcancavel pela anon key, que vai para o browser.
revoke execute on function public.fn_tags_de_conversa_em_uso(uuid) from public, anon;
grant execute on function public.fn_tags_de_conversa_em_uso(uuid) to authenticated, service_role;
-- ---- Índices em FKs de mensagens e runs (migration 0247) ----
create index if not exists idx_messages_contact_id
on public.messages (contact_id)
where contact_id is not null;
create index if not exists idx_messages_channel_session_id
on public.messages (channel_session_id)
where channel_session_id is not null;
create index if not exists idx_ai_agent_runs_contact_id
on public.ai_agent_runs (contact_id)
where contact_id is not null;
create index if not exists idx_ai_agent_runs_channel_session_id
on public.ai_agent_runs (channel_session_id)
where channel_session_id is not null;
create index if not exists idx_ai_agent_runs_conversation_id
on public.ai_agent_runs (conversation_id)
where conversation_id is not null;
create index if not exists idx_ai_agent_runs_inbound_message_id
on public.ai_agent_runs (inbound_message_id)
where inbound_message_id is not null;
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;
-- ---- guarda contra replay do gateway do Supabase (migration 0250) ----
-- O gateway entre o Cloudflare e o PostgREST reexecuta resposta 5xx sem limite.
-- Um `raise ... errcode='40001'` (conflito benigno) vira HTTP 500 no PostgREST;
-- 8 requisições de dois dias antes, reexecutadas ~280×/s cada, ocuparam o pool
-- inteiro, o schema cache não carregou e TODA requisição virou 503 PGRST002 —
-- o produto inteiro em "Algo deu errado" (2026-09-11). Este hook responde 409 a
-- requisição cujo `sb-request-id` (UUIDv7) tem mais de 5 minutos: 4xx não é
-- reexecutado. Idempotente: `create or replace`, grants e `alter role` repetíveis.
create or replace function public.fn_pgrst_recusar_replay_do_gateway()
returns void
language plpgsql
stable
set search_path = ''
as $$
declare
rid text;
aceito_ha interval;
begin
rid := coalesce(nullif(current_setting('request.headers', true), '')::jsonb ->> 'sb-request-id', '');
-- Só UUIDv7 (versão 7 no 3º grupo) carrega instante; qualquer outro formato passa.
if rid !~ '^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-' then
return;
end if;
aceito_ha := now() - to_timestamp((('x' || replace(left(rid, 13), '-', ''))::bit(48)::bigint) / 1000.0);
if aceito_ha > interval '5 minutes' then
raise exception 'gateway_replay'
using errcode = 'PT409',
detail = format('sb-request-id %s foi aceito pelo gateway há %s', rid, aceito_ha),
hint = 'A requisição original já expirou; esta é uma reexecução do gateway de uma resposta 5xx antiga.';
end if;
exception
when sqlstate 'PT409' then
raise;
when others then
-- A guarda nunca derruba uma requisição por defeito próprio (cabeçalho fora do esperado etc.).
return;
end;
$$;
comment on function public.fn_pgrst_recusar_replay_do_gateway() is
'pgrst.db_pre_request: responde 409 a requisição que o gateway do Supabase reexecuta há >5 min (sb-request-id UUIDv7 velho), para não alimentar o loop de retry de 5xx que esgota o pool do PostgREST.';
-- Roda sob o papel da REQUISIÇÃO (anon/authenticated/service_role), então os três
-- precisam de EXECUTE; sem isso a própria guarda vira "permission denied" → 5xx.
-- Não é definer e não lê nada além dos GUCs da requisição: expô-la não amplia nada.
revoke all on function public.fn_pgrst_recusar_replay_do_gateway() from public, anon;
grant execute on function public.fn_pgrst_recusar_replay_do_gateway() to anon, authenticated, service_role;
-- O papel `authenticator` só existe onde há PostgREST (Supabase). No Postgres
-- descartável do `test:db` não existe, e um ALTER ROLE sem guarda derrubaria o
-- install fresco (ON_ERROR_STOP=1).
do $$
begin
if to_regrole('authenticator') is not null then
execute $c$alter role authenticator set pgrst.db_pre_request = 'public.fn_pgrst_recusar_replay_do_gateway'$c$;
end if;
end $$;
notify pgrst, 'reload config';
notify pgrst, 'reload schema';
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
--
@@ -0,0 +1,42 @@
-- Índices em chaves estrangeiras de mensagens e execuções de agentes de IA.
--
-- Motivação (Auditoria de Banco / Supabase Best Practices):
-- Chaves estrangeiras sem índice em tabelas de alto volume geram varreduras
-- sequenciais (sequential scan) inteiras na tabela filha durante deleções ou
-- updates em cascata na tabela pai (ex: exclusão de contatos, encerramento de
-- sessões de canal, rotação ou expurgo de conversas/mensagens via LGPD).
--
-- Além disso, consultas de histórico por contato ou sessão em messages e
-- ai_agent_runs passam a se beneficiar de index scans btree com filtros parciais.
--
-- Idempotente: `if not exists` em cada índice.
-- 1. Tabela messages
create index if not exists idx_messages_contact_id
on public.messages (contact_id)
where contact_id is not null;
create index if not exists idx_messages_channel_session_id
on public.messages (channel_session_id)
where channel_session_id is not null;
-- 2. Tabela ai_agent_runs
create index if not exists idx_ai_agent_runs_contact_id
on public.ai_agent_runs (contact_id)
where contact_id is not null;
create index if not exists idx_ai_agent_runs_channel_session_id
on public.ai_agent_runs (channel_session_id)
where channel_session_id is not null;
create index if not exists idx_ai_agent_runs_conversation_id
on public.ai_agent_runs (conversation_id)
where conversation_id is not null;
create index if not exists idx_ai_agent_runs_inbound_message_id
on public.ai_agent_runs (inbound_message_id)
where inbound_message_id is not null;
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;
@@ -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;

Some files were not shown because too many files have changed in this diff Show More