Merge branch 'main' of https://github.com/IanCouto/DeskcommCRM into develop
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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?
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||