Merge remote-tracking branch 'origin/main' into feat/jev-onda-3

This commit is contained in:
melgarafael
2026-09-26 22:44:54 -03:00
96 changed files with 4704 additions and 1415 deletions
-7
View File
@@ -1,7 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Repetir uma marcação devolve o compromisso já criado
---
Retries de uma mesma operação de agendamento passam a reutilizar o compromisso criado e a resposta registrada, tanto pela API quanto pelas ferramentas MCP e pelo runtime nativo do agente. Operações distintas continuam podendo criar compromissos distintos. Contribuição de @lucasa15.
@@ -1,7 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O aviso de compromisso por webhook traz horário, situação, tipo, local e negócios, e comparecimento e falta viram gatilho
---
Os gatilhos `appointment.*` passam a mandar no corpo o início, o fim, a situação, o tipo, o local, o link da reunião (quando houver) e os negócios ligados (`lead_ids`). Nada muda para quem já integra: as chaves antigas continuam com o mesmo nome e o mesmo tipo. Surgem dois gatilhos novos de regra, `appointment.completed` (compareceu) e `appointment.no_show` (faltou), que disparam uma vez por mudança de situação. A ação de webhook ganha a opção "Incluir o responsável no corpo", que vem desligada. Contribuição de @webtecnica (PR #1709, issue #1612).
-12
View File
@@ -1,12 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O aviso ao cliente e o título na Central saem no idioma da organização quando a IA passa a conversa
---
Quando a IA passava a conversa para a equipe, o cliente recebia o aviso sempre
em português ("Esse caso é melhor resolvido por uma pessoa…"), mesmo numa
organização que atende em espanhol — e o aviso na Central aparecia com o título
em português. Agora os dois saem no idioma da organização: há frases próprias
em espanhol, e os demais idiomas seguem em português, como antes. Se o idioma
da organização não puder ser lido, o aviso sai mesmo assim, em português.
-21
View File
@@ -1,21 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Com "responder em várias mensagens curtas" ligado, cada parágrafo vira uma bolha, na ordem certa
---
A opção do agente "Responder em várias mensagens curtas (como uma pessoa
digita)" dizia ao modelo para preferir várias mensagens a um texto único, e o
modelo mandava duas ou três de uma vez — que podiam chegar ao cliente fora de
ordem (a lista de dados de entrega embaralhada, por exemplo). Agora o agente
escreve uma resposta só e o sistema manda cada parágrafo como uma bolha, na
ordem e no ritmo de quem digita, como a tela já prometia; resposta curta, de
uma ideia só, continua saindo numa bolha só, em vez de virar saudação, resposta
e pergunta em três mensagens. O tamanho máximo por bolha passa a valer só para
o parágrafo que sozinho é longo demais: antes, parágrafos curtos eram juntados
até esse tamanho, e com o padrão quase nenhuma resposta era dividida, enquanto
com um valor baixo o resumo do pedido era cortado no meio de uma linha.
O teto de mensagens por turno (`MAX_SENDS_PER_TURN`, padrão 3) vale também para
as bolhas: o que passar dele segue junto na última, sem perder texto e na ordem.
Contribuição de @jmpo (#1724).
@@ -1,7 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Funis podem exigir campos ao entrar numa etapa ou ao encerrar, e o motivo de ganho vira campo próprio
---
Na tela de funil, cada campo pode ser marcado como exigido ao entrar em etapas escolhidas, ao ganhar ou ao perder. Sem nenhuma marca, nada muda. Quem arrasta um card sem os dados recebe um diálogo que pede só o que falta. Quando o assistente de IA é barrado, aparece um aviso na Central. O motivo de ganho passa a ser um campo próprio do negócio, com lista e exigência opcionais por funil, e sai no webhook. Contribuição de @webtecnica (PR #1688, issue #1536).
-15
View File
@@ -1,15 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O candidato ao golden set sai do disco e vira linha sem texto de cliente
---
Os candidatos de curadoria que o matcher de skills e o classificador de etapa gravavam em
`lib/agent-engine/golden-candidates/` deixam de existir como arquivo: agora são linhas em
`golden_candidates`, com o rótulo (skill + motivo, ou os dois estágios da divergência) e os
ponteiros do lead e do job — sem texto de cliente. Em desenvolvimento a pasta ficava dentro
do repositório, e em produção o JSON ia para o disco do contêiner, onde nenhuma tela lia,
se perdia a cada atualização de imagem e ficava fora da cascata de anonimização. A linha
nova é alcançada pela retenção (`fn_expurgar_candidatos_do_golden`, 90 dias, piso 30, no
cron `data-retention`); quem quiser ler a conversa abre a ficha pelo ponteiro. Nada muda na
operação de quem já roda o sistema.
@@ -1,12 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Candidato a golden set não grava o texto do cliente como ele chegou
---
Os arquivos de curadoria que o matcher de skills e o classificador de etapa gravam em
`lib/agent-engine/golden-candidates/` levavam a mensagem do cliente como ela chegou — CPF,
telefone e e-mail junto. A mensagem agora passa pelo mesmo redator da telemetria antes de
tocar o disco, e a pasta saiu do git: as duas portas por onde um `git add -A` publicava
conversa de cliente. Os candidatos que já estavam versionados foram removidos. Nada muda na
operação de quem já roda o sistema.
@@ -1,12 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Mudar a etapa do negócio pela conversa, sem abrir o quadro do funil
---
O painel da conversa ganhou o seletor "Etapa do funil" no bloco "Leads
recentes": quando o cliente confirma pelo WhatsApp, quem atende passa o negócio
para a etapa seguinte (por exemplo, "Pedido confirmado") sem sair da conversa.
É o mesmo caminho do "Mover para…" do quadro, então a atividade, a auditoria e
o aviso da etapa na Central saem iguais. Etapa de perda continua pelo quadro,
onde se informa o motivo.
-22
View File
@@ -1,22 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O follow-up manda modelo aprovado do WhatsApp e respeita o retorno combinado
---
O passo de mensagem pronta de um follow-up passa a oferecer, além dos textos de
Ajustes → Modelos, os modelos aprovados no WhatsApp — no canal oficial, o único
envio que chega ao cliente depois de 24 horas sem resposta. Antes, um passo apontado para um
modelo aprovado era publicado sem erro e falhava no primeiro disparo. A
mensagem escrita pela IA também passa a usar o modelo aprovado escolhido como
plano B quando a janela de 24 horas já fechou; até aqui esse campo era salvo e
nunca usado. Esse plano B só é exigido na publicação de quem tem conexão com
janela de 24 horas: quem conecta só por um canal sem janela segue publicando
sem ele.
E quando o assistente combina com o cliente um retorno numa data ("te escrevo
no dia 30"), o follow-up de silêncio não escreve por cima: o contato não entra
no fluxo enquanto o retorno está agendado, e quem já estava nele fica em espera
até um dia depois do retorno. O detalhe do follow-up mostra o motivo.
Contribuição de @jmpo (#1729).
-14
View File
@@ -1,14 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Quando a conta de IA fica sem saldo, as respostas esperam a recarga em vez de se perder
---
Quando a conta do provedor de IA fica sem crédito, as respostas aos clientes
não são mais descartadas em dois minutos: ficam esperando e saem sozinhas
assim que o saldo é recarregado, por até 6 horas. Nesse intervalo aparece um
único aviso na Central dizendo que a IA está sem saldo e o que fazer; ele se
fecha sozinho quando a primeira resposta sai. Se alguém da equipe respondeu o
cliente enquanto a IA esperava, ela não repete a resposta. Na tela de
Execuções, essa recusa passa a aparecer como limite de uso ou saldo, e não
como erro desconhecido.
@@ -1,17 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Os modelos do provedor intermediado se editam e se apagam pela tela, com prévia como no WhatsApp
---
Na aba de modelos do provedor intermediado, abrir um modelo mostra a prévia de
como ele chega ao cliente: o balão com o texto e os botões embaixo, cada um com
o ícone do tipo. Dois botões novos: **Editar**, que abre o formulário já
preenchido com o texto aprovado e a prévia ao lado (nome, idioma e categoria não
mudam, porque a plataforma não deixa), e **Apagar**, que pede confirmação. Se o
modelo estiver em uso num follow-up ou no prompt de um agente, o apagar mostra
onde antes de confirmar. Editar manda o modelo de novo para a revisão da
plataforma, e a Meta limita quantas vezes um modelo aprovado pode ser editado;
um nome apagado só pode ser reusado depois de 30 dias.
Contribuição de @jmpo (#1728).
@@ -1,13 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Motivos de perda com categoria, filtro por motivo e relatório de perdas
---
Em **Configurações › Funis**, cada motivo de perda ganha uma categoria (Cliente, Concorrência, Mérito, Nós, Ausência). No quadro, com a aba **Perdidos**, aparecem os filtros **Motivo** e **Categoria**, que viram link (`?motivo=`, `?categoria=`). Em **Métricas**, quem é gerente ou admin vê o relatório **Perdas**: por motivo, por categoria e pela etapa de onde o negócio saiu, com o valor separado por moeda (moedas nunca são somadas). Transferência entre funis não conta como perda. A tool MCP `crm_list_leads` aceita `lost_reason` e `lost_reason_category`.
A coluna nova `crm_leads.lost_from_stage_id` passa a ser gravada a partir desta versão. Perdas anteriores aparecem como "Etapa desconhecida", porque não há como saber a etapa delas sem inventar.
Não há ação para quem opera a VPS: funis com motivos só de texto continuam funcionando como antes e nenhum dado existente é reescrito.
Contribuição de @webtecnica (#1715).
@@ -1,11 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Mover um negócio para a etapa em que ele já está não é mais barrado por campos obrigatórios
---
Em funil que exige campos para entrar numa etapa, mover um negócio para a etapa em que ele
JÁ está — pelo assistente de IA ou por uma automação — era recusado com a frase dos campos
obrigatórios, mesmo sem nada mudar de etapa: o que muda ali é a posição dentro da coluna. A
tela já tratava esse gesto como reordenação; agora o caminho do MCP e o das automações
tratam igual. A mudança de etapa de verdade continua exigindo os campos.
@@ -0,0 +1,15 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A poda do histórico de captação passa a ordenar o lote e a dizer quando falha
---
A retenção do histórico de leads captados passou a apagar em lotes **ordenados**
(`id` ascendente, a mesma coluna e a mesma direção da poda de rascunhos), e a
falha do banco deixou de ser engolida: ela sobe, responde 500, grava a linha
`falhou` na trilha e chega ao Sentry — em vez de virar um "não havia nada
vencido" que não era verdade.
Sem ação para quem opera: as duas tabelas e os dois horizontes são os mesmos. O
efeito é que a poda deixa de poder escolher um subconjunto arbitrário a cada
lote, e uma instalação em que o banco recusa o DELETE passa a ser vista.
@@ -1,13 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A previsão em Métricas mostra o valor certo em moeda sem centavos
---
No painel "Previsão" de Métricas, o valor ponderado e o bruto de cada mês — e os
dos negócios sem data ou sem chance definida — apareciam cem vezes maiores em
moeda sem centavos, como o guarani (₲125.000 saía "Gs. 12.500.000"). Agora o
painel escreve o valor do mesmo jeito que o quadro do funil ("Gs. 125.000").
Em real, dólar e demais moedas com centavos nada muda.
Diagnóstico de @jmpo (#1727).
@@ -1,13 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Chance de fechamento por etapa e previsão ponderada do funil
---
Em **Configurações › Funis**, cada etapa aberta ganha o campo **Chance de fechamento (0 a 100)**, calibrado por quem gere a equipe. Etapas de ganho e de perda valem 100 e 0 automaticamente. No quadro, cada coluna mostra o valor **ponderado** abaixo do total. Em **Métricas**, o painel **Previsão** mostra o valor bruto e o ponderado por mês de fechamento previsto e por moeda (moedas nunca são somadas); negócios sem data prevista e em etapa sem chance configurada aparecem à parte, em vez de sumirem como zero. A previsão respeita o que cada atendente pode ver. A API ganha `GET /api/v1/pipelines/{id}/forecast` e a tool MCP `crm_get_pipeline_forecast`; `crm_update_stage` e `crm_list_stages` passam a aceitar e devolver `win_probability`.
A coluna nova `crm_stages.win_probability` nasce vazia em todas as etapas: nada muda até alguém configurar a chance.
Não há ação para quem opera a VPS.
Contribuição de @webtecnica (#1716, issue #1535).
-19
View File
@@ -1,19 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O quadro do funil cabe na tela, e o total da etapa em moeda sem centavos soma certo
---
Com uma etapa cheia de negócios, a barra para andar para o lado só aparecia no
fim da coluna mais comprida, e o nome da etapa sumia do alto no caminho. Agora o
quadro ocupa a altura da tela: a barra lateral fica sempre à vista no pé, e o
nome e o total de cada etapa ficam presos em cima enquanto os cards rolam.
O total no topo de cada etapa aparecia cem vezes maior em moeda sem centavos,
como o guarani (dois pedidos de ₲125.000 somavam "Gs. 25.000.000"). Ele passa a
somar certo, e o card, o total e o detalhe do negócio escrevem o valor do mesmo
jeito, na convenção da moeda ("Gs. 125.000").
A linha "ponderado" das etapas com chance calibrada passa a somar do mesmo
jeito que o total, sem sair cem vezes maior nessas moedas.
Contribuição de @jmpo (#1727).
@@ -1,7 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O rascunho sugerido por integração passa a ser apagado 30 dias depois de vencer
---
O rascunho que outro sistema cria na conversa, o texto sugerido para revisar antes de enviar, guarda uma mensagem escrita para uma pessoa. Depois de vencido ele não abre nem pode ser usado, mas ficava guardado para sempre. Agora a limpeza diária (`data-retention`) apaga o rascunho 30 dias depois do vencimento (mínimo de 7), usado ou não. O que foi enviado continua na conversa, e a criação e o uso continuam na auditoria. Nada a fazer na VPS; o prazo muda com `DRAFT_RETENTION_DAYS` no `.env`. Contribuição de @webtecnica (#1719).
-30
View File
@@ -1,30 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A retenção de mídia passa a ser cumprida — arquivos vencidos e órfãos saem do armazenamento
---
A configuração «retenção de mídia» da organização existia no formulário e não
era cumprida por nada: todo áudio, foto, vídeo e PDF do WhatsApp ficava no
armazenamento para sempre, inclusive os de conversas já apagadas. Numa
instalação no Supabase gratuito isso chega ao limite de 1 GB, e o Supabase
restringe o projeto inteiro — login, mensagens e agente param juntos.
Agora, uma vez por dia, o CRM separa para remoção:
- o arquivo de mensagem mais antigo que a retenção da organização (mínimo 30
dias). A mensagem continua na conversa, com texto e horário; o arquivo aparece
como «Mídia indisponível». Se outra mensagem mais recente ainda usa o mesmo
arquivo (a foto de catálogo reenviada, por exemplo), ele fica;
- o arquivo que nenhuma mensagem ou contato usa mais (o rastro de conversa
apagada), depois de um dia de carência.
As imagens de cabeçalho de modelo nunca são tocadas. A remoção sai pela mesma
fila da anonimização da LGPD, com reintento. Quem precisa guardar mídia por mais
tempo aumenta a retenção em Configurações — o padrão segue 365 dias.
Na primeira rodada depois de atualizar, sai de uma vez o que já passou da
retenção de cada empresa; com o padrão de 365 dias, hoje isso só alcança
empresas que configuraram uma retenção menor.
Contribuição de @jmpo (#1731).
-13
View File
@@ -1,13 +0,0 @@
---
titulo: "Retomada de negócio perdido como novo negócio, por funil"
impacto: capacidade_nova
secao: adicionado
---
**Retomada de negócio perdido como novo negócio, escolhida por funil.** O funil ganha `settings.reabertura` com dois modos: `mesmo_registro` (padrão, o comportamento de sempre) e `novo_negocio`. No segundo, mover um negócio encerrado para uma etapa aberta não o reabre — o arrasto, o lote, a IA, a automação e a tool MCP devolvem 409 `reabertura_cria_novo`, e a tela oferece "Retomar como novo negócio", que chama `POST /api/v1/leads/{id}/retomar`: nasce um lead novo com o mesmo contato, campos e tags copiados (o que se copia é configurável em `reabertura_campos`), `source = "retomada"` e `retomado_de_lead_id` apontando para o encerrado, que fica intacto, com o motivo dele. É por essa coluna que "quantas tentativas até fechar" passa a ser derivável. O clone entre funis também aceita origem encerrada nesse modo.
Liga-se em **Configurações › Funis**, na caixa "Negócio encerrado que volta abre um negócio novo". Retomar duas vezes a mesma origem devolve a retomada que já está aberta, e a etapa em que ela nasce aplica os campos obrigatórios do funil.
Não há ação para quem opera a VPS: a opção nasce desligada e nenhum dado existente é reescrito.
Contribuição de @webtecnica (#1712).
@@ -1,13 +0,0 @@
---
titulo: "O seletor de modelo do atendente não oferece mais modelo de busca, e o fim do onboarding só diz que o atendente está no ar quando ele está"
impacto: nada_mudou
secao: corrigido
---
**Quatro correções de tela achadas numa jornada real de dono de clínica.** A lista de modelos do atendente (IA › Agentes › Modelo) deixa de oferecer o modelo de busca do material (Text Embedding), que não conversa: agora só aparecem modelos que usam as ferramentas do CRM, a mesma regra que o sistema já usava para escolher o modelo sozinho. Um agente novo passa a nascer no provedor de IA que a organização já usa, em vez de sempre em Anthropic.
O diálogo de publicar uma versão fala português: diz a empresa pelo nome, conta os caracteres a mais ou a menos do prompt e explica que a versão anterior continua no histórico; na primeira publicação, diz que é a primeira. E a última página do onboarding pergunta ao banco se há atendente publicado: quem pulou o passo da IA ou deixou o atendente em rascunho vê "Quase lá!" e o que falta, em vez de "Tudo pronto! Seu funcionário já está de pé".
Não há ação para quem opera a VPS: nenhum dado é reescrito.
Contribuição de @webtecnica (#1718, #1694).
-20
View File
@@ -1,20 +0,0 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A transcrição de áudio aceita idioma declarado e modelo melhor sem copiar a chave
---
Quem atende em espanhol ou português pode declarar o idioma dos áudios em
`TRANSCRIPTION_LANGUAGES` (por exemplo `es`) e trocar o modelo em
`TRANSCRIPTION_MODEL` (por exemplo `gpt-transcribe`) usando a mesma chave da
OpenAI já cadastrada na organização — antes, trocar o modelo exigia copiar a
chave para o `.env`. O motivo é medido: com o padrão, um áudio sem fala virava
"Thanks for watching!" e "ya es caro" virava "ya es claro", e o assistente
respondia ao que leu; com o idioma declarado e `gpt-transcribe`, os dois saem
certos e o áudio sem fala sai vazio. A tela de Provedores passa a mostrar o
modelo de transcrição que está em uso. Sem essas variáveis, nada muda. Sem a
chave própria, `TRANSCRIPTION_MODEL` só vale com `TRANSCRIPTION_BASE_URL` vazio:
quem já tinha o modelo de outro serviço (Groq, por exemplo) no `.env` segue com
`whisper-1` na OpenAI, como antes.
Contribuição de @jmpo (#1723).
+3 -2
View File
@@ -22,7 +22,7 @@ Stack canônica (major; a versão exata é o `package.json`):
Next.js 16 (App Router, Turbopack) · React 19 · TypeScript 6 estrito · Tailwind 4 (config em CSS) ·
shadcn/ui (`new-york`) · Supabase (Postgres + Auth + Realtime + Storage) · Zod 4 · Vitest 4 ·
Playwright 1 · Sentry 10 · WAHA 2026.7.2 (engine NOWEB) · Upstash Redis · Vercel AI Gateway
Playwright 1 · Sentry 11 · WAHA 2026.7.2 (engine NOWEB) · Upstash Redis · Vercel AI Gateway
(`@ai-sdk/anthropic|openai|google`).
> As majors acima são verificadas contra o `package.json` por
@@ -304,7 +304,8 @@ server; segredo em query string; `throw` cru na borda da API.
`react-hooks`, `typescript-eslint`. `next lint` foi removido no Next 16 — o script chama o CLI.
- **Prettier** com `prettier-plugin-tailwindcss`; classes Tailwind em ordem canônica.
- **Tailwind 4** — configuração em CSS (`app/globals.css`), não em `tailwind.config.js`.
- **Sentry** — `beforeSend` higieniza PII; `tunnelRoute: "/monitoring"` evita ad-blocker.
- **Sentry** — coleta restrita (`dataCollection`) + scrub num ponto só, `lib/sentry/privacidade.ts`,
provado pelo envelope do SDK em `privacidade.sdk.test.ts`; `tunnelRoute: "/monitoring"` evita ad-blocker.
- **Packaging (não-negociável; lei em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md))** —
nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente: todo serviço declara
`image:` de imagem publicada, e `build:` existe só ao lado, como escape. Serviço `build:`-only é
+311 -1
View File
@@ -8,6 +8,314 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es
## [Não lançado]
## [1.54.0] — 2026-09-27
### Adicionado
- **Aba Graph (Datafy): editar e apagar um modelo sem levar as outras traduções** Na aba **Modelos** do canal Graph (Datafy), os botões **Editar** e **Apagar**
voltam a aparecer. Eles ficavam desligados porque apagar um modelo por nome
removia todas as traduções de uma vez, enquanto a tela mostrava um só idioma.
Agora a operação identifica a variante escolhida (nome + idioma) antes de falar
com a plataforma: apagar tira só a tradução selecionada, e editar manda o
conteúdo para a variante certa.
Apagar continua perguntando antes, mostrando onde o modelo está em uso
(follow-up ou prompt de agente), e só confirma com a sua confirmação. Se a
plataforma não devolver a variante, nada é apagado no escuro — a operação
recusa com o motivo.
Contribuição de @webtecnica (#1761, issue #1734).
- **A ferramenta de agenda lê um período inteiro, no fuso da empresa e em páginas** A ferramenta `crm_list_appointments` passa a aceitar `de`/`ate` (até 62 dias, a agenda inteira da organização) e paginação por `depois_de`/`proximo`, e cada compromisso traz o nome do contato e do atendente, o tipo, o local e os negócios vinculados — as chaves `contato_id`/`atendente_id` continuam na resposta. O filtro por `dia` passa a contar o dia no fuso da organização. A listagem da agenda pela API recusa com 422 um período acima de 62 dias.
Não há ação para quem opera a VPS.
Contribuição de @webtecnica (#1762).
- **O dono pode impedir que um agente marque novos retornos sem desligar os acompanhamentos configurados** Cada agente passa a ter um controle separado para permitir ou impedir novos retornos prometidos por conta própria. Os acompanhamentos configurados, a consulta e o cancelamento de retornos existentes e os agendamentos de compromisso continuam disponíveis. Agentes existentes mantêm o comportamento atual. Contribuição de @lucasa15 (#1764).
- **Nome da etapa editável direto no cabeçalho do quadro** Em **`/app/pipelines/:id`**, quem é `manager` ou `admin` agora renomeia a etapa clicando no próprio cabeçalho da coluna — sem precisar ir a Configurações › Funis. Salva ao confirmar (Enter ou saindo do campo), nunca a cada tecla, pela mesma rota que a tela de Configurações já usa. Para `viewer`/`agent`, que também abrem este quadro, o cabeçalho continua só leitura.
Não há ação para quem opera a VPS.
Contribuição de @lmarceloc (#1738).
- **Dá para trocar entre tema claro e escuro dentro do Modo Plataforma** O Modo Plataforma — a área de administração da instalação, que enxerga todas as
organizações — não tinha como trocar o tema. Não era só o botão que faltava: o
atalho de teclado também vive dentro desse botão, então quem estava ali não
tinha caminho nenhum. Para mudar de claro para escuro era preciso sair para o
app pessoal, trocar lá e voltar, e nada na tela dizia isso.
Quem mais sentia é quem acabou de instalar: a instalação cria o dono como
administrador da plataforma, então o Modo Plataforma costuma ser a primeira
tela de uma VPS nova.
Agora o controle fica na própria tarja amarela do topo, ao lado do "Sair pra app
pessoal". Ele cicla entre claro, escuro e o que o sistema operacional estiver
usando, e o atalho `Ctrl + Shift + L` funciona ali também. A escolha continua
valendo nas duas áreas, como sempre valeu.
A tarja também passou a acompanhar o tema. Antes ela era uma faixa clara fixa,
que não mudava de cor — no tema escuro ficava gritando no topo da tela.
Crédito: @Draven9
Contribuição de @Draven9 (#1757, trazida no #1759).
### Alterado
- **Telemetria atualizada para o Sentry 11, com a coleta de dados pessoais travada no mínimo** O componente que envia relatórios de erro foi atualizado para a versão 11 do
Sentry. A versão nova passaria a coletar, por padrão, IP, cookies, corpo das
requisições e o texto trocado com a IA. Aqui essa coleta continua desligada, de
forma explícita, e a limpeza de dados pessoais (e-mail, CPF, telefone, IP,
tokens de webhook e de convite) agora é conferida no pacote que de fato sai do
servidor. Quem usa o padrão (Sentry da comunidade) ou desligou a telemetria
(`SENTRY_DSN=off`) não precisa fazer nada.
Se você aponta `SENTRY_DSN` para o seu próprio Sentry, o rastreamento de
desempenho passa a ser enviado em fluxo contínuo, sem o antigo limite de 1.000
trechos por requisição. Alguns atributos mudaram de nome (por exemplo,
`http.method` virou `http.request.method` e `db.statement` virou
`db.query.text`), então alertas e painéis que filtram pelos nomes antigos
precisam ser revistos. Se o seu Sentry é auto-hospedado, o SDK novo só dá
suporte à versão 26.4.2 ou mais nova. Os relatórios de erro continuam chegando
como antes.
### Corrigido
- **Mídia já removida pode ser enfileirada de novo e a fila deixa de crescer sem teto** A fila de remoção de mídia guarda cada arquivo por `object_path` e a 0432 pedia
`on conflict (bucket, object_path) do nothing`. Como o worker marca a linha
como `deleted` e a linha nunca sai da fila, um arquivo NOVO gravado naquele
mesmo caminho era ignorado em silêncio: não entrava mais na retenção nem na
anonimização da LGPD, e nenhuma das duas conseguia alcançá-lo depois.
Agora o conflito reabre a linha só quando ela já terminou — `deleted` ou
`skipped` volta a `pending` com as tentativas zeradas — e não toca em `pending`
nem `failed` em curso, que é justamente o `where` que garante isso. O cron
diário de retenção passa também a expurgar a linha `deleted` da retenção com
mais de 90 dias, para a fila deixar de crescer sem teto; a linha ligada a um
pedido LGPD permanece, porque é o registro de que a mídia do titular foi
removida. Nada a fazer para quem já roda o sistema.
Contribuição de @webtecnica (#1763).
- **O servidor do app segura a conexão ociosa por mais tempo que o proxy na frente** O app fechava a conexão ociosa com o proxy (Caddy ou Traefik) aos 6 segundos, enquanto o proxy
a guardava por até 2 minutos para reaproveitar. Quando a próxima requisição saía no instante
em que o app fechava, ela morria no meio e a pessoa via um erro 502 raro e sem explicação —
um salvamento podia falhar e dar certo ao tentar de novo. Agora o app segura a conexão por
125 segundos, e quem fecha primeiro é sempre o proxy. Nada a fazer: vale ao atualizar.
## [1.53.0] — 2026-09-26
### Adicionado
- **O aviso de compromisso por webhook traz horário, situação, tipo, local e negócios, e comparecimento e falta viram gatilho** Os gatilhos `appointment.*` passam a mandar no corpo o início, o fim, a situação, o tipo, o local, o link da reunião (quando houver) e os negócios ligados (`lead_ids`). Nada muda para quem já integra: as chaves antigas continuam com o mesmo nome e o mesmo tipo. Surgem dois gatilhos novos de regra, `appointment.completed` (compareceu) e `appointment.no_show` (faltou), que disparam uma vez por mudança de situação. A ação de webhook ganha a opção "Incluir o responsável no corpo", que vem desligada. Contribuição de @webtecnica (PR #1709, issue #1612).
- **Funis podem exigir campos ao entrar numa etapa ou ao encerrar, e o motivo de ganho vira campo próprio** Na tela de funil, cada campo pode ser marcado como exigido ao entrar em etapas escolhidas, ao ganhar ou ao perder. Sem nenhuma marca, nada muda. Quem arrasta um card sem os dados recebe um diálogo que pede só o que falta. Quando o assistente de IA é barrado, aparece um aviso na Central. O motivo de ganho passa a ser um campo próprio do negócio, com lista e exigência opcionais por funil, e sai no webhook. Contribuição de @webtecnica (PR #1688, issue #1536).
- **Mudar a etapa do negócio pela conversa, sem abrir o quadro do funil** O painel da conversa ganhou o seletor "Etapa do funil" no bloco "Leads
recentes": quando o cliente confirma pelo WhatsApp, quem atende passa o negócio
para a etapa seguinte (por exemplo, "Pedido confirmado") sem sair da conversa.
É o mesmo caminho do "Mover para…" do quadro, então a atividade, a auditoria e
o aviso da etapa na Central saem iguais. Etapa de perda continua pelo quadro,
onde se informa o motivo.
Contribuição de @jmpo (#1726).
- **O follow-up manda modelo aprovado do WhatsApp e respeita o retorno combinado** O passo de mensagem pronta de um follow-up passa a oferecer, além dos textos de
Ajustes → Modelos, os modelos aprovados no WhatsApp — no canal oficial, o único
envio que chega ao cliente depois de 24 horas sem resposta. Antes, um passo apontado para um
modelo aprovado era publicado sem erro e falhava no primeiro disparo. A
mensagem escrita pela IA também passa a usar o modelo aprovado escolhido como
plano B quando a janela de 24 horas já fechou; até aqui esse campo era salvo e
nunca usado. Esse plano B só é exigido na publicação de quem tem conexão com
janela de 24 horas: quem conecta só por um canal sem janela segue publicando
sem ele.
E quando o assistente combina com o cliente um retorno numa data ("te escrevo
no dia 30"), o follow-up de silêncio não escreve por cima: o contato não entra
no fluxo enquanto o retorno está agendado, e quem já estava nele fica em espera
até um dia depois do retorno. O detalhe do follow-up mostra o motivo.
Contribuição de @jmpo (#1729).
- **Os modelos do provedor intermediado se editam e se apagam pela tela, com prévia como no WhatsApp** Na aba de modelos do provedor intermediado, abrir um modelo mostra a prévia de
como ele chega ao cliente: o balão com o texto e os botões embaixo, cada um com
o ícone do tipo. Dois botões novos: **Editar**, que abre o formulário já
preenchido com o texto aprovado e a prévia ao lado (nome, idioma e categoria não
mudam, porque a plataforma não deixa), e **Apagar**, que pede confirmação. Se o
modelo estiver em uso num follow-up ou no prompt de um agente, o apagar mostra
onde antes de confirmar. Editar manda o modelo de novo para a revisão da
plataforma, e a Meta limita quantas vezes um modelo aprovado pode ser editado;
um nome apagado só pode ser reusado depois de 30 dias.
Contribuição de @jmpo (#1728).
- **Motivos de perda com categoria, filtro por motivo e relatório de perdas** Em **Configurações › Funis**, cada motivo de perda ganha uma categoria (Cliente, Concorrência, Mérito, Nós, Ausência). No quadro, com a aba **Perdidos**, aparecem os filtros **Motivo** e **Categoria**, que viram link (`?motivo=`, `?categoria=`). Em **Métricas**, quem é gerente ou admin vê o relatório **Perdas**: por motivo, por categoria e pela etapa de onde o negócio saiu, com o valor separado por moeda (moedas nunca são somadas). Transferência entre funis não conta como perda. A tool MCP `crm_list_leads` aceita `lost_reason` e `lost_reason_category`.
A coluna nova `crm_leads.lost_from_stage_id` passa a ser gravada a partir desta versão. Perdas anteriores aparecem como "Etapa desconhecida", porque não há como saber a etapa delas sem inventar.
Não há ação para quem opera a VPS: funis com motivos só de texto continuam funcionando como antes e nenhum dado existente é reescrito.
Contribuição de @webtecnica (#1715).
- **Chance de fechamento por etapa e previsão ponderada do funil** Em **Configurações › Funis**, cada etapa aberta ganha o campo **Chance de fechamento (0 a 100)**, calibrado por quem gere a equipe. Etapas de ganho e de perda valem 100 e 0 automaticamente. No quadro, cada coluna mostra o valor **ponderado** abaixo do total. Em **Métricas**, o painel **Previsão** mostra o valor bruto e o ponderado por mês de fechamento previsto e por moeda (moedas nunca são somadas); negócios sem data prevista e em etapa sem chance configurada aparecem à parte, em vez de sumirem como zero. A previsão respeita o que cada atendente pode ver. A API ganha `GET /api/v1/pipelines/{id}/forecast` e a tool MCP `crm_get_pipeline_forecast`; `crm_update_stage` e `crm_list_stages` passam a aceitar e devolver `win_probability`.
A coluna nova `crm_stages.win_probability` nasce vazia em todas as etapas: nada muda até alguém configurar a chance.
Não há ação para quem opera a VPS.
Contribuição de @webtecnica (#1716, issue #1535).
- **O rascunho sugerido por integração passa a ser apagado 30 dias depois de vencer** O rascunho que outro sistema cria na conversa, o texto sugerido para revisar antes de enviar, guarda uma mensagem escrita para uma pessoa. Depois de vencido ele não abre nem pode ser usado, mas ficava guardado para sempre. Agora a limpeza diária (`data-retention`) apaga o rascunho 30 dias depois do vencimento (mínimo de 7), usado ou não. O que foi enviado continua na conversa, e a criação e o uso continuam na auditoria. Nada a fazer na VPS; o prazo muda com `DRAFT_RETENTION_DAYS` no `.env`. Contribuição de @webtecnica (#1719).
- **A retenção de mídia passa a ser cumprida — arquivos vencidos e órfãos saem do armazenamento** A configuração «retenção de mídia» da organização existia no formulário e não
era cumprida por nada: todo áudio, foto, vídeo e PDF do WhatsApp ficava no
armazenamento para sempre, inclusive os de conversas já apagadas. Numa
instalação no Supabase gratuito isso chega ao limite de 1 GB, e o Supabase
restringe o projeto inteiro — login, mensagens e agente param juntos.
Agora, uma vez por dia, o CRM separa para remoção:
- o arquivo de mensagem mais antigo que a retenção da organização (mínimo 30
dias). A mensagem continua na conversa, com texto e horário; o arquivo aparece
como «Mídia indisponível». Se outra mensagem mais recente ainda usa o mesmo
arquivo (a foto de catálogo reenviada, por exemplo), ele fica;
- o arquivo que nenhuma mensagem ou contato usa mais (o rastro de conversa
apagada), depois de um dia de carência.
As imagens de cabeçalho de modelo nunca são tocadas. A remoção sai pela mesma
fila da anonimização da LGPD, com reintento. Quem precisa guardar mídia por mais
tempo aumenta a retenção em Configurações — o padrão segue 365 dias.
Na primeira rodada depois de atualizar, sai de uma vez o que já passou da
retenção de cada empresa; com o padrão de 365 dias, hoje isso só alcança
empresas que configuraram uma retenção menor.
Contribuição de @jmpo (#1731).
- **Retomada de negócio perdido como novo negócio, por funil** **Retomada de negócio perdido como novo negócio, escolhida por funil.** O funil ganha `settings.reabertura` com dois modos: `mesmo_registro` (padrão, o comportamento de sempre) e `novo_negocio`. No segundo, mover um negócio encerrado para uma etapa aberta não o reabre — o arrasto, o lote, a IA, a automação e a tool MCP devolvem 409 `reabertura_cria_novo`, e a tela oferece "Retomar como novo negócio", que chama `POST /api/v1/leads/{id}/retomar`: nasce um lead novo com o mesmo contato, campos e tags copiados (o que se copia é configurável em `reabertura_campos`), `source = "retomada"` e `retomado_de_lead_id` apontando para o encerrado, que fica intacto, com o motivo dele. É por essa coluna que "quantas tentativas até fechar" passa a ser derivável. O clone entre funis também aceita origem encerrada nesse modo.
Liga-se em **Configurações › Funis**, na caixa "Negócio encerrado que volta abre um negócio novo". Retomar duas vezes a mesma origem devolve a retomada que já está aberta, e a etapa em que ela nasce aplica os campos obrigatórios do funil.
Não há ação para quem opera a VPS: a opção nasce desligada e nenhum dado existente é reescrito.
Contribuição de @webtecnica (#1712).
- **A transcrição de áudio aceita idioma declarado e modelo melhor sem copiar a chave** Quem atende em espanhol ou português pode declarar o idioma dos áudios em
`TRANSCRIPTION_LANGUAGES` (por exemplo `es`) e trocar o modelo em
`TRANSCRIPTION_MODEL` (por exemplo `gpt-transcribe`) usando a mesma chave da
OpenAI já cadastrada na organização — antes, trocar o modelo exigia copiar a
chave para o `.env`. O motivo é medido: com o padrão, um áudio sem fala virava
"Thanks for watching!" e "ya es caro" virava "ya es claro", e o assistente
respondia ao que leu; com o idioma declarado e `gpt-transcribe`, os dois saem
certos e o áudio sem fala sai vazio. A tela de Provedores passa a mostrar o
modelo de transcrição que está em uso. Sem essas variáveis, nada muda. Sem a
chave própria, `TRANSCRIPTION_MODEL` só vale com `TRANSCRIPTION_BASE_URL` vazio:
quem já tinha o modelo de outro serviço (Groq, por exemplo) no `.env` segue com
`whisper-1` na OpenAI, como antes.
Contribuição de @jmpo (#1723).
### Corrigido
- **Repetir uma marcação devolve o compromisso já criado** Retries de uma mesma operação de agendamento passam a reutilizar o compromisso criado e a resposta registrada, tanto pela API quanto pelas ferramentas MCP e pelo runtime nativo do agente. Operações distintas continuam podendo criar compromissos distintos. Contribuição de @lucasa15 (#1735).
- **O aviso ao cliente e o título na Central saem no idioma da organização quando a IA passa a conversa** Quando a IA passava a conversa para a equipe, o cliente recebia o aviso sempre
em português ("Esse caso é melhor resolvido por uma pessoa…"), mesmo numa
organização que atende em espanhol — e o aviso na Central aparecia com o título
em português. Agora os dois saem no idioma da organização: há frases próprias
em espanhol, e os demais idiomas seguem em português, como antes. Se o idioma
da organização não puder ser lido, o aviso sai mesmo assim, em português.
Contribuição de @jmpo (#1725).
- **Com "responder em várias mensagens curtas" ligado, cada parágrafo vira uma bolha, na ordem certa** A opção do agente "Responder em várias mensagens curtas (como uma pessoa
digita)" dizia ao modelo para preferir várias mensagens a um texto único, e o
modelo mandava duas ou três de uma vez — que podiam chegar ao cliente fora de
ordem (a lista de dados de entrega embaralhada, por exemplo). Agora o agente
escreve uma resposta só e o sistema manda cada parágrafo como uma bolha, na
ordem e no ritmo de quem digita, como a tela já prometia; resposta curta, de
uma ideia só, continua saindo numa bolha só, em vez de virar saudação, resposta
e pergunta em três mensagens. O tamanho máximo por bolha passa a valer só para
o parágrafo que sozinho é longo demais: antes, parágrafos curtos eram juntados
até esse tamanho, e com o padrão quase nenhuma resposta era dividida, enquanto
com um valor baixo o resumo do pedido era cortado no meio de uma linha.
O teto de mensagens por turno (`MAX_SENDS_PER_TURN`, padrão 3) vale também para
as bolhas: o que passar dele segue junto na última, sem perder texto e na ordem.
Contribuição de @jmpo (#1724).
- **O candidato ao golden set sai do disco e vira linha sem texto de cliente** Os candidatos de curadoria que o matcher de skills e o classificador de etapa gravavam em
`lib/agent-engine/golden-candidates/` deixam de existir como arquivo: agora são linhas em
`golden_candidates`, com o rótulo (skill + motivo, ou os dois estágios da divergência) e os
ponteiros do lead e do job — sem texto de cliente. Em desenvolvimento a pasta ficava dentro
do repositório, e em produção o JSON ia para o disco do contêiner, onde nenhuma tela lia,
se perdia a cada atualização de imagem e ficava fora da cascata de anonimização. A linha
nova é alcançada pela retenção (`fn_expurgar_candidatos_do_golden`, 90 dias, piso 30, no
cron `data-retention`); quem quiser ler a conversa abre a ficha pelo ponteiro. Nada muda na
operação de quem já roda o sistema.
Contribuição de @webtecnica (#1720, issue #1695).
- **Candidato a golden set não grava o texto do cliente como ele chegou** Os arquivos de curadoria que o matcher de skills e o classificador de etapa gravam em
`lib/agent-engine/golden-candidates/` levavam a mensagem do cliente como ela chegou — CPF,
telefone e e-mail junto. A mensagem agora passa pelo mesmo redator da telemetria antes de
tocar o disco, e a pasta saiu do git: as duas portas por onde um `git add -A` publicava
conversa de cliente. Os candidatos que já estavam versionados foram removidos. Nada muda na
operação de quem já roda o sistema.
Contribuição de @hiro-nikaitou (#1708).
- **Quando a conta de IA fica sem saldo, as respostas esperam a recarga em vez de se perder** Quando a conta do provedor de IA fica sem crédito, as respostas aos clientes
não são mais descartadas em dois minutos: ficam esperando e saem sozinhas
assim que o saldo é recarregado, por até 6 horas. Nesse intervalo aparece um
único aviso na Central dizendo que a IA está sem saldo e o que fazer; ele se
fecha sozinho quando a primeira resposta sai. Se alguém da equipe respondeu o
cliente enquanto a IA esperava, ela não repete a resposta. Na tela de
Execuções, essa recusa passa a aparecer como limite de uso ou saldo, e não
como erro desconhecido.
Contribuição de @jmpo (#1730).
- **Mover um negócio para a etapa em que ele já está não é mais barrado por campos obrigatórios** Em funil que exige campos para entrar numa etapa, mover um negócio para a etapa em que ele
JÁ está — pelo assistente de IA ou por uma automação — era recusado com a frase dos campos
obrigatórios, mesmo sem nada mudar de etapa: o que muda ali é a posição dentro da coluna. A
tela já tratava esse gesto como reordenação; agora o caminho do MCP e o das automações
tratam igual. A mudança de etapa de verdade continua exigindo os campos.
Contribuição de @hiro-nikaitou (#1714).
- **A previsão em Métricas mostra o valor certo em moeda sem centavos** No painel "Previsão" de Métricas, o valor ponderado e o bruto de cada mês — e os
dos negócios sem data ou sem chance definida — apareciam cem vezes maiores em
moeda sem centavos, como o guarani (₲125.000 saía "Gs. 12.500.000"). Agora o
painel escreve o valor do mesmo jeito que o quadro do funil ("Gs. 125.000").
Em real, dólar e demais moedas com centavos nada muda.
Diagnóstico de @jmpo (#1727).
- **O quadro do funil cabe na tela, e o total da etapa em moeda sem centavos soma certo** Com uma etapa cheia de negócios, a barra para andar para o lado só aparecia no
fim da coluna mais comprida, e o nome da etapa sumia do alto no caminho. Agora o
quadro ocupa a altura da tela: a barra lateral fica sempre à vista no pé, e o
nome e o total de cada etapa ficam presos em cima enquanto os cards rolam.
O total no topo de cada etapa aparecia cem vezes maior em moeda sem centavos,
como o guarani (dois pedidos de ₲125.000 somavam "Gs. 25.000.000"). Ele passa a
somar certo, e o card, o total e o detalhe do negócio escrevem o valor do mesmo
jeito, na convenção da moeda ("Gs. 125.000").
A linha "ponderado" das etapas com chance calibrada passa a somar do mesmo
jeito que o total, sem sair cem vezes maior nessas moedas.
Contribuição de @jmpo (#1727).
- **O seletor de modelo do atendente não oferece mais modelo de busca, e o fim do onboarding só diz que o atendente está no ar quando ele está** **Quatro correções de tela achadas numa jornada real de dono de clínica.** A lista de modelos do atendente (IA › Agentes › Modelo) deixa de oferecer o modelo de busca do material (Text Embedding), que não conversa: agora só aparecem modelos que usam as ferramentas do CRM, a mesma regra que o sistema já usava para escolher o modelo sozinho. Um agente novo passa a nascer no provedor de IA que a organização já usa, em vez de sempre em Anthropic.
O diálogo de publicar uma versão fala português: diz a empresa pelo nome, conta os caracteres a mais ou a menos do prompt e explica que a versão anterior continua no histórico; na primeira publicação, diz que é a primeira. E a última página do onboarding pergunta ao banco se há atendente publicado: quem pulou o passo da IA ou deixou o atendente em rascunho vê "Quase lá!" e o que falta, em vez de "Tudo pronto! Seu funcionário já está de pé".
Não há ação para quem opera a VPS: nenhum dado é reescrito.
Contribuição de @webtecnica (#1718, #1694).
## [1.52.0] — 2026-09-26
### Adicionado
@@ -8228,7 +8536,9 @@ Primeira versão marcada do DeskcommCRM. O projeto vinha sendo desenvolvido publ
- **Node 22 é obrigatório para desenvolvimento.** A suíte de invariantes instancia o cliente do Supabase, que exige o `WebSocket` global — nativo apenas a partir do Node 22. Isso não afeta quem apenas hospeda: a VPS roda a imagem pronta.
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.52.0...HEAD
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.54.0...HEAD
[1.54.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.53.0...v1.54.0
[1.53.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.52.0...v1.53.0
[1.52.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.51.0...v1.52.0
[1.51.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.50.0...v1.51.0
[1.50.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.49.0...v1.50.0
+10 -1
View File
@@ -96,10 +96,19 @@ LABEL org.opencontainers.image.source="https://github.com/melgarafael/DeskcommCR
# (2026-09-13, três builds do mesmo fonte): versão nova → `apk add ffmpeg`
# REEXECUTA (23–41s); mesma versão → CACHED (3s). Por isso a declaração E o uso
# descem para depois do `apk add` e do `adduser`, junto da cópia do artefato.
# KEEP_ALIVE_TIMEOUT (lido pelo `server.js` do standalone): quanto o servidor
# segura uma conexão ociosa. O padrão do Node é 5 s — MENOR que o do proxy na
# frente (Caddy reaproveita a conexão com o upstream por 2 min; Traefik, 90 s).
# Quem fecha primeiro tem de ser o proxy: se é o servidor, o proxy manda a
# próxima requisição num socket que acabou de morrer e o usuário leva 502 (o Go
# só reenvia sozinho o que é idempotente — um POST não). 125 s passa dos dois.
# O e2e sobe o servidor com o mesmo valor (`playwright.config.ts`), onde a
# mesma corrida derrubava `page.request.get` com `socket hang up`.
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
NEXT_TELEMETRY_DISABLED=1 \
KEEP_ALIVE_TIMEOUT=125000
# ffmpeg: a derivação de vídeo (Onda 3.1) roda no processo do app — o cron
# event-log-drain executa o media_derive handler, que chama `ffmpeg` via spawn
# pra extrair áudio+frames. Sem o binário, todo vídeo recebido falha a derivação.
+19 -11
View File
@@ -142,16 +142,20 @@ const cancelarSchema = z.object({
* igual para a tela e para a IA.
*
* O recorte que a grade usa é `de`+`ate`, em INSTANTES. A tela é semanal e
* mensal (seis semanas), então o filtro por `dia` não a serve — e ele tem um
* corte em UTC que, para fuso negativo, não é o dia de quem olha: medido para
* São Paulo, o "dia 12" pega três horas do dia 11 e perde as três últimas do 12.
* Mandando instante, quem chama calcula os limites no fuso de APRESENTAÇÃO e
* esta rota não precisa adivinhar em que fuso o dia foi pedido.
* mensal (seis semanas), então o filtro por `dia` não a serve — e ele corta no
* fuso da ORGANIZAÇÃO (desde a #1744; sem fuso legível, em UTC), que não é
* necessariamente o fuso de quem olha. Mandando instante, quem chama calcula os
* limites no fuso de APRESENTAÇÃO e esta rota não precisa adivinhar em que fuso
* o dia foi pedido.
*/
export async function GET(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
// `viewer`: olhar a agenda é o menor privilégio desta feature.
// `viewer`: olhar a agenda é o menor privilégio desta feature. E SEGUE
// SÓ-SESSÃO — `requireRole` não lê Bearer, e abrir isto a token é decisão de
// produto, não de implementação (a própria suíte da rota trava este estado;
// ver o cabeçalho de `GET … continua só-sessão` em `route.test.ts`). Quem
// integra agenda por token continua saindo pela ferramenta MCP.
const authz = await requireRole("viewer", { requestId, resource: "agenda" });
if (!authz.ok) return authz.response;
const t = (texto: string) => traduzir(texto, authz.user.idioma);
@@ -188,13 +192,15 @@ export async function GET(req: NextRequest): Promise<Response> {
});
if (!resultado.ok) {
// ⚠️ DUAS DAS TRÊS RECUSAS SÃO ERRO DE QUEM CHAMA — e o `else` de antes
// ⚠️ QUATRO DAS CINCO RECUSAS SÃO ERRO DE QUEM CHAMA — e o `else` de antes
// chamava todas de falha do servidor.
//
// `sem_alvo` (falta recorte) e `alvo_nao_e_lead` (o `lead_id` veio com o id
// de um CONTATO — a confusão medida em #509) são consulta malformada: o
// servidor está inteiro, e 500 diz ao cliente server-to-server que a culpa é
// nossa. Pior: acorda o Sentry por requisição malformada, que é ruído.
// `sem_alvo` (falta recorte), `alvo_nao_e_lead` (o `lead_id` veio com o id
// de um CONTATO — a confusão medida em #509), `janela_invalida` (período
// invertido, incompleto ou acima do teto) e `cursor_invalido` são consulta
// malformada: o servidor está inteiro, e 500 diz ao cliente server-to-server
// que a culpa é nossa. Pior: acorda o Sentry por requisição malformada, que
// é ruído.
//
// O mapa é explícito — mesmo desenho de `CODIGO_DA_RECUSA` em `_handler.ts`
// — porque status e código andam juntos, e a indexação pelo código faz o
@@ -202,6 +208,8 @@ export async function GET(req: NextRequest): Promise<Response> {
const recusa = {
sem_alvo: { status: 422, code: "agenda_listagem_sem_recorte" },
alvo_nao_e_lead: { status: 422, code: "agenda_listagem_alvo_nao_e_lead" },
janela_invalida: { status: 422, code: "agenda_listagem_janela_invalida" },
cursor_invalido: { status: 422, code: "agenda_listagem_cursor_invalido" },
erro_interno: { status: 500, code: "internal_error" },
} as const;
const { status, code } = recusa[resultado.codigo];
@@ -23,9 +23,13 @@ const followupExistente = {
enabled: true,
flow_pointer_ids: [FLOW],
send_window: null,
callback_enabled: true,
};
function adminStub(atualizacoes: Record<string, unknown>[]) {
function adminStub(
atualizacoes: Record<string, unknown>[],
followup: Record<string, unknown> = followupExistente,
) {
return {
from: () => ({
select: () => ({
@@ -38,7 +42,7 @@ function adminStub(atualizacoes: Record<string, unknown>[]) {
status: "draft",
agent_id: AGENT,
organization_id: ORG,
followup: followupExistente,
followup,
},
error: null,
}),
@@ -110,6 +114,37 @@ describe("PATCH .../versions/:vid — atualização parcial", () => {
enabled: true,
flow_pointer_ids: [FLOW],
send_window: sendWindow,
callback_enabled: true,
},
},
]);
});
it("altera callback_enabled isoladamente e preserva followup normal e a janela", async () => {
const sendWindow = { start: "09:00", end: "18:00", weekdays: [1, 2, 3, 4, 5] };
const { PATCH } = await import("./route");
const request = new NextRequest("http://localhost/api/v1/ai/agents/x/versions/y", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ followup: { callback_enabled: false } }),
});
// Este caso comprova também que o estado existente é o mesmo JSON que o PATCH parcial mescla.
vi.mocked(createAdminClient).mockReturnValue(
adminStub(atualizacoes, { ...followupExistente, send_window: sendWindow }) as never,
);
const response = await PATCH(request, {
params: Promise.resolve({ id: AGENT, vid: VERSION }),
});
expect(response.status).toBe(200);
expect(atualizacoes).toEqual([
{
followup: {
enabled: true,
flow_pointer_ids: [FLOW],
send_window: sendWindow,
callback_enabled: false,
},
},
]);
@@ -1,7 +1,7 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* GET /api/v1/channels/graph-partner/templates — o espelho desta conexão, com os slots.
* POST /api/v1/channels/graph-partner/templates — sincroniza, ou CRIA e sincroniza.
* POST /api/v1/channels/graph-partner/templates — sincroniza, CRIA, EDITA ou APAGA.
*
* As definições aprovadas do canal parceiro que espelha a Cloud API (recorte do
* #1130, de @vgamkt). Espelha a rota do outro parceiro, com três diferenças que
@@ -14,6 +14,15 @@ import { requireSupportWrite } from "@/lib/impersonate/support";
* oficial: sem eles o seletor da janela fechada não pede os `{{n}}`, e o
* pré-voo do envio recusa todo modelo que tenha variável.
*
* ─── Editar e apagar por VARIANTE (issue #1734) ─────────────────────────────
*
* Foram recusados aqui (422) até a #1734, porque o DELETE desta plataforma, por
* nome só, leva TODAS as variantes de idioma enquanto a tela apagaria uma. Agora
* o alvo resolve o id da variante por nome+idioma antes de falar com a
* plataforma (`lib/channels/graph-parceiro/templates.ts`), e a rota passa a
* mesma `executarGestao` da rota do outro parceiro — a regra de "apagar pergunta
* onde o modelo está em uso" é uma só, e duas cópias envelheceriam separadas.
*
* ─── Desligado por padrão ───────────────────────────────────────────────────
*
* Canal opcional da INSTALAÇÃO (decisão do dono, doc 54, opção b): com o
@@ -46,6 +55,7 @@ import {
} from "@/lib/channels";
import { canalGraphParceiroLigado } from "@/lib/channels/graph-parceiro/credentials";
import { findGraphPartnerSession } from "@/lib/channels/graph-parceiro/session";
import { acaoApagarSchema, acaoEditarSchema, executarGestao } from "@/lib/channels/gestao-de-modelos";
import { slotKey } from "@/lib/channels/meta/build-components";
import { hashContract } from "@/lib/channels/meta/contract-hash";
import { deriveTemplateContract, describeAddress } from "@/lib/channels/meta/template-contract";
@@ -71,6 +81,10 @@ const corpoSchema = z.discriminatedUnion("acao", [
category: z.enum(["AUTHENTICATION", "MARKETING", "UTILITY"]).default("UTILITY"),
components: z.array(z.record(z.string(), z.unknown())).min(1).max(10),
}),
// Editar e apagar pela tela (ver lib/channels/gestao-de-modelos.ts). Os dois
// exigem name + language: é o par que identifica a VARIANTE na plataforma.
acaoEditarSchema,
acaoApagarSchema,
]);
interface Contexto {
@@ -208,6 +222,34 @@ export async function POST(req: NextRequest): Promise<Response> {
}
try {
if (corpo.acao === "editar" || corpo.acao === "apagar") {
const gestao = await executarGestao(
adapter.templates,
createAdminClient(),
{ orgId: r.ctx.orgId, sessionId: r.ctx.sessionId, sessionRef: r.ctx.sessionRef },
corpo,
);
if (!gestao.ok) {
// Apagar um modelo em uso faria o passo do follow-up pular e o agente
// errar o envio, em silêncio. A tela mostra ONDE e pede a confirmação.
return fail("template_in_use", t("Este modelo está em uso. Confirme para apagar assim mesmo."), 409, {
requestId,
details: {
usos: gestao.usos.map((u) => `${u.tipo === "fluxo" ? t("Follow-up") : t("Agente")} «${u.nome}»`),
},
});
}
await audit({
action: gestao.acao === "editar" ? "template.updated" : "template.deleted",
actorUserId: r.ctx.userId,
organizationId: r.ctx.orgId,
resourceType: "channel_session",
resourceId: r.ctx.sessionId,
requestId,
metadata: { name: corpo.name, language: corpo.language },
});
}
if (corpo.acao === "criar") {
await adapter.templates.create({
organizationId: r.ctx.orgId,
+78 -7
View File
@@ -11,14 +11,32 @@
*
* Auth: `Authorization: Bearer <INTERNAL_CRON_SECRET>` (fecha quando falta o
* segredo). Mesma forma de `app/api/v1/cron/storage-redaction/route.ts`.
*
* ─── A falha da captação, no MESMO canal das podas irmãs (issue #1721) ──────
*
* `podarHistoricoDeCaptacao` passou a PROPAGAR o erro do DELETE — a mesma
* decisão que a 10ª poda do `data-retention` tomou no #1719, e pelo mesmo
* motivo. Sem esta volta, a falha da captação virava `{ apagadas: 0 }`, que
* na resposta do cron é a MESMA linha de "não havia nada vencido".
*
* O que ela não pode é derrubar a parte da rodada que JÁ FOI FEITA: o arquivo
* forense roda antes, no mesmo tique, e cada lote fecha a própria transação.
* Então a captação entra num `try` PRÓPRIO, a falha é reportada pelos três
* canais que as irmãs já usam (`logger.error`, a linha `retention.sweep_run`
* com `falhou: true`, e Sentry) e a rodada responde 500 — como respondem o
* `data-retention` e o `media-retention` quando uma das suas podas falha. O
* que o arquivo forense conseguiu vai em `details`, para que o dia da falha
* não vire um dia sem informação.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { LOTE_PADRAO, podarArquivoDeWebhooks } from "@/lib/channels/retencao-do-arquivo";
import { podarHistoricoDeCaptacao } from "@/lib/webhooks/retencao-da-captacao";
import { env } from "@/lib/env";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
import { autorizaCron } from "@/lib/auth/cron-auth";
@@ -57,13 +75,66 @@ export async function GET(req: NextRequest): Promise<Response> {
// de agendar — o defeito que já custou meses ao risk-watcher e ao
// routing-worker. Horizonte próprio (muito mais longo), porque lá a linha é
// despejo de depuração e aqui ela é o produto.
const captacao = await podarHistoricoDeCaptacao(admin, {
// A STRING crua, e não um número já coagido: quem interpreta é
// `lib/retencao/politica.ts`, que sabe resolver lixo para o lado seguro E
// devolver a frase de aviso. Coagir antes jogaria o aviso fora.
diasBrutos: env.LEAD_CAPTURE_RETENTION_DAYS,
lote,
});
//
// Try PRÓPRIO, e o motivo é o mesmo da cascata de LGPD no `data-retention`:
// quem falha aqui falha aqui, e a falha é DITA. Sem esta volta a exceção
// derruba o `ok()` da rodada e leva junto o relatório do arquivo forense —
// que é a parte do trabalho que JÁ estava feita, porque cada lote fecha a
// própria transação.
//
// A forma do reporte é a de `reportAuditFailure` (`lib/audit/index.ts`):
// log estruturado, uma linha na trilha com `falhou: true` e o Sentry por
// import DINÂMICO com `.catch` no fim — numa instalação com `SENTRY_DSN=off`
// essa import pode nem carregar, e ela não pode virar a segunda falha da
// rodada. A ordem dos três é do log para fora: o log é o que existe sempre,
// a trilha e o Sentry são o que sobrevive ao contêiner.
let captacao: Awaited<ReturnType<typeof podarHistoricoDeCaptacao>>;
try {
captacao = await podarHistoricoDeCaptacao(admin, {
// A STRING crua, e não um número já coagido: quem interpreta é
// `lib/retencao/politica.ts`, que sabe resolver lixo para o lado seguro E
// devolver a frase de aviso. Coagir antes jogaria o aviso fora.
diasBrutos: env.LEAD_CAPTURE_RETENTION_DAYS,
lote,
});
} catch (err) {
const detalhe = err instanceof Error ? err.message : String(err);
logger.error("[webhook-log-retention] a poda da captação falhou", {
error: detalhe,
request_id: requestId,
});
void audit({
action: "retention.sweep_run",
organizationId: null,
bypassedRls: true,
metadata: {
origem: "webhook-log-retention",
poda: "webhook_lead_captures",
falhou: true,
erro: detalhe.slice(0, 300),
},
requestId,
});
void import("@sentry/nextjs")
.then((Sentry) => {
Sentry.captureException(err instanceof Error ? err : new Error(detalhe), {
level: "error",
tags: { subsystem: "retencao", poda: "webhook_lead_captures" },
extra: { request_id: requestId },
});
})
.catch(() => {
/* sem Sentry configurado: o logger.error e a linha de trilha bastam */
});
// 500, como `data-retention` e `media-retention` respondem quando uma
// poda falha: o `curl -fsS` do scheduler passa a ver a falha, e não um 200
// de "tudo certo". O que o arquivo forense conseguiu vem em `details` — a
// rodada falhou, e mesmo assim isto é verdade.
return fail("internal_error", "webhook_lead_captures_retention_failed", 500, {
requestId,
details: { erro: detalhe.slice(0, 300), arquivo_forense: resultado },
});
}
return ok({ ...resultado, captacao }, { requestId });
}
@@ -74,6 +74,7 @@ import type { AgentVersionRow } from "@/hooks/ai/useAgentVersions";
import type { CredentialRow, Provider } from "@/hooks/ai/useCredentials";
import { credentialStatus } from "@/hooks/ai/useCredentials";
import type { FunilDaResposta } from "@/hooks/pipelines/usePipelines";
import { callbacksHabilitados } from "@/lib/followup/callback-policy";
/**
* O canal oferecido no seletor é exatamente o que `listSelectableChannels`
@@ -195,6 +196,7 @@ interface FormState {
interface FollowupValue {
enabled: boolean;
flow_pointer_ids: string[];
callback_enabled: boolean;
/** Ausente em versões antigas; null = sem janela própria. */
send_window?: FollowupWindowValue | null;
}
@@ -203,6 +205,7 @@ const DEFAULT_FOLLOWUP: FollowupValue = {
enabled: false,
flow_pointer_ids: [],
send_window: null,
callback_enabled: true,
};
const DEFAULT_TRIGGER: TriggerValue = {
@@ -279,7 +282,13 @@ export function buildState(args: {
cases_enabled: version?.cases_enabled ?? false,
split_messages: version?.split_messages ?? false,
split_max_chars: version?.split_max_chars ?? 600,
followup: version?.followup ?? DEFAULT_FOLLOWUP,
followup: version?.followup
? {
...DEFAULT_FOLLOWUP,
...version.followup,
callback_enabled: callbacksHabilitados(version.followup),
}
: DEFAULT_FOLLOWUP,
operator_enabled: version?.operator_enabled ?? false,
// O form usa "" onde o banco usa null — Select controlado não aceita null.
// A conversão de volta acontece em `toVersionPayload`, num ponto só.
@@ -1234,6 +1243,24 @@ export function AgentForm(props: Props) {
"Retomar sozinho quem parou de responder, para o interessado não sumir sem ninguém perceber.",
)}
</p>
<div className="flex items-center gap-2">
<Switch
id="callback_enabled"
checked={form.followup.callback_enabled}
onCheckedChange={(v) =>
patch({ followup: { ...form.followup, callback_enabled: v } })
}
disabled={disabled}
/>
<Label htmlFor="callback_enabled">
{t("Permitir que o agente marque novos retornos por conta própria")}
</Label>
</div>
<p className="text-xs text-muted-foreground">
{t(
"Desligar impede novos retornos prometidos pelo agente. Os fluxos configurados abaixo e a consulta ou o cancelamento de retornos existentes continuam disponíveis.",
)}
</p>
<div className="flex items-center gap-2">
<Switch
id="followup_enabled"
@@ -13,6 +13,7 @@ import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { useT } from "@/hooks/i18n/useT";
import type { AgentVersionRow } from "@/hooks/ai/useAgentVersions";
import { callbacksHabilitados } from "@/lib/followup/callback-policy";
interface Props {
versionA: AgentVersionRow; // mais antiga / base
@@ -106,6 +107,9 @@ export function VersionDiff({ versionA, versionB }: Props) {
);
const followupEnabledChanged =
(versionA.followup?.enabled ?? false) !== (versionB.followup?.enabled ?? false);
const callbackEnabledA = callbacksHabilitados(versionA.followup);
const callbackEnabledB = callbacksHabilitados(versionB.followup);
const callbackEnabledChanged = callbackEnabledA !== callbackEnabledB;
const fields = buildFieldChanges(versionA, versionB);
const lines = diffLines(versionA.system_prompt ?? "", versionB.system_prompt ?? "");
@@ -166,7 +170,7 @@ export function VersionDiff({ versionA, versionB }: Props) {
<Section title={t("Follow-up")}>
{followupEnabledChanged ? (
<p className="text-xs">
{t("Habilitado:")}{" "}
{t("Fluxos automáticos habilitados:")}{" "}
<span className="font-mono text-destructive">
{String(versionA.followup?.enabled ?? false)}
</span>{" "}
@@ -176,9 +180,18 @@ export function VersionDiff({ versionA, versionB }: Props) {
</span>
</p>
) : null}
{callbackEnabledChanged ? (
<p className="text-xs">
{t("Retornos marcados pelo agente habilitados:")}{" "}
<span className="font-mono text-destructive">{String(callbackEnabledA)}</span>
{" → "}
<span className="font-mono text-emerald-600">{String(callbackEnabledB)}</span>
</p>
) : null}
<Pills label={t("Fluxos adicionados")} tone="add" items={followupFlows.added} />
<Pills label={t("Fluxos removidos")} tone="del" items={followupFlows.removed} />
{!followupEnabledChanged &&
!callbackEnabledChanged &&
followupFlows.added.length === 0 &&
followupFlows.removed.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("Sem mudanças.")}</p>
+4
View File
@@ -29,13 +29,16 @@ import { Plus } from "@/lib/ui/icons";
import type { LeadFilters } from "@/lib/kanban/filters";
import { applyFilters, filtersFromParams, filtersToParams } from "@/lib/kanban/filters";
import { categoriaDoMotivo } from "@/lib/leads/motivos-de-perda-do-funil";
import { ROLE_RANK, type Role } from "@/lib/auth/types";
export function PipelinePageClient({
pipelineId,
initialName,
role,
}: {
pipelineId: string;
initialName: string;
role: Role;
}) {
const t = useT();
const { data, isLoading, error, pulses, realtimeStatus, seguranca } = useBoard(pipelineId);
@@ -150,6 +153,7 @@ export function PipelinePageClient({
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
leadInicial={searchParams.get("lead")}
podeRenomearEtapa={ROLE_RANK[role] >= ROLE_RANK.manager}
/>
)}
<BulkActionBar
+1 -1
View File
@@ -26,5 +26,5 @@ export default async function PipelinePage({
.eq("id", id)
.maybeSingle();
if (!pipeline) notFound();
return <PipelinePageClient pipelineId={id} initialName={pipeline.name} />;
return <PipelinePageClient pipelineId={id} initialName={pipeline.name} role={activeOrg.role} />;
}
+40 -15
View File
@@ -1,32 +1,57 @@
"use client";
import Link from "next/link";
import { Buildings } from "@/lib/ui/icons";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { useT } from "@/hooks/i18n/useT";
/**
* Sticky top banner that signals the user is operating in cross-tenant
* Platform mode. Persistent visual cue to prevent accidental destructive
* actions when the operator forgets which surface they're in.
*
* ── Por que a troca de tema mora AQUI ────────────────────────────────────────
*
* O `AdminShell` não tem barra de topo no desktop — o único `<header>` dele é
* `lg:hidden`. Esta tarja é o único elemento persistente do topo em TODAS as
* larguras, então é o único lugar onde um controle global cabe sem inventar
* uma barra nova (o que mudaria o layout de todas as telas do admin).
*
* ── Por que as cores são token e não `amber-*` ───────────────────────────────
*
* A versão anterior usava `bg-amber-100 border-amber-300 text-amber-900`, cru
* do Tailwind e portanto FIXO: a tarja não acompanhava claro e escuro, e num
* tema escuro ela virava uma faixa clara gritando no topo. Os tokens de
* `warning` são os mesmos que o `Badge variant="warning"` usa, e existem nos
* dois temas com contraste aferido.
*
* O `--color-warning-bg` é translúcido (12% em claro, 18% em escuro), e esta
* tarja é `sticky` — conteúdo rolando por baixo apareceria através dela. Por
* isso são DOIS elementos: o de fora dá o fundo opaco da página, o de dentro
* dá o tom de aviso por cima. Sem `backdrop-blur`, que é anti-pattern nº 5.
*/
export function PlatformModeBanner() {
const t = useT();
return (
<div
role="region"
aria-label={t("Modo Plataforma")}
className="sticky top-0 z-40 flex h-10 w-full items-center justify-between border-b border-amber-300 bg-amber-100 px-4 text-amber-900"
>
<div className="flex items-center gap-2 text-sm">
<Buildings size={18} weight="fill" aria-hidden />
<span className="font-semibold tracking-tight">{t("MODO PLATAFORMA")}</span>
<span className="hidden text-amber-800/80 sm:inline">{t("— operação cross-tenant")}</span>
<div role="region" aria-label={t("Modo Plataforma")} className="sticky top-0 z-40 w-full bg-bg">
<div className="flex h-10 w-full items-center justify-between border-b border-warning/35 bg-warning-bg px-4 text-warning-fg">
<div className="flex items-center gap-2 text-sm">
<Buildings size={18} weight="fill" aria-hidden />
<span className="font-semibold tracking-tight">{t("MODO PLATAFORMA")}</span>
<span className="hidden opacity-80 sm:inline">{t("— operação cross-tenant")}</span>
</div>
<div className="flex items-center gap-1">
{/* 40px de tarja não comportam os 44px de alvo de toque do `size="icon"`.
O mesmo já valia para o link ao lado, que é `text-xs`: quem opera o
modo plataforma está num desktop, com cursor. */}
<ThemeToggle className="h-7 w-7 text-warning-fg lg:h-7 lg:w-7" />
<Link
href="/app"
className="rounded-md px-2 py-1 text-xs font-medium underline-offset-2 hover:underline"
>
{t("Sair pra app pessoal")}
</Link>
</div>
</div>
<Link
href="/app"
className="rounded-md px-2 py-1 text-xs font-medium underline-offset-2 hover:underline"
>
{t("Sair pra app pessoal")}
</Link>
</div>
);
}
+5 -3
View File
@@ -136,9 +136,11 @@ export function ConexoesShell({
<CanalGraphParceiroClient />
</TabsContent>
<TabsContent value="templates" className="mt-0">
{/* Sem editar/apagar: nesta plataforma o DELETE é por nome e leva
todas as variantes de idioma, e a tela apagaria uma só (#1728). */}
<TemplatesParceiroClient rota={rotaDeTemplates("graph")} gerenciar={false} />
{/* Editar e apagar valem aqui como no outro parceiro: desde a
#1734 o alvo resolve o id da variante (nome + idioma) antes de
falar com a plataforma, então a tela apaga UMA tradução, não
todas (#1728 era este o motivo de ficar desligado). */}
<TemplatesParceiroClient rota={rotaDeTemplates("graph")} />
</TabsContent>
</Tabs>
</TabsContent>
+12
View File
@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useBoard } from "@/hooks/kanban/useBoard";
import { useMoveCard, type RecusaDeCampos, type RetomadaPendente } from "@/hooks/kanban/useMoveCard";
import { useRenameStage } from "@/hooks/kanban/useRenameStage";
import { CamposObrigatoriosDialog } from "./CamposObrigatoriosDialog";
import { useAssignableMembers } from "@/hooks/inbox/useAssignableMembers";
import { useAtRiskLeads } from "@/hooks/leads/useAtRiskLeads";
@@ -37,6 +38,13 @@ interface KanbanBoardProps {
onSelectionChange?: (ids: string[]) => void;
/** Lead a abrir já na montagem (deep link `?lead=` — ver o dossiê abaixo). */
leadInicial?: string | null;
/**
* `manager`+ pode renomear a etapa direto no cabeçalho da coluna — mesmo
* corte de papel da rota (`PATCH .../stages/:stageId`, `requireRole("manager")`).
* `viewer`/`agent` também abrem este board (ele não é rota manager-only),
* então o cabeçalho fica só leitura para eles.
*/
podeRenomearEtapa?: boolean;
}
function groupLeadsByStage(stages: Stage[], leads: Lead[]): Map<string, Lead[]> {
@@ -80,10 +88,12 @@ export function KanbanBoard({
pulses: pulsesProp,
onSelectionChange,
leadInicial,
podeRenomearEtapa = false,
}: KanbanBoardProps) {
const t = useT();
const useExternal = stagesProp !== undefined && leadsProp !== undefined;
const queryResult = useBoard(useExternal ? null : pipelineId);
const renameStage = useRenameStage(pipelineId);
// A RECUSA DE CAMPOS ABRE DIÁLOGO, não toast (issue #1536): o 422 traz em
// `details.faltando` o que falta, o diálogo coleta, e o reenvio leva os
// valores NA MESMA escrita que muda a etapa. O hook é o MESMO de antes —
@@ -288,6 +298,8 @@ export function KanbanBoard({
selectedLeadIds={selectedLeadIds}
onSelectMany={handleSelectMany}
onOpen={setDossieId}
podeRenomear={podeRenomearEtapa}
onRenomear={(nome) => renameStage.mutate({ stageId: stage.id, name: nome })}
/>
))}
</div>
+74 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { Droppable } from "@hello-pangea/dnd";
import { useRef, type CSSProperties } from "react";
import { useRef, useState, type CSSProperties } from "react";
import { useT } from "@/hooks/i18n/useT";
import { cn } from "@/lib/utils";
import type { Lead } from "@/lib/types/leads";
@@ -34,6 +34,9 @@ interface StageColumnProps {
onSelectMany?: (leadIds: string[], marcar: boolean) => void;
/** Abrir o dossiê — atravessa o board até o card, como `pulses`. */
onOpen?: (leadId: string) => void;
/** `manager`+ — mesmo corte de papel da rota que renomeia a etapa. */
podeRenomear?: boolean;
onRenomear?: (nome: string) => void;
}
export function StageColumn({
@@ -48,6 +51,8 @@ export function StageColumn({
pulses,
onSelectMany,
onOpen,
podeRenomear = false,
onRenomear,
}: StageColumnProps) {
const t = useT();
const totalCents = leads.reduce((sum, l) => sum + (l.value_cents ?? 0), 0);
@@ -145,7 +150,19 @@ export function StageColumn({
style={accentStyle}
aria-hidden
/>
<h2 className="flex-1 truncate text-sm font-semibold text-text">{stage.name}</h2>
{podeRenomear && onRenomear ? (
// `key` pelo nome: remonta (e descarta o rascunho) quando o nome
// GRAVADO muda — mesmo contrato de `NomeDaEtapa` em Configurações,
// para uma edição feita em outra aba não ficar escondida atrás de
// um rascunho velho aqui.
// Dentro do <h2>: a coluna segue sendo título para quem navega
// por leitor de tela, com ou sem permissão de renomear.
<h2 className="flex min-w-0 flex-1">
<NomeDaEtapaNoQuadro key={stage.name} nome={stage.name} onConfirmar={onRenomear} />
</h2>
) : (
<h2 className="flex-1 truncate text-sm font-semibold text-text">{stage.name}</h2>
)}
<span className="rounded-full bg-surface px-2 py-0.5 text-[11px] font-medium text-text-muted tabular-nums">
{selecionadosAqui > 0 ? `${selecionadosAqui}/${leads.length}` : leads.length}
</span>
@@ -206,3 +223,58 @@ export function StageColumn({
</div>
);
}
/**
* O nome da etapa, editado no lugar — direto no cabeçalho da coluna.
*
* Mesmo contrato de `NomeDaEtapa` (Configurações › Funis, que chama a MESMA
* rota `PATCH /api/v1/pipelines/:id/stages/:stageId`): salva ao CONFIRMAR
* (Enter ou sair do campo), nunca a cada tecla — um PATCH por caractere
* dispararia a validação de nome duplicado no meio da digitação. O rascunho é
* local; o `key={stage.name}` de quem chama remonta o campo quando o nome
* GRAVADO muda, então uma edição feita em outra aba não fica escondida atrás
* de um rascunho velho.
*/
function NomeDaEtapaNoQuadro({
nome,
onConfirmar,
}: {
nome: string;
onConfirmar: (nome: string) => void;
}) {
const t = useT();
const [rascunho, setRascunho] = useState(nome);
// O Escape desfoca, e o desfoque chama `confirmar` na MESMA tecla: o
// `rascunho` que ele lê ainda é o texto digitado, e sem esta marca o Escape
// salvava o que devia desfazer (medido em renomear-etapa-no-quadro.test.tsx).
const cancelado = useRef(false);
function confirmar() {
const limpo = rascunho.trim();
if (cancelado.current || !limpo || limpo === nome) {
cancelado.current = false;
setRascunho(nome);
return;
}
onConfirmar(limpo);
}
return (
<input
value={rascunho}
maxLength={80}
aria-label={`${t("Nome da etapa")} «${nome}»`}
data-testid="nome-etapa-quadro"
onChange={(e) => setRascunho(e.target.value)}
onBlur={confirmar}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") {
cancelado.current = true;
e.currentTarget.blur();
}
}}
className="min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 py-0.5 text-sm font-semibold text-text outline-hidden hover:bg-surface focus:bg-surface"
/>
);
}
+15 -1
View File
@@ -4,9 +4,22 @@ import { useTheme } from "@/lib/theme";
import { useHotkeys } from "react-hotkeys-hook";
import { Sun, Moon, MonitorPlay } from "@/lib/ui/icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useT } from "@/hooks/i18n/useT";
export function ThemeToggle() {
/**
* ⚠️ O ATALHO `mod+shift+l` MORA AQUI DENTRO, e por isso ele só existe onde
* este componente está montado. Até 20/09/2026 o `ThemeToggle` só era montado
* pelo `components/shell/UserMenu.tsx` (a casca do tenant), o que deixava TODA a
* superfície de Admin Plataforma sem botão e sem atalho: quem entrava direto
* lá — e o `install.sh` cria o dono como platform admin, então é onde muita
* gente cai primeiro — ficava preso ao tema que estivesse valendo, sem
* descobrir que a troca existia numa outra casca.
*
* `className` existe para a tarja do Modo Plataforma, que tem 40px de altura e
* não comporta os 44px de alvo de toque do `size="icon"`.
*/
export function ThemeToggle({ className }: { className?: string }) {
const t = useT();
const { theme, setTheme } = useTheme();
@@ -22,6 +35,7 @@ export function ThemeToggle() {
<Button
variant="ghost"
size="icon"
className={cn(className)}
onClick={cycle}
aria-label={t(`Tema: ${theme}. Cmd+Shift+L para alternar.`)}
// O servidor não sabe a preferência salva no navegador do usuário --
+3 -3
View File
@@ -172,7 +172,7 @@
"col": 3,
"type": "frontend",
"label": "Capacidades",
"sublabel": "pacotes + uso real (app/app/ai/agents)",
"sublabel": "pacotes + uso real + callback_enabled (app/app/ai/agents)",
"width": 150
},
{
@@ -563,7 +563,7 @@
"id": "capacidades-turn",
"from": "capacidades",
"to": "turn",
"label": "tool_ids da versão",
"label": "tool_ids + callback_enabled da versão",
"variant": "dashed",
"labelDy": 120,
"fromSide": "right",
@@ -796,7 +796,7 @@
"title": "Capacidades: configurar e observar são a mesma peça",
"items": [
"Entrada: o humano liga pacotes por jornada; capacidade de risco crítico exige marcação individual",
"Saída: tool_ids da versão publicada é o que pickToolsFromMcp entrega ao modelo",
"Saída: tool_ids da versão publicada escolhe capacidades; callback_enabled filtra só a criação de retorno",
"Volta: api_audit_log (mcp.tool_called) → fn_agent_tool_usage → usos, falhas e última vez na aba Capacidades",
"Sem a volta, ligar uma capacidade era um ato de fé: o log existia desde a Spec 11 e nenhuma tela lia"
]
+14
View File
@@ -490,6 +490,20 @@ Resposta:
}
```
#### Follow-up configurado por versão
`ai_agent_versions.followup` é a configuração JSONB versionada. `enabled` e
`flow_pointer_ids` controlam apenas os fluxos publicados inscritos pelo agente.
`callback_enabled` é independente: controla a criação de um retorno pontual
prometido pelo agente (`schedule_followup` nativa e `crm_schedule_followup` no
catálogo MCP). Campo ausente mantém o comportamento legado habilitado.
Quando `callback_enabled=false`, o runtime não oferece as duas ferramentas de
criação, inclusive ao papel Operador. As ferramentas para consultar e cancelar
retornos, inscrever um cliente num fluxo configurado e agendar compromissos
continuam disponíveis. PATCH de `followup` mescla somente as propriedades
enviadas; desligar callbacks não muda `enabled` nem `flow_pointer_ids`.
### 4.5 Publish / lifecycle
**POST `/api/v1/ai/agents/:id:publish`** body `{ version_id }`:
@@ -293,6 +293,13 @@ validateToolIds(version.tool_ids).forEach(t => {
Endpoint `GET /api/v1/mcp/tools` (admin+) retorna o catálogo completo com schemas para a UI Spec 12 §3 popular o checklist.
No runtime interno dos agentes, `ai_agent_versions.followup.callback_enabled=false`
remove `crm_schedule_followup` depois da seleção por `tool_ids`, para o
Conversador e para o Operador. O filtro mantém `crm_list_followups`,
`crm_cancel_followup`, `crm_enroll_followup_flow` e ferramentas de agendamento
de compromisso. A superfície pública genérica `/api/mcp` não recebe política
por-agente: ela continua obedecendo ao token e às permissões do seu chamador.
---
## 5. Implementação (esqueleto)
+15
View File
@@ -194,6 +194,21 @@ Layout 2 colunas em desktop, stack em mobile.
└───────────────────────────────────────┴──────────────────────────────────────┘
```
### 3.2.1 Follow-up e retornos do agente
Na edição da versão, a seção **Follow-up** expõe controles independentes:
- `callback_enabled`: permite que o agente marque novos retornos prometidos. O
campo ausente em versões antigas é mostrado como habilitado. Desligá-lo oculta
a criação pontual pelo Conversador e Operador, sem retirar consulta ou
cancelamento de retornos existentes.
- `enabled` e `flow_pointer_ids`: continuam controlando somente os fluxos
automáticos publicados. O controle de callbacks não desmarca esses campos nem
apaga a seleção de fluxos.
O PATCH envia somente a propriedade alterada dentro de `followup`; o servidor
preserva as demais propriedades da versão.
### 3.3 Validações de form (Zod, sincronizadas com Spec 10)
| Campo | Regra | Mensagem |
@@ -110,6 +110,9 @@ capacidade de mexer na operação."*
- **Vê:** estado do lead, a declaração, o histórico, as 51 capacidades do catálogo.
- **Tools:** as de escrita do catálogo MCP + as nativas de operação (`update_lead_state`,
`schedule_followup`, `save_lead_note`, `open_human_case`, `provide_case_update`).
- `followup.callback_enabled=false` oculta somente a criação de retorno (`schedule_followup` /
`crm_schedule_followup`) nos dois papéis. Consulta, cancelamento e inscrição em fluxo
configurado mantêm as regras próprias; agendamento de compromisso não é callback.
- **Não tem canal.** `send_message` não existe no toolset dele. Não é regra de prompt — é ausência.
- **Saída:** chamadas de ferramenta + registro. Um turno sem ação é **"nada a fazer" registrado**,
nunca um `return` mudo.
+5 -5
View File
@@ -40,11 +40,11 @@ export interface RecorteDaGrade {
*
* ## Por que `de`/`ate` e NUNCA `dia`
*
* A rota também aceita `dia=YYYY-MM-DD`, e ele corta em **UTC**. Medido pelo
* autor enquanto escrevia: para America/Sao_Paulo, pedir o dia 12 devolve de
* 11/03 21:00 a 12/03 20:59 na parede de quem olha — três horas do dia anterior
* ENTRAM, e as três últimas do dia pedido FICAM DE FORA. Um compromisso das 22h
* some da lista do próprio dia.
* A rota também aceita `dia=YYYY-MM-DD`, e ele corta no fuso da **organização**
* (desde a #1744; sem fuso legível, em UTC). Esse não é necessariamente o fuso
* de APRESENTAÇÃO de quem olha — e, antes da #1744, o corte era em UTC: para
* America/Sao_Paulo o dia 12 pegava três horas do dia 11 e perdia as três
* últimas do 12.
*
* Mandando INSTANTE, quem calcula o começo e o fim é a tela, no fuso de
* APRESENTAÇÃO — o mesmo `AuthUser.timezone` que a página já resolve. A rota não
+6 -1
View File
@@ -31,7 +31,12 @@ export interface AgentVersionRow {
knowledge_source_ids: string[];
split_messages: boolean;
split_max_chars: number;
followup: { enabled: boolean; flow_pointer_ids: string[] };
followup: {
enabled: boolean;
flow_pointer_ids: string[];
callback_enabled?: boolean;
send_window?: { start: string; end: string; weekdays: number[] } | null;
};
status: "draft" | "published" | "superseded" | "archived";
published_at: string | null;
superseded_at: string | null;
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import { chaveDoQuadro } from "./useBoard";
import type { BoardData } from "@/lib/kanban/types";
interface RenameArgs {
stageId: string;
name: string;
}
/**
* Renomear a etapa direto do cabeçalho da coluna, no board — mesma rota
* (`PATCH /api/v1/pipelines/:id/stages/:stageId`) que a tela de
* Configurações › Funis já usa (`_stages.tsx`), aqui só com outro cliente.
*
* Otimista + invalidação, como `useMoveCard`: a coluna mostra o nome novo na
* hora, e o `onSettled` reconcilia com o servidor (inclusive a recusa por
* nome duplicado, que só o backend conhece).
*/
export function useRenameStage(pipelineId: string) {
const qc = useQueryClient();
const queryKey = chaveDoQuadro(pipelineId);
return useMutation({
mutationFn: ({ stageId, name }: RenameArgs) =>
apiClient.patch(`/api/v1/pipelines/${pipelineId}/stages/${stageId}`, { name }),
onMutate: async ({ stageId, name }) => {
await qc.cancelQueries({ queryKey });
const snapshot = qc.getQueryData<BoardData>(queryKey);
if (snapshot) {
qc.setQueryData<BoardData>(queryKey, {
...snapshot,
stages: snapshot.stages.map((s) => (s.id === stageId ? { ...s, name } : s)),
});
}
return { snapshot };
},
onError: (err, _args, ctx) => {
if (ctx?.snapshot) qc.setQueryData(queryKey, ctx.snapshot);
showApiError(err);
},
onSettled: () => {
qc.invalidateQueries({ queryKey });
},
});
}
+3 -5
View File
@@ -4,7 +4,7 @@
import * as Sentry from "@sentry/nextjs";
import { resolveSentryDsn, isCommunityDsn, integracoesDoCliente } from "./lib/sentry/dsn";
import { sentryScrubHooks } from "./lib/sentry/scrub";
import { opcoesDePrivacidade } from "./lib/sentry/privacidade";
const sentryDsn = resolveSentryDsn(
typeof window !== "undefined" ? window.__PUBLIC_ENV__?.SENTRY_DSN : undefined,
@@ -28,14 +28,12 @@ Sentry.init({
// ERRO continua, porque é o que explica o stack trace — e o replayIntegration()
// sem argumentos já aplica maskAllText/blockAllMedia.
tracesSampleRate: community ? 0 : 1,
enableLogs: true,
replaysSessionSampleRate: community ? 0 : 0.1,
replaysOnErrorSampleRate: 1.0,
sendDefaultPii: false,
...sentryScrubHooks,
// Coleta restrita + scrub, num ponto só (Sentry 11 coleta amplo por default).
...opcoesDePrivacidade,
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+307 -26
View File
@@ -71,7 +71,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { nomeDoContato, type ContatoNomeavel } from "@/lib/contacts/rotulo-do-contato";
import { diaLocalISO } from "./fuso";
import { diaLocalISO, instanteDe } from "./fuso";
import { horariosLivres, type ExcecaoDeData, type Slot } from "./horarios-livres";
import { lerJornadaDoBanco } from "./jornada";
import {
@@ -498,6 +498,29 @@ export interface AgendamentoListado {
donoId: string | null;
contatoId: string | null;
contatoNome: string | null;
/**
* O TIPO DE ATENDIMENTO de que o compromisso nasceu (`calendar_event_types`).
*
* `null` quando a linha não tem tipo — bloqueio do Google e compromisso
* marcado antes do cadastro do tipo. Inventar um aqui faria um calendário
* externo mostrar "Consulta" para um bloco que ninguém marcou. (issue #1744)
*/
tipo?: { slug: string; nome: string } | null;
/**
* COMO e ONDE se atende — o mesmo par `location_kind`/`location_details` que
* `lib/agenda/locais.ts` rotula na tela. Entrego os VALORES, não o rótulo:
* quem lê é um integrante externo que tem o próprio vocabulário, e o rótulo
* em português seria uma tradução a menos para ele. (issue #1744)
*/
local?: { tipo: string | null; descricao: string | null };
/**
* Negócios do funil vinculados a este compromisso (`crm_lead_links`,
* `target_kind='appointment'` — DECISÃO 6: não há `lead_id` na linha).
*
* Custa uma consulta extra, então é OPÇÃO: a grade da tela não publica o
* vínculo e não paga o preço de quem publica. (issue #1744)
*/
leadIds?: string[];
}
export interface ParametrosDaLista {
@@ -531,17 +554,42 @@ export interface ParametrosDaLista {
ate?: string | null;
ownerUserId?: string | null;
situacao?: SituacaoDoAgendamento | null;
/**
* Cursor opaco devolvido em `proximo` — retoma DEPOIS do último item da
* página anterior, pelo par `(starts_at, id)`. Um UUID não bastaria: dois
* compromissos no mesmo minuto têm o mesmo `starts_at`, e só o id desempata.
* (issue #1744)
*/
depoisDe?: string | null;
/**
* Traz `leadIds`. Custa uma consulta extra por página, então só quem publica
* o vínculo liga isto — a grade da tela continua sem pagar por ele.
*/
comLeadIds?: boolean;
limite: number;
}
export type ResultadoDaLista =
| { ok: true; agendamentos: AgendamentoListado[] }
| {
ok: true;
agendamentos: AgendamentoListado[];
/**
* Cursor para a PRÓXIMA página — `null` quando não sobrou nada além do
* `limite`. É o que um calendário externo repassa no `depois_de` para
* seguir lendo sem repetir item nem pular nenhum. (issue #1744)
*/
proximo?: string | null;
}
| {
ok: false;
// `alvo_nao_e_lead`: o id veio no parâmetro `lead_id` e não é um negócio
// do funil — quase sempre um id de CONTATO, que é o que o contexto do
// turno chama de `lead_id`. Ver o ramo que o emite. (issue #509)
codigo: "erro_interno" | "sem_alvo" | "alvo_nao_e_lead";
//
// `janela_invalida` e `cursor_invalido` são erro de QUEM CHAMA, como os
// dois de cima: a janela passou do teto, veio invertida ou incompleta, ou
// o cursor não é um que esta função emitiu. (issue #1744)
codigo: "erro_interno" | "sem_alvo" | "alvo_nao_e_lead" | "janela_invalida" | "cursor_invalido";
motivoParaOperador: string;
motivoParaCliente: string;
};
@@ -559,11 +607,156 @@ function contatoDoEmbed(
return nomeDoContato(Array.isArray(c) ? (c[0] ?? null) : c);
}
// ─────────────────────────────────────────────────────────────────────────────
// O CURSOR DA LISTAGEM (issue #1744)
//
// `depois_de` é o par `(starts_at, id)` do ÚLTIMO item da página anterior, e é
// opaco de propósito: o formato é interno, e trocá-lo um dia não pode quebrar
// quem já guardou um cursor. base64url de JSON é o mesmo desenho do cursor de
// `listLeadsHandler` (`app/api/v1/leads/_handler.ts`) — duas formas diferentes
// para a mesma ideia seria a terceira lista da qual o repo avisa.
//
// O `id` não é enfeite: a ordenação é por `starts_at` e dois compromissos no
// mesmo minuto empatam. Sem o desempate, a página seguinte recomeçaria do
// primeiro dos empatados e o integrante leria o mesmo item duas vezes.
// ─────────────────────────────────────────────────────────────────────────────
export interface CursorDaLista {
inicio: string;
id: string;
}
export function codificarCursorDaLista(c: CursorDaLista): string {
return Buffer.from(JSON.stringify(c), "utf8").toString("base64url");
}
export function decodificarCursorDaLista(bruto: string): CursorDaLista | null {
try {
const c = JSON.parse(Buffer.from(bruto, "base64url").toString("utf8")) as Partial<CursorDaLista>;
if (typeof c.inicio !== "string" || typeof c.id !== "string") return null;
if (Number.isNaN(new Date(c.inicio).getTime())) return null;
return { inicio: c.inicio, id: c.id };
} catch {
return null;
}
}
/**
* O fuso da organização (`organizations.timezone`) — o que decide qual é o
* "dia 12" para quem olha a agenda.
*
* `null` é a resposta de quem NÃO SABE, e é sempre seguro: sem fuso, `dia`
* continua cortando em UTC como sempre cortou (comportamento antigo, não um
* palpite). Falha de leitura e fuso ausente caem no mesmo ramo de propósito —
* um `dia` errado por fuso desconhecido é ruim, um `dia` errado por engano é
* pior, e o integrante que precisa de recorte exato tem `de`/`ate`.
*/
async function fusoDaOrganizacao(
supabase: SupabaseClient,
organizationId: string,
): Promise<string | null> {
try {
const { data, error } = await supabase
.from("organizations")
.select("timezone")
.eq("id", organizationId)
.maybeSingle();
if (error) return null;
const tz = (data as { timezone?: unknown } | null)?.timezone;
return typeof tz === "string" && tz.trim() ? tz.trim() : null;
} catch {
return null;
}
}
/**
* O intervalo UTC de um dia CIVIL no fuso dado — `[início do dia, início do
* próximo dia)`.
*
* O segundo limite é o início do dia SEGUINTE calculado no MESMO fuso, e não
* `início + 24h`: no dia do horário de verão o dia tem 23 ou 25 horas, e somar
* 24 à fecharia uma hora cedo ou deixaria uma hora a mais na lista.
*
* `null` quando o fuso é inexistente (`instanteDe` lança `RangeError`) — quem
* chama cai no corte em UTC.
*/
function janelaDoDiaNoFuso(dia: string, fuso: string): { de: string; ate: string } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dia);
if (!m) return null;
const [ano, mes, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
// O dia seguinte no calendário GREGORIANO (e não `+ 86400000`), para que a
// virada de mês e de ano se resolva sozinhas antes de virar parede.
const seguinte = new Date(Date.UTC(ano, mes - 1, d + 1));
try {
const de = instanteDe({ ano, mes, dia: d }, fuso);
const ate = instanteDe(
{
ano: seguinte.getUTCFullYear(),
mes: seguinte.getUTCMonth() + 1,
dia: seguinte.getUTCDate(),
},
fuso,
);
if (Number.isNaN(de.getTime()) || Number.isNaN(ate.getTime())) return null;
return { de: de.toISOString(), ate: ate.toISOString() };
} catch {
return null;
}
}
export async function listaAgendamentos(
supabase: SupabaseClient,
organizationId: string,
params: ParametrosDaLista,
): Promise<ResultadoDaLista> {
// ─── A JANELA, ANTES DE QUALQUER COISA (issue #1744) ──────────────────────
//
// `de`/`ate` são INSTANTES e dispensam qualquer outro recorte: com os dois, a
// listagem da organização inteira é permitida (é o `temAlvo` de baixo). Mas
// a janela é também o único caminho que varre semanas de uma vez, então ela é
// a única que pode virar uma varredura de ano inteiro por erro de chamada —
// por isso o teto é checado AQUI, e não em cada porta: rota e ferramenta MCP
// chamam esta mesma função, e uma régua por fora daria duas respostas.
const veioPeriodo = params.de !== undefined && params.de !== null
|| params.ate !== undefined && params.ate !== null;
if (veioPeriodo) {
const de = params.de ? new Date(params.de) : null;
const ate = params.ate ? new Date(params.ate) : null;
const inteiro = (d: Date | null): d is Date => d !== null && !Number.isNaN(d.getTime());
if (!inteiro(de) || !inteiro(ate)) {
return {
ok: false,
codigo: "janela_invalida",
motivoParaOperador:
"período incompleto ou inválido: `de` e `ate` vêm JUNTOS, como instantes ISO " +
"(ex.: 2026-09-01T00:00:00Z).",
motivoParaCliente:
"Preciso do início e do fim do período. Pergunte qual intervalo a pessoa quer ver e " +
"mande os dois, com data e hora.",
};
}
if (ate.getTime() <= de.getTime()) {
return {
ok: false,
codigo: "janela_invalida",
motivoParaOperador: "`ate` é anterior (ou igual) a `de`: o período tem de ir do início para o fim.",
motivoParaCliente:
"O fim do período ficou antes do começo. Pergunte de novo qual intervalo a pessoa quer ver.",
};
}
if (ate.getTime() - de.getTime() > MAXIMO_DE_DIAS * 86_400_000) {
return {
ok: false,
codigo: "janela_invalida",
motivoParaOperador:
`o período pedido passa de ${MAXIMO_DE_DIAS} dias. Pergunte um intervalo menor — ` +
"uma semana por chamada é o que um calendário desenha.",
motivoParaCliente:
`Esse intervalo é grande demais para uma consulta só. Divida em partes de até ` +
`${MAXIMO_DE_DIAS} dias e leia uma por vez.`,
};
}
}
const temAlvo = Boolean(
params.contactId || params.leadId || params.dia || params.ownerUserId || (params.de && params.ate),
);
@@ -639,21 +832,62 @@ export async function listaAgendamentos(
}
}
// O `+ 1` é o truque do `has_more` de sempre: vendo uma linha a mais do que
// o `limite` eu sei que sobrou página, sem contar tudo. A folga é cortada na
// montagem da resposta, então quem chama continua recebendo no máximo `limite`.
let q = supabase
.from("calendar_appointments")
.select(
"id, title, starts_at, ends_at, time_zone, status, revision, meeting_state, meeting_url, owner_user_id, contact_id, contacts(name, display_name)",
"id, title, starts_at, ends_at, time_zone, status, revision, meeting_state, meeting_url, owner_user_id, contact_id, location_kind, location_details, calendar_event_types(id, name, slug), contacts(name, display_name)",
)
.eq("organization_id", organizationId)
.order("starts_at", { ascending: true })
.limit(params.limite);
// O DESEMPATE por `id` é o que faz o cursor ser determinístico: sem ele,
// dois compromissos no mesmo instante trocam de lugar entre uma página e
// outra e a paginação pula ou repete item. (issue #1744)
.order("id", { ascending: true })
.limit(params.limite + 1);
if (idsPorLead) q = q.in("id", idsPorLead);
if (params.contactId) q = q.eq("contact_id", params.contactId);
if (params.ownerUserId) q = q.eq("owner_user_id", params.ownerUserId);
if (params.situacao) q = q.eq("status", params.situacao);
if (params.dia) {
q = q.gte("starts_at", `${params.dia}T00:00:00Z`).lt("starts_at", `${params.dia}T23:59:59.999Z`);
if (params.depoisDe) {
const cursor = decodificarCursorDaLista(params.depoisDe);
if (!cursor) {
return {
ok: false,
codigo: "cursor_invalido",
motivoParaOperador:
"`depois_de` não é um cursor que esta listagem emitiu. Passe `proximo` exatamente como veio, " +
"ou comece a leitura sem cursor.",
motivoParaCliente:
"A leitura parou no meio e eu não consegui continuar de onde parei. Comece de novo do início.",
};
}
q = q.or(
`starts_at.gt.${cursor.inicio},and(starts_at.eq.${cursor.inicio},id.gt.${cursor.id})`,
);
}
if (params.dia && !(params.de && params.ate)) {
// ─── O DIA É DA ORGANIZAÇÃO, E NÃO DE UTC (issue #1744) ────────────────
//
// O corte em UTC estava escrito no próprio código: em São Paulo três horas
// do dia ANTERIOR entravam e as três últimas do dia pedido ficavam de fora
// — um compromisso das 22h sumia da lista do próprio dia. Agora o `dia` é
// um dia CIVIL no `organizations.timezone`, e o filtro é o intervalo UTC
// equivalente.
//
// Sem fuso legível (coluna vazia, fuso inválido, client que não lê a org)
// o comportamento É o antigo, em UTC — degradar para o que existia é
// melhor do que adivinhar, e `de`/`ate` continua sendo o recorte exato.
const fuso = await fusoDaOrganizacao(supabase, organizationId);
const janela = fuso ? janelaDoDiaNoFuso(params.dia, fuso) : null;
if (janela) {
q = q.gte("starts_at", janela.de).lt("starts_at", janela.ate);
} else {
q = q.gte("starts_at", `${params.dia}T00:00:00Z`).lt("starts_at", `${params.dia}T23:59:59.999Z`);
}
}
// O período vence o dia quando os dois vêm: quem manda instante está pedindo
// recorte exato, e sobrepor o corte grosseiro do `dia` devolveria a interseção
@@ -690,27 +924,74 @@ export async function listaAgendamentos(
};
}
const linhas = data ?? [];
const temMais = linhas.length > params.limite;
const pagina = temMais ? linhas.slice(0, params.limite) : linhas;
const ultima = pagina[pagina.length - 1];
// O vínculo com o negócio é uma segunda tabela (DECISÃO 6), então é uma
// segunda consulta — paga só por quem pediu, e nunca quando a página veio
// vazia (uma consulta contra `in ()` não diria nada).
let vinculosPorAlvo: Map<string, string[]> | null = null;
if (params.comLeadIds && pagina.length > 0) {
const { data: vinculos, error: erroVinculos } = await supabase
.from("crm_lead_links")
.select("lead_id, target_id")
.eq("organization_id", organizationId)
.eq("target_kind", "appointment")
.in("target_id", pagina.map((l) => String(l.id)));
if (erroVinculos) {
// Vínculo é cortesia: a listagem não pode morrer por ele. Sem mapa, os
// itens saem com `leadIds` vazio — que é o que a tela da grade já mostra.
vinculosPorAlvo = null;
} else {
vinculosPorAlvo = new Map();
for (const v of vinculos ?? []) {
const alvo = String((v as { target_id: unknown }).target_id);
const lead = String((v as { lead_id: unknown }).lead_id);
const lista = vinculosPorAlvo.get(alvo) ?? [];
lista.push(lead);
vinculosPorAlvo.set(alvo, lista);
}
}
}
return {
ok: true,
agendamentos: (data ?? []).map((l) => ({
id: String(l.id),
titulo: String(l.title),
meetingState: l.meeting_state,
meetingUrl: l.meeting_state === "ready" ? l.meeting_url : null,
revision:Number(l.revision),
iniciaEm: String(l.starts_at),
terminaEm: String(l.ends_at),
fuso: String(l.time_zone),
situacao: String(l.status),
donoId: l.owner_user_id ? String(l.owner_user_id) : null,
contatoId: l.contact_id ? String(l.contact_id) : null,
// O ID sozinho não serve a nenhum dos dois consumidores: a grade precisa do
// nome para dizer "com quem", e o AGENTE recebia um uuid cru onde devia
// dizer "você já tem consulta marcada, Maria". Mesma coluna que a tela do
// produto lê, e a MESMA decisão de nome — `lib/contacts/rotulo-do-contato.ts`,
// não um precedente copiado de outro arquivo.
contatoNome: contatoDoEmbed(l.contacts),
})),
agendamentos: pagina.map((l) => {
// O embed chega objeto ou array conforme o gerador de tipos — o mesmo
// aviso de `contatoDoEmbed`, aqui de novo porque é o MESMO embed.
const tipo = (Array.isArray(l.calendar_event_types)
? (l.calendar_event_types[0] ?? null)
: (l.calendar_event_types ?? null)) as { name?: unknown; slug?: unknown } | null;
const texto = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v : null);
return {
id: String(l.id),
titulo: String(l.title),
meetingState: l.meeting_state,
meetingUrl: l.meeting_state === "ready" ? l.meeting_url : null,
revision:Number(l.revision),
iniciaEm: String(l.starts_at),
terminaEm: String(l.ends_at),
fuso: String(l.time_zone),
situacao: String(l.status),
donoId: l.owner_user_id ? String(l.owner_user_id) : null,
contatoId: l.contact_id ? String(l.contact_id) : null,
// O ID sozinho não serve a nenhum dos dois consumidores: a grade precisa do
// nome para dizer "com quem", e o AGENTE recebia um uuid cru onde devia
// dizer "você já tem consulta marcada, Maria". Mesma coluna que a tela do
// produto lê, e a MESMA decisão de nome — `lib/contacts/rotulo-do-contato.ts`,
// não um precedente copiado de outro arquivo.
contatoNome: contatoDoEmbed(l.contacts),
tipo: tipo ? { slug: texto(tipo.slug) ?? "", nome: texto(tipo.name) ?? "" } : null,
local: { tipo: texto(l.location_kind), descricao: texto(l.location_details) },
leadIds: vinculosPorAlvo?.get(String(l.id)) ?? [],
} satisfies AgendamentoListado;
}),
proximo:
temMais && ultima
? codificarCursorDaLista({ inicio: String(ultima.starts_at), id: String(ultima.id) })
: null,
};
}
@@ -11,6 +11,7 @@ const baseRow = {
version_created_by: null, agent_created_by: null,
active_kb_version_id: 'kb-1',
config: { rag_top_k: 7, rag_similarity_threshold: 0.8 },
followup: { enabled: true, flow_pointer_ids: ['flow-1'], callback_enabled: false },
};
function poolWith(row: Record<string, unknown> | undefined): pg.Pool {
@@ -43,6 +44,30 @@ describe('loadPublishedAgentConfig — campos de RAG', () => {
const cfg = await loadPublishedAgentConfig(poolWith({ ...baseRow, active_kb_version_id: null }), 'org1', 'cs1');
expect(cfg?.activeKbVersionId).toBeNull();
});
it('carrega followup da versão publicada, sem misturar a política de callbacks com os fluxos', async () => {
const pool = poolWith(baseRow);
const cfg = await loadPublishedAgentConfig(pool, 'org1', 'cs1');
const queryMock = pool.query as unknown as ReturnType<typeof vi.fn>;
const [sql] = queryMock.mock.calls[0] as [string, unknown[]];
expect(sql).toMatch(/v\.followup/);
expect(cfg?.followup).toEqual({
enabled: true,
flow_pointer_ids: ['flow-1'],
callback_enabled: false,
});
});
it('preserva a configuração legada sem callback_enabled', async () => {
const cfg = await loadPublishedAgentConfig(
poolWith({ ...baseRow, followup: { enabled: true, flow_pointer_ids: ['flow-1'] } }),
'org1',
'cs1',
);
expect(cfg?.followup).toEqual({ enabled: true, flow_pointer_ids: ['flow-1'] });
});
});
describe('loadPublishedAgentConfigById', () => {
+5
View File
@@ -39,6 +39,8 @@ export interface PublishedAgentConfig {
multimodalInput: boolean;
/** tools open_human_case/provide_case_update habilitadas no turno (spec 15). */
casesEnabled: boolean;
/** JSON versionado com `followup.callback_enabled` e os fluxos normais. */
followup?: unknown;
/** tool_ids do catálogo MCP habilitadas na tela (2B-tools). */
toolIds: string[];
/**
@@ -110,6 +112,7 @@ interface Row {
split_max_chars: number;
multimodal_input: boolean;
cases_enabled: boolean;
followup: unknown;
tool_ids: string[] | null;
active_kb_version_id: string | null;
config: Record<string, unknown> | null;
@@ -139,6 +142,7 @@ const SELECT_AGENT_CONFIG_COLUMNS = `a.operation_mode,a.paused_at,a.operation_re
v.split_max_chars,
v.multimodal_input,
v.cases_enabled,
v.followup,
v.tool_ids,
a.active_kb_version_id,
a.config,
@@ -192,6 +196,7 @@ function mapAgentConfigRow(r: Row): PublishedAgentConfig {
splitMaxChars: r.split_max_chars,
multimodalInput: r.multimodal_input,
casesEnabled: r.cases_enabled,
followup: r.followup,
toolIds: r.tool_ids ?? [],
// `?? []` cobre o clone sem a 0181: sem a coluna, o agente cai no ponteiro
// legado abaixo em vez de ficar sem material nenhum.
+6 -5
View File
@@ -87,6 +87,7 @@ import {
import { applySaveLeadNote, buildNotesIndexBlock, getLeadNoteBody } from './lead-notes';
import { buildCompromissosBlock } from './compromissos-do-contato';
import { applyScheduleFollowup, type FollowupWindowKnobs } from './schedule-followup';
import { podeExporScheduleFollowup } from '@/lib/followup/callback-policy';
import {
avisarLeadDaEscalacao,
avisarLeadLendoOContato,
@@ -3821,12 +3822,12 @@ async function executarTurnoDoAgente(
}),
};
// F3-02: a tool de agendamento (schedule_followup) só entra quando sua janela
// está configurada — main.ts sempre a preenche pelos knobs do env; tenant/lead
// vêm da ROW do job (closure), nunca do payload do modelo. É MUTANTE (cria
// cron_job), por isso fica fora de READ_ONLY_TOOLS.
// F3-02: a tool nativa só entra com janela configurada e callback habilitado
// na versão publicada. Tenant/lead vêm da ROW do job (closure), nunca do
// payload do modelo. É MUTANTE (cria cron_job), por isso fica fora de
// READ_ONLY_TOOLS.
const followupKnobs = deps.knobs.followup;
if (followupKnobs !== undefined) {
if (podeExporScheduleFollowup(agentConfig?.followup, followupKnobs)) {
rawTools.schedule_followup = tool({
...AGENT_TOOL_DEFS.schedule_followup,
execute: async (raw) => {
+6 -1
View File
@@ -25,6 +25,7 @@ import { IDS_DO_HARNESS, motivoDoHarness } from '@/lib/mcp/tools/ferramentas-do-
import type { McpAuthResult } from '@/lib/mcp/auth';
import type { McpContext } from '@/lib/mcp/types';
import { modulosLigados } from '@/lib/instalacao/modulos';
import { filtrarToolsComCallbackDesabilitado } from '@/lib/followup/callback-policy';
import type { Logger } from '../../obs/logger';
import type { CrmEdgeConfig } from './mcp-client';
@@ -60,7 +61,11 @@ export async function buildMcpTurnTools(
log: Logger,
options?: { readOnly: boolean },
): Promise<McpTurnTools | null> {
const allowed = agentConfig.toolIds.filter((id) => !BLOCKED_TOOL_IDS.has(id));
const callbackFiltered = filtrarToolsComCallbackDesabilitado(
agentConfig.toolIds,
agentConfig.followup,
);
const allowed = callbackFiltered.filter((id) => !BLOCKED_TOOL_IDS.has(id));
const blocked = agentConfig.toolIds.filter((id) => BLOCKED_TOOL_IDS.has(id));
if (blocked.length > 0) {
// A tela não oferece mais estas capacidades (a rota serve `marcavel: false`
+2 -1
View File
@@ -47,7 +47,7 @@ const VERSAO_PUBLICADA = {
cases_enabled: true,
split_messages: true,
split_max_chars: 240,
followup: { enabled: true, flow_pointer_ids: ["pointer-1"] },
followup: { enabled: true, flow_pointer_ids: ["pointer-1"], callback_enabled: false },
status: "published",
published_at: "2026-07-31T00:00:00Z",
superseded_at: null,
@@ -115,6 +115,7 @@ describe("duplicateAgentWithVersion", () => {
expect(versao!.row.split_messages).toBe(true);
expect(versao!.row.split_max_chars).toBe(240);
expect(versao!.row.followup).toEqual(VERSAO_PUBLICADA.followup);
expect((versao!.row.followup as { callback_enabled: boolean }).callback_enabled).toBe(false);
expect(versao!.row.credential_id).toBe("cred-1");
expect(versao!.row.channel_session_id).toBe("chan-1");
expect(versao!.row.system_prompt).toBe("prompt da versao");
+2
View File
@@ -82,6 +82,7 @@ const followupConfigObjectSchema = z
enabled: z.boolean().default(false),
flow_pointer_ids: z.array(UUID).max(20).default([]),
send_window: followupSendWindowSchema.nullable().optional().default(null),
callback_enabled: z.boolean().optional(),
})
.strict();
@@ -96,6 +97,7 @@ const followupPatchSchema = followupConfigObjectSchema
enabled: followupConfigObjectSchema.shape.enabled.removeDefault(),
flow_pointer_ids: followupConfigObjectSchema.shape.flow_pointer_ids.removeDefault(),
send_window: followupConfigObjectSchema.shape.send_window.removeDefault(),
callback_enabled: followupConfigObjectSchema.shape.callback_enabled,
})
.partial();
+122 -29
View File
@@ -74,34 +74,97 @@ function mesmaOrigem(url: string): boolean {
}
}
const CAMPOS_DA_LISTA =
"name,language,status,category,parameter_format,rejected_reason,quality_score,components";
/**
* Varre a coleção de modelos, UMA página por vez, até o fim ou até `emCada`
* pedir para parar.
*
* É a MESMA navegação que a lista da tela faz: `paging.next` só é seguido no
* mesmo host (o token vai no header, e um `next` apontando para fora o
* entregaria a quem a resposta mandasse) e o teto de páginas fecha o laço se a
* plataforma devolver sempre o mesmo cursor.
*/
async function varrerModelos(
c: { wabaId: string; token: string },
fields: string,
emCada: (t: RawTemplate) => boolean,
nome?: string,
): Promise<void> {
// `name` é filtro DOCUMENTADO da plataforma e devolve o nome em TODOS os
// idiomas — daí o idioma casar aqui dentro e não no servidor.
const filtro = nome ? `&name=${encodeURIComponent(nome)}` : "";
let url: string | null = `${graphPartnerGraphBase()}/${encodeURIComponent(c.wabaId)}/message_templates?limit=100&fields=${fields}${filtro}`;
for (let pagina = 0; pagina < 50 && url; pagina += 1) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${c.token}` } });
const json = (await res.json().catch(() => null)) as
| (RawTemplate & { data?: RawTemplate[]; paging?: { next?: string } })
| null;
if (!res.ok || json?.error) throw erroDaGraph(res, json, "failed");
for (const t of json?.data ?? []) {
if (!emCada(t)) return;
}
const proxima = json?.paging?.next;
url = proxima && proxima !== url && mesmaOrigem(proxima) ? proxima : null;
}
}
/**
* O id da UMA variante — nome e idioma JUNTOS.
*
* A plataforma numera cada variante de idioma por conta própria: o mesmo
* `name` em `pt_BR` e `en_US` são DOIS ids, e é por eles que se fala com uma
* só (`POST /{template_id}` para editar, `DELETE …&hsm_id=` para
* apagar). Sem o id a
* única chave que sobra é o nome — e por nome o DELETE leva TODAS as variantes
* (#1734), que é o defeito que este helper existe para fechar.
*
* Sem a variante não há id, e SEM ID A OPERAÇÃO NÃO SAI DO LUGAR: o adapter
* lança com o motivo, em vez de chamar a plataforma no escuro.
*/
async function idDaVariante(
c: { wabaId: string; token: string },
name: string,
language: string,
): Promise<string> {
const achados: string[] = [];
await varrerModelos(
c,
"id,name,language",
(t) => {
if (t.id && t.name === name && t.language === language) {
achados.push(t.id);
return false;
}
return true;
},
name,
);
const id = achados[0];
if (!id) {
throw new Error(
`graph_partner_template_variante_ausente: ${name} (${language}) não está nesta conta.`,
);
}
return id;
}
export const graphPartnerTemplateOps: ChannelTemplateOps = {
async list({ organizationId, sessionRef }): Promise<ChannelTemplate[]> {
const c = await creds({ organizationId, sessionRef });
const fields =
"name,language,status,category,parameter_format,rejected_reason,quality_score,components";
let url: string | null = `${graphPartnerGraphBase()}/${encodeURIComponent(c.wabaId)}/message_templates?limit=100&fields=${fields}`;
const todos: ChannelTemplate[] = [];
// Paginação por `paging.next` (URL completa). O teto de páginas evita laço
// infinito se a plataforma devolver sempre o mesmo cursor.
for (let pagina = 0; pagina < 50 && url; pagina += 1) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${c.token}` } });
const json = (await res.json().catch(() => null)) as
| (RawTemplate & { data?: RawTemplate[]; paging?: { next?: string } })
| null;
if (!res.ok || json?.error) throw erroDaGraph(res, json, "failed");
for (const t of json?.data ?? []) todos.push(toNeutral(t));
const proxima = json?.paging?.next;
// Só segue a página seguinte no MESMO host: o token vai no header, e um
// `next` apontando para fora o entregaria a quem a resposta mandasse.
url = proxima && proxima !== url && mesmaOrigem(proxima) ? proxima : null;
}
await varrerModelos(c, CAMPOS_DA_LISTA, (t) => {
todos.push(toNeutral(t));
return true;
});
return todos;
},
async create({ organizationId, sessionRef, draft }): Promise<ChannelTemplate> {
const c = await creds({ organizationId, sessionRef });
return postMessageTemplate(c, {
return postar(c, `/${encodeURIComponent(c.wabaId)}/message_templates`, {
name: draft.name,
language: draft.language,
category: draft.category,
@@ -110,19 +173,43 @@ export const graphPartnerTemplateOps: ChannelTemplateOps = {
});
},
async update({ organizationId, sessionRef, name, patch }): Promise<ChannelTemplate> {
/**
* Edita a VARIANTE, endereçada pelo id: `POST /{template_id}` — o caminho da
* edição é SÓ o id, sem o waba_id (OpenAPI `editar-template`), e não a
* coleção.
*
* Antes era um POST na coleção só com o nome — com duas variantes de idioma
* dava para errar a tradução sem nenhum aviso, e não se sabia se a chamada
* falhava ou mirava a variante errada (#1734). Nome e idioma entram aqui só
* para ACHAR o id: a plataforma não os deixa mudar, porque são eles que
* identificam a linha.
*/
async update({ organizationId, sessionRef, name, language, patch }): Promise<ChannelTemplate> {
const c = await creds({ organizationId, sessionRef });
// A Graph edita pela MESMA coleção (POST por nome), não por id.
return postMessageTemplate(c, {
name,
const id = await idDaVariante(c, name, language);
const editado = await postar(c, `/${encodeURIComponent(id)}`, {
...(patch.category ? { category: patch.category } : {}),
...(patch.components ? { components: patch.components } : {}),
});
// O nó devolve `id/name/category` sem o idioma — ele identifica a variante
// e não muda —, então ele vem de quem chamou e o escolheu na tela.
return { ...editado, language };
},
async remove({ organizationId, sessionRef, name }): Promise<void> {
/**
* Apaga UMA variante: o id dela em `hsm_id`, o nome em `name`.
*
* Medido na documentação da própria plataforma (Datafy,
* `api-reference/whatsapp/templates/deletar-template`): sem `hsm_id` o
* DELETE remove TODOS os idiomas daquele nome; com `hsm_id` "apenas aquela
* versão é removida, e o `name` continua obrigatório" — por isso os dois
* viajam juntos. O id é resolvido ANTES: se a variante não está na conta,
* isto lança e nada é apagado, em vez de cair no nome só (#1734).
*/
async remove({ organizationId, sessionRef, name, language }): Promise<void> {
const c = await creds({ organizationId, sessionRef });
const url = `${graphPartnerGraphBase()}/${encodeURIComponent(c.wabaId)}/message_templates?name=${encodeURIComponent(name)}`;
const id = await idDaVariante(c, name, language);
const url = `${graphPartnerGraphBase()}/${encodeURIComponent(c.wabaId)}/message_templates?name=${encodeURIComponent(name)}&hsm_id=${encodeURIComponent(id)}`;
const res = await fetch(url, {
method: "DELETE",
headers: { Authorization: `Bearer ${c.token}` },
@@ -131,12 +218,18 @@ export const graphPartnerTemplateOps: ChannelTemplateOps = {
},
};
/** POST na coleção de modelos; devolve o que a Graph devolver (pode vir vazio). */
async function postMessageTemplate(
c: { wabaId: string; token: string },
/**
* POST num caminho da API do parceiro, relativo à base: `/{waba}/message_templates`
* para criar e `/{template_id}` para editar — este SEM o waba_id, que a
* plataforma não quer no caminho da edição. Devolve o que ela devolver
* (pode vir vazio).
*/
async function postar(
c: { token: string },
caminho: string,
body: Record<string, unknown>,
): Promise<ChannelTemplate> {
const url = `${graphPartnerGraphBase()}/${encodeURIComponent(c.wabaId)}/message_templates`;
const url = `${graphPartnerGraphBase()}${caminho}`;
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${c.token}`, "Content-Type": "application/json" },
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
callbacksHabilitados,
filtrarToolsComCallbackDesabilitado,
podeExporScheduleFollowup,
} from "./callback-policy";
describe("política de criação de retornos pelo agente", () => {
it.each([
[undefined, true],
[{ enabled: false, flow_pointer_ids: [] }, true],
[{ callback_enabled: true }, true],
[{ callback_enabled: false }, false],
[null, true],
["inválido", true],
])("interpreta %j como callback_enabled=%s", (followup, esperado) => {
expect(callbacksHabilitados(followup)).toBe(esperado);
});
it("só oferece schedule_followup quando há knobs e callbacks estão habilitados", () => {
const knobs = { minAheadMs: 60_000 };
expect(podeExporScheduleFollowup(undefined, knobs)).toBe(true);
expect(podeExporScheduleFollowup({ callback_enabled: true }, knobs)).toBe(true);
expect(podeExporScheduleFollowup({ callback_enabled: false }, knobs)).toBe(false);
expect(podeExporScheduleFollowup(undefined, undefined)).toBe(false);
});
it("remove somente a ferramenta MCP que cria retorno; mantém leitura, cancelamento, fluxos e agenda", () => {
const ids = [
"crm_schedule_followup",
"crm_list_followups",
"crm_cancel_followup",
"crm_enroll_followup_flow",
"crm_propose_reactivation",
"crm_book_appointment",
"crm_find_and_book_appointment",
];
expect(filtrarToolsComCallbackDesabilitado(ids, { callback_enabled: false })).toEqual([
"crm_list_followups",
"crm_cancel_followup",
"crm_enroll_followup_flow",
"crm_propose_reactivation",
"crm_book_appointment",
"crm_find_and_book_appointment",
]);
expect(filtrarToolsComCallbackDesabilitado(ids, undefined)).toEqual(ids);
expect(filtrarToolsComCallbackDesabilitado(ids, { callback_enabled: true })).toEqual(ids);
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Decide quais retornos um agente pode criar por conta própria.
*
* `callback_enabled` é independente de `followup.enabled`: o segundo controla
* a inscrição em fluxos publicados; o primeiro controla apenas a criação de um
* retorno pontual a partir de uma promessa do agente. Versões sem o campo
* preservam o comportamento legado (habilitado).
*/
export function callbacksHabilitados(followup: unknown): boolean {
if (followup === null || typeof followup !== "object" || Array.isArray(followup)) {
return true;
}
return !("callback_enabled" in followup && followup.callback_enabled === false);
}
/** `schedule_followup` nativa também exige a janela configurada pelo runtime. */
export function podeExporScheduleFollowup<T>(
followup: unknown,
knobs: T | undefined,
): knobs is T {
return knobs !== undefined && callbacksHabilitados(followup);
}
/** Oculta somente o criador MCP de retorno; consulta, cancelamento e agenda ficam. */
export function filtrarToolsComCallbackDesabilitado(
toolIds: readonly string[],
followup: unknown,
): string[] {
return callbacksHabilitados(followup)
? [...toolIds]
: toolIds.filter((id) => id !== "crm_schedule_followup");
}
+12 -2
View File
@@ -1486,6 +1486,16 @@ export const DICIONARIO: Traducoes = {
"Os fluxos abaixo só entram em ação para um cliente se este agente estiver publicado com follow-up habilitado.": {
es: "Los flujos de abajo solo entran en acción para un cliente si este agente está publicado con el seguimiento habilitado.",
},
"Permitir que o agente marque novos retornos por conta própria": {
es: "Permitir que el agente programe nuevos retornos por su cuenta",
},
"Desligar impede novos retornos prometidos pelo agente. Os fluxos configurados abaixo e a consulta ou o cancelamento de retornos existentes continuam disponíveis.": {
es: "Al desactivarlo, el agente no podrá programar nuevos retornos. Los flujos configurados abajo y la consulta o cancelación de retornos existentes seguirán disponibles.",
},
"Fluxos automáticos habilitados:": { es: "Flujos automáticos habilitados:" },
"Retornos marcados pelo agente habilitados:": {
es: "Retornos programados por el agente habilitados:",
},
// ─── Agentes de IA: seletor de modelo, capacidades, credencial, handoff ───
Modelo: { es: "Modelo" },
"Selecione um modelo": { es: "Selecciona un modelo" },
@@ -10392,8 +10402,8 @@ export const DICIONARIO: Traducoes = {
"Lista os assuntos que já foram passados para uma pessoa resolver, com o estado de cada um, para o agente não pedir duas vezes a mesma coisa.": {
es: "Lista los asuntos que ya se pasaron a una persona para que los resuelva, con el estado de cada uno, para que el agente no pida dos veces lo mismo.",
},
"Lista os compromissos com hora marcada de um cliente ou de um dia, com a situação de cada um: marcado, realizado ou desmarcado.": {
es: "Lista los compromisos con fecha y hora de un cliente o de un día, con el estado de cada uno: programado, realizado o cancelado.",
"Lista os compromissos com hora marcada de um cliente, de um dia ou de um período de até 62 dias, com a situação de cada um: marcado, realizado ou desmarcado.": {
es: "Lista los compromisos con fecha y hora de un cliente, de un día o de un período de hasta 62 días, con el estado de cada uno: programado, realizado o cancelado.",
},
"Lista os textos que a empresa compartilhou com a equipe para responder as situações de sempre, com o atalho de cada um e as variáveis que cada texto usa.": {
es: "Lista los textos que la empresa compartió con el equipo para responder a las situaciones de siempre, con el atajo de cada uno y las variables que usa cada texto.",
+1 -1
View File
@@ -5129,7 +5129,7 @@
"Lista as oportunidades de venda de um funil, com a etapa em que cada uma está e quem é o responsável por ela.": "列出某个销售漏斗中的销售商机,以及每个商机所处阶段和负责人。",
"Lista as pessoas do time e o que cada uma pode fazer aqui dentro, para o agente saber para quem passar um atendimento.": "列出团队成员及每个人在此可执行的操作,让 AI 智能体知道可将接待转给谁。",
"Lista os assuntos que já foram passados para uma pessoa resolver, com o estado de cada um, para o agente não pedir duas vezes a mesma coisa.": "列出已转给人工处理的事项及各自状态,让 AI 智能体不会重复请求同一件事。",
"Lista os compromissos com hora marcada de um cliente ou de um dia, com a situação de cada um: marcado, realizado ou desmarcado.": "列出某客户或某天有具体时间的预约,以及各自状态:已预约、已完成或已取消。",
"Lista os compromissos com hora marcada de um cliente, de um dia ou de um período de até 62 dias, com a situação de cada um: marcado, realizado ou desmarcado.": "列出某客户、某天或最长 62 天内某一时段有具体时间的预约,以及各自状态:已预约、已完成或已取消。",
"Lista os textos que a empresa compartilhou com a equipe para responder as situações de sempre, com o atalho de cada um e as variáveis que cada texto usa.": "列出企业与团队共享的、用于应对常见情况的回复文本,以及各自的快捷方式和每个文本使用的变量。",
"Listar conversas": "列出对话",
"Lê as mensagens já trocadas com o cliente, para o agente responder sem pedir que ele repita o que já contou.": "读取与客户已交换的消息,让 AI 智能体无需让对方重复已说过的内容即可回复。",
+76 -5
View File
@@ -37,6 +37,7 @@ import {
import { ApiError } from "@/lib/api/types";
import { SITUACOES_DO_AGENDAMENTO } from "@/lib/agenda/tipos";
import type { McpContext, McpToolDefinition } from "@/lib/mcp/types";
import { resolveUserNames } from "./_users";
/** Teto do horizonte pedido — espelha o da rota, e o excesso é erro de chamada. */
const DIAS_PADRAO = 14;
@@ -383,7 +384,41 @@ const listarShape = {
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe("um dia específico, no formato AAAA-MM-DD"),
.describe(
"um dia civil, no formato AAAA-MM-DD, contado NO FUSO DA ORGANIZAÇÃO — 22h de São Paulo " +
"é daquele dia. Para um recorte com hora exata, prefira `de` + `ate`.",
),
/**
* ⚠️ `de`/`ate` são INSTANTES, e é isso que resolve o fuso na origem: quem
* chama calcula os limites no fuso em que está olhando e manda o instante,
* sem o servidor precisar adivinhar. Mesma escolha do `GET` da grade
* (`app/api/v1/agenda/agendamentos/route.ts`) — duas portas, uma régua.
*/
de: z
.string()
.datetime({ offset: true })
.optional()
.describe(
"início do PERÍODO, como instante ISO com fuso (ex.: 2026-09-01T00:00:00-03:00). Com " +
"`ate`, lista a agenda INTEIRA da organização no intervalo — nenhum outro recorte é " +
"preciso. Os dois vêm juntos; a janela aceita no máximo " +
`${MAXIMO_DE_DIAS} dias.`,
),
ate: z
.string()
.datetime({ offset: true })
.optional()
.describe(
`fim do PERÍODO, como instante ISO com fuso. Vem sempre junto com \`de\`, e a janela ` +
`aceita no máximo ${MAXIMO_DE_DIAS} dias — mais que isso é recusado.`,
),
depois_de: z
.string()
.optional()
.describe(
"cursor da PRÓXIMA página: é o valor de `proximo` da resposta anterior, passado como " +
"está. Só use quando `proximo` vier preenchido; sem cursor, a leitura começa do início.",
),
owner_user_id: z.string().uuid().optional(),
/**
* ⚠️ A constante, NUNCA os literais. `SITUACOES_DO_AGENDAMENTO` é a fonte
@@ -398,10 +433,13 @@ const listarShape = {
export const crmListAppointments: McpToolDefinition<typeof listarShape> = {
name: "crm_list_appointments",
description:
"Lista os compromissos com HORA MARCADA de um cliente, ou de um dia da equipe, com a " +
"situação de cada um. Informe pelo menos um recorte: contact_id, lead_id, dia ou " +
"owner_user_id — sem recorte a chamada é recusada, porque varrer a agenda inteira não " +
"responde pergunta nenhuma. " +
"Lista os compromissos com HORA MARCADA de um cliente, de um dia da equipe ou de um " +
"PERÍODO, com a situação de cada um. Informe pelo menos um recorte: contact_id, lead_id, " +
"dia, owner_user_id ou o PAR de+ate — sem recorte a chamada é recusada. O par de+ate é o " +
"único que dispensa os outros: com os dois informados a listagem cobre a agenda INTEIRA da " +
`organização no intervalo, em janelas de até ${MAXIMO_DE_DIAS} dias (uma semana por chamada ` +
"é o que um calendário desenha). A paginação é pelo cursor: quando a resposta trouxer " +
"`proximo` preenchido, chame de novo passando-o em `depois_de` até ele vir `null`. " +
"NÃO CONFUNDA COM `crm_list_followups`, que lista os RETORNOS — as vezes em que nós " +
"decidimos voltar a falar, sem nada combinado com o cliente. Aqui é o que foi combinado " +
"COM ele e ocupa o tempo de um atendente. O mesmo cliente pode ter os dois. " +
@@ -418,6 +456,14 @@ export const crmListAppointments: McpToolDefinition<typeof listarShape> = {
dia: input.dia ?? null,
ownerUserId: input.owner_user_id ?? null,
situacao: input.situacao ?? null,
// O PERÍODO e o CURSOR passam inteiros — quem define teto, fuso e
// continuidade é a regra, não a porta (issue #1744).
de: input.de ?? null,
ate: input.ate ?? null,
depoisDe: input.depois_de ?? null,
// O vínculo com o negócio É parte do que um calendário mostra, então esta
// porta paga a consulta extra que a grade da tela não paga.
comLeadIds: true,
limite: input.limite ?? 20,
});
@@ -426,6 +472,16 @@ export const crmListAppointments: McpToolDefinition<typeof listarShape> = {
return { compromissos: [], motivo: r.codigo, mensagem: r.motivoParaCliente };
}
// O NOME DO RESPONSÁVEL segue a mesma regra de exposição de #1528: sai pelo
// helper (`lib/mcp/tools/_users.ts`), que devolve SÓ o `full_name` — nunca
// e-mail, telefone ou o `user_metadata` inteiro — e é não-crítico: falha de
// lookup devolve `null`, não derruba a leitura. Montar nome à mão aqui seria
// uma segunda fonte de verdade sobre quem é uma pessoa.
const nomes = await resolveUserNames(
ctx.supabase,
r.agendamentos.map((a) => a.donoId),
);
return {
compromissos: r.agendamentos.map((a) => ({
id: a.id,
@@ -436,9 +492,24 @@ export const crmListAppointments: McpToolDefinition<typeof listarShape> = {
situacao: a.situacao,
meet_state: a.meetingState,
meeting_url: a.meetingState === "ready" ? a.meetingUrl : null,
// O RÓTULO DO CONTATO nunca é montado aqui: vem de `nomeDoContato` por
// `contatoDoEmbed` (`lib/contacts/rotulo-do-contato.ts`), a mesma decisão
// de nome que a tela do produto usa.
// `contato_id`/`atendente_id` ficam AO LADO dos objetos: são a forma que
// esta ferramenta devolvia antes da #1744, e um integrador que já as lê
// não pode passar a receber `undefined` em silêncio.
contato_id: a.contatoId,
atendente_id: a.donoId,
contato: { id: a.contatoId, nome: a.contatoNome },
atendente: {
id: a.donoId,
nome: a.donoId ? (nomes.get(a.donoId) ?? null) : null,
},
tipo: a.tipo ?? null,
local: a.local ?? { tipo: null, descricao: null },
lead_ids: a.leadIds ?? [],
})),
proximo: r.proximo ?? null,
};
},
};
+1 -1
View File
@@ -190,7 +190,7 @@ export const TOOLS_AGENDAMENTO = declararTools([
category: "read",
rotulo: "Ver os compromissos marcados",
explicacao:
"Lista os compromissos com hora marcada de um cliente ou de um dia, com a situação de cada um: marcado, realizado ou desmarcado.",
"Lista os compromissos com hora marcada de um cliente, de um dia ou de um período de até 62 dias, com a situação de cada um: marcado, realizado ou desmarcado.",
oQueToca: "Agenda da equipe",
risco: "seguro",
pacotes: ["vender"],
+158
View File
@@ -0,0 +1,158 @@
// @vitest-environment node
/**
* PROVA PELO SDK, não pela função.
*
* `scrub.test.ts` chama o hook direto: fica verde mesmo que o `Sentry.init`
* nunca o chame, ou que o SDK mude o formato do que entrega a ele — foi o que o
* Sentry 11 fez com o span (`description`→`name`, `data`→`attributes`), e o hook
* antigo continuaria sendo chamado sem limpar nada.
*
* Aqui o `@sentry/nextjs` de verdade (entrada de servidor, a que o app e o
* worker usam) roda com as MESMAS opções dos quatro `Sentry.init`, e um
* transporte que captura o envelope no lugar da rede. O que se afirma é sobre o
* envelope: o que sairia da VPS.
*/
import * as Sentry from "@sentry/nextjs";
import { describe, expect, it } from "vitest";
import { opcoesDePrivacidade } from "./privacidade";
import { sentryScrubHooks } from "./scrub";
// Os literais ficam AQUI, longe das linhas que disparam: o evento carrega o
// código-fonte em volta de cada frame (`context_line`), e um literal escrito na
// linha do `expect` apareceria no envelope como se fosse vazamento.
const TOKEN = "wht_9f3a1c8b2e4d6a0f";
const EMAIL = "joao.titular@exemplo.com";
const CPF = "123.456.789-09";
const TELEFONE = "(11) 98765-4321";
const IP = "203.0.113.77";
const ASSINATURA = "ASSINATURA9";
const COOKIE = "valor-do-cookie-preferencia";
const CORPO = "corpo-da-mensagem-do-paciente";
const URL_WEBHOOK = `https://crm.exemplo.com/api/v1/webhooks/canal-x/${TOKEN}?sig=${ASSINATURA}`;
type Opcoes = Parameters<typeof Sentry.init>[0];
async function envelopeDe(opcoes: Opcoes, disparar: () => void): Promise<string> {
const enviados: unknown[] = [];
// O `init` do @sentry/nextjs é no-op se já houver cliente (`sdkAlreadyInitialized`),
// o que em produção é o certo — um init por processo — e aqui faria todo caso
// depois do primeiro medir as opções do primeiro. Solta o cliente anterior.
Sentry.getCurrentScope().setClient(undefined);
Sentry.init({
dsn: "https://chave@o0.ingest.sentry.io/0",
tracesSampleRate: 1,
transport: () => ({
send: async (envelope: unknown) => {
enviados.push(envelope);
return {};
},
flush: async () => true,
}),
...opcoes,
});
Sentry.withIsolationScope((escopo) => {
// O que a instrumentação HTTP do SDK põe no escopo a cada requisição: é
// daqui que o `requestDataIntegration` monta `request`, `cookies` e o IP.
escopo.setSDKProcessingMetadata({
normalizedRequest: {
url: URL_WEBHOOK,
method: "POST",
query_string: `sig=${ASSINATURA}`,
headers: {
"x-forwarded-for": IP,
authorization: `Bearer ${TOKEN}`,
cookie: `preferencia=${COOKIE}`,
"content-type": "application/json",
},
cookies: { preferencia: COOKIE },
data: JSON.stringify({ texto: CORPO, email: EMAIL, cpf: CPF }),
},
ipAddress: IP,
});
disparar();
});
await Sentry.close(2000);
// Sem isto, um transporte nunca chamado deixaria verde todo caso que só
// afirma ausência — a sonda cega lê igual a "nada vazou".
expect(enviados.length, "o SDK não entregou envelope nenhum ao transporte").toBeGreaterThan(0);
return JSON.stringify(enviados);
}
const erroComDadoDoTitular = () => {
Sentry.captureException(
new Error(`falha para ${EMAIL}, cpf ${CPF}, tel ${TELEFONE} em ${URL_WEBHOOK}`),
);
};
const spanDeWebhook = () => {
Sentry.startSpan(
{
name: `POST /api/v1/webhooks/canal-x/${TOKEN}`,
attributes: {
// Como a instrumentação HTTP marca a rota não parametrizada. Com `url`, o
// SDK tira o nome do cabeçalho do envelope (`trace.transaction`), que o
// `beforeSendSpan` não alcança; ver o NÃO MEDIDO do PR.
"sentry.segment.name.source": "url",
"url.full": URL_WEBHOOK,
"http.request.header.x-canal-api-key": ["segredo-do-canal"],
},
},
() => undefined,
);
};
describe("o que sai da VPS, medido no envelope do SDK", () => {
it("controle: sem a nossa configuração, o dado do titular SAI — a sonda enxerga", async () => {
// Sem este caso, um envelope vazio (transporte que nunca é chamado) deixaria
// todos os outros verdes pelo motivo errado.
const envelope = await envelopeDe({}, erroComDadoDoTitular);
expect(envelope).toContain(EMAIL);
expect(envelope).toContain(TOKEN);
expect(envelope).toContain(CORPO);
expect(envelope).toContain(COOKIE);
expect(envelope).toContain(IP);
});
it("erro com as opções dos quatro inits: nenhum dado do titular nem credencial", async () => {
const envelope = await envelopeDe(opcoesDePrivacidade, erroComDadoDoTitular);
expect(envelope).toContain("[EMAIL]"); // o evento saiu, e saiu limpo
for (const vazamento of [EMAIL, CPF, TELEFONE, TOKEN, ASSINATURA, IP, COOKIE, CORPO]) {
expect(envelope, `vazou ${vazamento}`).not.toContain(vazamento);
}
});
it("span de webhook com as opções dos quatro inits: nem token nem header-credencial", async () => {
const envelope = await envelopeDe(opcoesDePrivacidade, spanDeWebhook);
expect(envelope).toContain("[TOKEN]"); // o span saiu, e saiu limpo
expect(envelope).not.toContain(TOKEN);
expect(envelope).not.toContain(ASSINATURA);
expect(envelope).not.toContain("segredo-do-canal");
});
it("controle do span: sem o beforeSendSpan, o token do path sai", async () => {
const envelope = await envelopeDe({}, spanDeWebhook);
expect(envelope).toContain(TOKEN);
});
// As duas camadas são provadas SEPARADAS: com as duas juntas, apagar uma
// deixaria o caso de cima verde pela outra.
it("só a coleta restrita, sem scrub: o SDK não anexa cookie nem IP", async () => {
const envelope = await envelopeDe(
{ dataCollection: opcoesDePrivacidade.dataCollection },
erroComDadoDoTitular,
);
expect(envelope).toContain(EMAIL); // controle: sem scrub a mensagem sai crua
expect(envelope).not.toContain(COOKIE);
expect(envelope).not.toContain(IP);
});
it("só o scrub, com a coleta AMPLA do default: corpo, cookie e IP não saem", async () => {
// Medido: o `requestDataIntegration` anexa o corpo que está no escopo sem
// olhar `httpBodies`, então o corpo passa da coleta e é o scrub que o apaga.
const envelope = await envelopeDe(sentryScrubHooks, erroComDadoDoTitular);
for (const vazamento of [CORPO, COOKIE, IP, EMAIL, TOKEN]) {
expect(envelope, `vazou ${vazamento}`).not.toContain(vazamento);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* O que o Sentry COLETA, num ponto só, para os quatro `Sentry.init` (servidor,
* edge, cliente e worker).
*
* No Sentry 10 bastava `sendDefaultPii: false`: omitir era restritivo. O 11
* removeu a opção e trocou por `dataCollection`, cujo default é o CONTRÁRIO —
* omitir coleta IP, cookies, corpo de requisição e de resposta, entrada e saída
* de IA, dados de query do banco e argumentos de fila (MIGRATION.md 11.0.0,
* "`sendDefaultPii` is replaced by `dataCollection`"). Numa instalação padrão o
* DSN é o Sentry da COMUNIDADE, então isso mandaria conversa de WhatsApp e dado
* de paciente de terceiros da VPS do cliente para a nossa conta (issue #100).
*
* Por isso a coleta é declarada inteira aqui, e não deixada ao default de
* nenhum SDK. A base é a que o guia documenta como "o comportamento do v10",
* com um aperto: `stackFrameVariables: false` (no v10 o default era `true`; hoje
* o `includeLocalVariables` segue desligado e nada muda, mas se alguém o ligar
* as variáveis locais — texto de mensagem, telefone — não saem).
*
* O scrub (`./scrub`) continua sendo a segunda camada: o `requestData` do SDK
* anexa o corpo que já estiver no escopo independentemente de `httpBodies`
* (que só barra a ESCRITA), e é o `beforeSend` que o apaga.
*/
import { sentryScrubHooks } from "./scrub";
// Lista do próprio guia para o "default do v10": casa por trecho do nome.
const CABECALHOS_DE_ENDERECO = ["forwarded", "-ip", "remote-", "via", "-user"];
const COLETA_RESTRITA = {
userInfo: false,
cookies: false,
httpHeaders: {
request: { deny: CABECALHOS_DE_ENDERECO },
response: { deny: CABECALHOS_DE_ENDERECO },
},
httpBodies: [],
urlQueryParams: { deny: CABECALHOS_DE_ENDERECO },
genAI: { inputs: false, outputs: false },
databaseQueryData: false,
queues: false,
graphQL: { document: false, variables: false },
stackFrameVariables: false,
};
/**
* Espalhe no `Sentry.init` de cada runtime. Coleta e scrub andam juntos de
* propósito: um init que tenha um sem o outro é o buraco que o #1743 teria aberto.
*/
export const opcoesDePrivacidade = {
dataCollection: COLETA_RESTRITA,
...sentryScrubHooks,
};
+27 -8
View File
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import { scrubMessage, scrubUrl, sentryScrubHooks } from "./scrub";
// Issue #100. O que estes testes travam: com `tracesSampleRate: 1` e sem
// `beforeSendTransaction`/`beforeSendSpan`/`beforeBreadcrumb`, a URL crua saía do
// hooks de transação/span/breadcrumb, a URL crua saía do
// servidor do self-hoster em 6 campos (transaction, request.url, url.full,
// http.url, url.path, http.target). As rotas de webhook por tenant têm CREDENCIAL
// no path, e na instalação padrão esse token é a credencial inteira da rota,
@@ -198,8 +198,10 @@ describe("sentryScrubHooks", () => {
expect(JSON.stringify(event)).not.toContain(TOKEN);
});
it("beforeSendTransaction limpa os atributos de trace — o canal que não tinha guarda", () => {
const event = sentryScrubHooks.beforeSendTransaction({
// No Sentry 11 não há mais evento de transação (o hook é no-op), mas o evento
// de ERRO ainda carrega `transaction` e `contexts.trace.data`.
it("beforeSend limpa o nome da transação e os atributos de trace do evento de erro", () => {
const event = sentryScrubHooks.beforeSend({
transaction: `GET /api/v1/webhooks/in/${TOKEN}`,
request: { url: urlComToken },
contexts: {
@@ -219,13 +221,30 @@ describe("sentryScrubHooks", () => {
expect(JSON.stringify(event)).not.toContain("deadbeef");
});
it("beforeSendSpan limpa description e data", () => {
it("beforeSendSpan limpa name e attributes — o formato do span no Sentry 11", () => {
const span = sentryScrubHooks.beforeSendSpan({
description: `GET ${urlComToken}`,
data: { "url.full": urlComToken },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
name: `GET ${urlComToken}`,
is_segment: true,
attributes: {
"url.full": urlComToken,
"http.request.header.x-canal-novo-api-key": ["chave-de-integracao-futura"],
"http.request.header.user-agent": ["Mozilla/5.0"],
},
});
expect(JSON.stringify(span)).not.toContain(TOKEN);
expect(JSON.stringify(span)).not.toContain("chave-de-integracao-futura");
// Header que não é credencial fica: serve para depurar.
expect(span.attributes).toHaveProperty("http.request.header.user-agent");
});
it("beforeSend apaga corpo, cookies e usuário do evento", () => {
const event = sentryScrubHooks.beforeSend({
request: { url: "https://crm.exemplo.com/x", data: { cpf: "123" }, cookies: { a: "b" } },
user: { ip_address: "203.0.113.9" },
});
expect(event.request).not.toHaveProperty("data");
expect(event.request).not.toHaveProperty("cookies");
expect(event).not.toHaveProperty("user");
});
it("beforeBreadcrumb limpa a URL — o README prometia isso sem mecanismo", () => {
+74 -19
View File
@@ -14,7 +14,7 @@
/**
* Tipos estruturais mínimos, em vez de importar de `@sentry/core`.
*
* `SpanJSON` e `TransactionEvent` não são reexportados por `@sentry/nextjs`, e o
* `StreamedSpanJSON` e `Event` não são reexportados por `@sentry/nextjs`, e o
* `@sentry/core` é dependência TRANSITIVA — sob o node_modules estrito do pnpm ele
* não resolve a partir da raiz. Importar dele funcionaria na máquina de quem tem
* hoisting e quebraria no CI. Declarar só os campos que este arquivo toca mantém os
@@ -25,13 +25,21 @@ type EventLike = {
// `unknown` de propósito nos campos que o Sentry tipa mais largo que string
// (`query_string` é `string | Record<string,string> | Array<[string,string]>`).
// A checagem de `typeof === "string"` acontece em runtime, logo abaixo.
request?: { url?: unknown; query_string?: unknown; headers?: unknown };
request?: {
url?: unknown;
query_string?: unknown;
headers?: unknown;
data?: unknown;
cookies?: unknown;
};
user?: unknown;
transaction?: string;
contexts?: { trace?: { data?: Record<string, unknown> } };
message?: string;
exception?: { values?: Array<{ value?: string }> };
};
type SpanLike = { description?: string; data?: Record<string, unknown> };
/** Formato do span no Sentry 11 (streaming): `description` virou `name`, `data` virou `attributes`. */
type SpanLike = { name?: string; attributes?: Record<string, unknown> };
type BreadcrumbLike = { message?: string; data?: Record<string, unknown> };
/**
@@ -42,8 +50,13 @@ type BreadcrumbLike = { message?: string; data?: Record<string, unknown> };
* lista, e o arquivo passa a nomear provider — o que a doutrina de restrição de canal
* proíbe fora de `lib/channels/` (`docs/doctrine/restricao-de-canal.md`). Casar pelo
* que torna o header sensível cobre os dois casos de uma vez.
*
* Sensível é credencial OU endereço do titular: `x-forwarded-for`, `x-real-ip`,
* `cf-connecting-ip` e afins carregam o IP de quem acessou (a mesma lista que o
* guia do Sentry 11 usa como "default do v10": forwarded, -ip, remote-, via).
*/
const SENSITIVE_HEADER = /authorization|cookie|api[-_]?key|token|secret|password|credential/i;
const SENSITIVE_HEADER =
/authorization|cookie|api[-_]?key|token|secret|password|credential|forwarded|-ip\b|remote-|^via$/i;
export function isSensitiveHeader(name: string): boolean {
return SENSITIVE_HEADER.test(name);
@@ -133,8 +146,13 @@ export function scrubUrl(input: string): string {
return scrubMessage(withoutQueryValues);
}
/** Atributos de span/trace que carregam URL crua na convenção OpenTelemetry. */
/**
* Atributos de span/trace que carregam URL crua na convenção OpenTelemetry.
* `sentry.segment.name` é a cópia do nome do span raiz que o Sentry 11 põe em
* TODO span — limpar só o `name` deixava o token sair por ela (medido pelo SDK).
*/
const URL_ATTRIBUTES = [
"sentry.segment.name",
"url.full",
"url.path",
"url.query",
@@ -151,6 +169,30 @@ function scrubAttributes(data: Record<string, unknown> | undefined): void {
}
}
/**
* No span, header vira atributo `http.request.header.<nome>` (valor em array no
* Sentry 11). O mesmo padrão de `isSensitiveHeader` decide quais saem.
*/
const HEADER_ATTRIBUTE = /^http\.(request|response)\.header\.(.+)$/;
/**
* Atributo de span que é dado do titular, e não metadado: o corpo (o
* `requestData` anexa `http.request.body.data` com o que houver no escopo, sem
* olhar `httpBodies` — medido pelo SDK), o usuário e o IP de quem acessou.
*/
const TITULAR_ATTRIBUTE = /^(http\.(request|response)\.body\.data|user\..+|client\.address)$/;
function scrubSpanAttributes(attributes: Record<string, unknown> | undefined): void {
if (!attributes) return;
scrubAttributes(attributes);
for (const key of Object.keys(attributes)) {
const header = HEADER_ATTRIBUTE.exec(key)?.[2];
if ((header && isSensitiveHeader(header)) || TITULAR_ATTRIBUTE.test(key)) {
delete attributes[key];
}
}
}
function scrubHeaders(headers: unknown): void {
if (!headers || typeof headers !== "object") return;
const record = headers as Record<string, string>;
@@ -160,8 +202,9 @@ function scrubHeaders(headers: unknown): void {
}
/**
* Limpa os campos que carregam URL em QUALQUER evento — erro ou transação.
* O nome da transação entra aqui porque o `@sentry/node` puro não parametriza a
* Limpa os campos do evento de erro que carregam URL ou dado do titular. O
* evento de erro ainda traz `transaction` e `contexts.trace` no Sentry 11, e o
* nome da transação entra aqui porque o `@sentry/node` puro não parametriza a
* rota; só o wrapper do Next parametriza, e nem todo caminho passa por ele.
*/
function scrubEventUrls<T extends EventLike>(event: T): T {
@@ -173,7 +216,15 @@ function scrubEventUrls<T extends EventLike>(event: T): T {
if (typeof event.request.query_string === "string") {
event.request.query_string = scrubUrl(event.request.query_string);
}
// Corpo e cookies não saem, nem se o SDK os anexar: o `requestData` do
// Sentry 11 anexa o corpo que estiver no escopo sem olhar `httpBodies`
// (que só barra a escrita) — medido em `privacidade.sdk.test.ts`.
delete event.request.data;
delete event.request.cookies;
}
// Não chamamos `setUser`; o que chega aqui é o que o SDK INFERIU (IP). Se um
// dia for preciso identificar usuário no Sentry, é decisão de LGPD, não default.
delete event.user;
if (typeof event.transaction === "string") {
event.transaction = scrubUrl(event.transaction);
}
@@ -182,33 +233,37 @@ function scrubEventUrls<T extends EventLike>(event: T): T {
}
/**
* Os quatro hooks, prontos para espalhar dentro do `Sentry.init` de cada runtime.
* Espalhar o objeto inteiro é o ponto: adicionar um hook aqui cobre servidor, edge
* e cliente de uma vez, sem depender de alguém lembrar dos três arquivos.
* Os hooks, prontos para espalhar dentro do `Sentry.init` de cada runtime (via
* `opcoesDePrivacidade`, em `./privacidade`). Espalhar o objeto inteiro é o
* ponto: adicionar um hook aqui cobre todos os runtimes de uma vez.
*
* Não há `beforeSendTransaction`: no Sentry 11 o span é transmitido em
* streaming, não existe mais evento de transação, e o hook é no-op (MIGRATION.md
* 11.0.0, "Replacing `beforeSendTransaction`"). O que ele limpava — o nome da
* transação e a URL nos atributos — é o `name` e os `attributes` do span raiz,
* e o `beforeSendSpan` limpa todo span, raiz (`is_segment`) inclusive.
*/
export const sentryScrubHooks = {
beforeSend<T extends EventLike>(event: T): T {
scrubEventUrls(event);
// `scrubUrl`, não só `scrubMessage`: mensagem de erro carrega URL (erro de
// fetch, de rota), e o token do path saía nela — medido pelo SDK.
if (typeof event.message === "string") {
event.message = scrubMessage(event.message);
event.message = scrubUrl(event.message);
}
if (event.exception?.values) {
for (const ex of event.exception.values) {
if (ex.value) ex.value = scrubMessage(ex.value);
if (ex.value) ex.value = scrubUrl(ex.value);
}
}
return event;
},
beforeSendTransaction<T extends EventLike>(event: T): T {
return scrubEventUrls(event);
},
beforeSendSpan<T extends SpanLike>(span: T): T {
if (typeof span.description === "string") {
span.description = scrubUrl(span.description);
if (typeof span.name === "string") {
span.name = scrubUrl(span.name);
}
scrubAttributes(span.data);
scrubSpanAttributes(span.attributes);
return span;
},
+44 -8
View File
@@ -38,6 +38,31 @@
* que escreveu `LEAD_CAPTURE_RETENTION_DAYS=1` descobriria pela ausência de
* efeito — falha fechada na ação e fechada também na informação, que é o pior
* dos dois mundos.
*
* ─── A ordem e o erro, na MESMA régua das podas irmãs (issue #1721) ─────────
*
* Esta poda nasceu no molde antigo e ficou nele em dois pontos, ambos medidos
* contra a décima poda do `data-retention`, que a casa já corrigiu no #1719:
*
* 1. o DELETE ia `.limit(lote)` SEM `order`. O PostgREST 12.2 recusa isso
* com 400 PGRST109 (medido no v12.2.12 pelo mantenedor; com `order=id`
* volta 200) — e, aqui, a recusa virava `apagadas: 0`. A ordem também é o
* que torna a drenagem DETERMINÍSTICA: sem `order`, cada lote apaga um
* subconjunto arbitrário, e a sequência de lotes deixa de ser repetível.
* A coluna é `id`, ASCENDENTE — a mesma da décima poda, e pela mesma
* razão: é a chave primária, logo a ordem é estável e o recorte é
* repetível, e é a coluna que casa com o índice de `received_at` sem
* exigir que o planner troque de caminho;
* 2. o erro do DELETE era ENGOLIDO (`logger.warn` + `apagadas: 0`). O
* `warn` é a evidência, não o aviso: a resposta do cron dizia "não havia
* nada vencido", indistinguível de uma instalação em dia, e o único sinal
* vivia num log dentro do contêiner, atrás de um `curl` que joga tudo
* para /dev/null. Agora a falha SOBE, como em `drenar`
* (`app/api/v1/cron/data-retention/route.ts`): quem chama involve a
* captação num `try` próprio, escreve a linha `retention.sweep_run` com
* `falhou: true` e responde 500. O que se perde é UMA rodada de um
* expurgo — o que já foi apagado no banco não volta atrás, porque cada
* lote fecha a própria transação.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -88,17 +113,28 @@ export async function podarHistoricoDeCaptacao(
.delete()
.lt("received_at", limite)
.select("id")
// O `order` ANTES do `limit` (issue #1721), na MESMA coluna e na mesma
// direção da décima poda do `data-retention`: `id` ascendente. Duas
// propriedades, e as duas importam. A PRIMEIRA é o que o PostgREST 12.2
// exige: `limit` sem `order` num DELETE volta 400 PGRST109, e sem esta
// linha a poda da captação nunca apaga nada em nenhum clone novo. A
// SEGUNDA é a drenagem: sem ordem o banco escolhe um subconjunto
// arbitrário a cada lote, então duas rodadas com o mesmo backlog não
// apagam as mesmas linhas e a sequência de lotes não é reproduzível.
.order("id")
.limit(lote);
if (error) {
// Falha ABERTA na ação (o banco cresce um pouco mais) e ABERTA na
// informação: uma poda que falha em silêncio vira "o disco encheu e
// ninguém sabe por quê" seis meses depois.
logger.warn("[retencao-captacao] não consegui apagar o lote", {
detail: error.message.slice(0, 160),
dias_aplicados: dias,
});
return { apagadas: 0, temMais: false, diasAplicados: dias };
// A falha SOBE — o mesmo caminho de `drenar`
// (`app/api/v1/cron/data-retention/route.ts`). O `warn` que vivia aqui
// dizia a causa e devolvia `apagadas: 0`, que na resposta do cron é
// indistinguível de "não havia nada vencido": um banco que parou de
// aceitar o DELETE ficava indistinguível de um banco em dia, e o sinal
// morava num log de contêiner atrás de um `curl` que joga tudo para
// /dev/null. Quem chama pega a exceção num `try` PRÓPRIO — o que já
// foi apagado no arquivo forense não se perde com ela, cada lote fecha a
// sua transação — e responde 500 com a linha `falhou: true` na trilha.
throw new Error(`webhook_lead_captures: ${error.message}`);
}
const apagadas = (data ?? []).length;
+1 -1
View File
@@ -1,4 +1,4 @@
import { withSentryConfig } from "@sentry/nextjs";
import { withSentryConfig } from "@sentry/nextjs/config";
import type { NextConfig } from "next";
/** Performance budget (EPIC-12 §S-12.05):
+16 -15
View File
@@ -47,12 +47,12 @@
"dependencies": {
"@ai-sdk/anthropic": "^4.0.16",
"@ai-sdk/google": "^4.0.18",
"@ai-sdk/openai": "^4.0.68",
"@ai-sdk/openai": "^4.0.73",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^5.9.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/sdk": "^1.30.1",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-avatar": "^1.2.6",
@@ -68,15 +68,15 @@
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@react-pdf/renderer": "^4.9.0",
"@sentry/nextjs": "^10",
"@sentry/nextjs": "^11",
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.116.0",
"@tanstack/react-query": "^5.103.1",
"@tanstack/react-query-devtools": "^5.103.1",
"@supabase/supabase-js": "^2.117.1",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-query-devtools": "^5.103.2",
"@tanstack/react-virtual": "^3.14.13",
"@upstash/redis": "^1.38.4",
"@upstash/redis": "^1.39.0",
"@xyflow/react": "^12.11.6",
"ai": "^7.0.103",
"ai": "^7.0.112",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
@@ -85,8 +85,8 @@
"import-in-the-middle": "^3.5.1",
"ipaddr.js": "^2.5.0",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.46.0",
"next": "^16.3.5",
"lucide-react": "^1.47.0",
"next": "^16.3.6",
"next-themes": "^0.4.6",
"nodemailer": "10.0.10",
"pdfjs-dist": "^6.3.289",
@@ -114,7 +114,7 @@
"@testing-library/user-event": "^14.6.7",
"@types/jest": "^29.5.13",
"@types/jsdom": "^30.0.0",
"@types/node": "^22.20.3",
"@types/node": "^22.20.4",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
@@ -125,18 +125,19 @@
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^9.12.0",
"eslint-config-next": "^16.2.10",
"jsdom": "^30.0.1",
"jsdom": "30.0.1",
"postcss": "^8.5.28",
"prettier": "^3.9.7",
"prettier": "^3.9.9",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.0",
"vitest": "^4.1.11"
},
"packageManager": "pnpm@9.15.9",
"//jsdom": "Fixado em 30.0.1 de propósito (sem ^). O jsdom 30.1 guarda a implementação do Blob num campo privado (#impl), e o ambiente jsdom do vitest (makeCompatBlob) lê o Blob pela 1ª símbolo da instância: com 30.1.x, URL.createObjectURL(file) e FormData/Request com File lançam `Cannot read properties of undefined (reading '_buffer')` — 4 casos da suíte (composer-attach x2, composer-colar-imagem, upload-templates-media-security). Upstream: vitest-dev/vitest#11336, aberta e sem conserto em nenhuma release (5.0.2 ainda quebra). Tire o pin quando o vitest instalado trouxer o conserto; o sinal é esses 4 casos verdes com jsdom 30.1+. Rastreio: melgarafael/DeskcommCRM#1745.",
"pnpm": {
"//overrides": "Pisos de versão para dependência TRANSITIVA com advisory. Não é atalho: `pnpm update <pkg>` só casa o padrão contra o package.json (hono/js-yaml/nanoid não estão lá, e não se movem), e `pnpm update` sem alvo reescreve 29 ranges diretas e arrasta 139 pacotes. Override é a única ferramenta que move transitiva sem arrastar o mundo. Os seletores com `@<major>` são LOAD-BEARING: o lock tem duas árvores de brace-expansion (1.x e 5.x) e duas de path-to-regexp (6.x e 8.x); sem o escopo, o override rebaixa a árvore nova para a antiga — em silêncio, com `pnpm audit` ainda verde.",
"overrides": {
+9 -1
View File
@@ -164,7 +164,15 @@ export default defineConfig({
webServer: {
// Produção (`next build` antes!): dev-server compila por rota (40-80s) e
// Turbopack dev quebra cookies() fora do request scope — inviável p/ e2e.
command: `pnpm exec next start --port ${PORT}`,
// `--keepAliveTimeout`: o MESMO valor do `KEEP_ALIVE_TIMEOUT` do Dockerfile
// (lá está o porquê; `tests/unit/keep-alive-do-servidor.test.ts` prende os
// dois). Com o padrão do Node, o servidor fecha a conexão ociosa aos 6 s
// (5 s + 1 s de `keepAliveTimeoutBuffer`), e o `page.request` do Playwright
// — agente keep-alive SEM prazo de ociosidade — reaproveita o socket no
// instante em que ele morre: `ECONNRESET`/`socket hang up` num GET depois
// de ~6 s sem chamada de API (medido: 5988 e 5998 ms nas runs 36069450590 e
// 36188123417).
command: `pnpm exec next start --port ${PORT} --keepAliveTimeout 125000`,
// O ambiente do servidor sob teste vem do `.env.e2e`, INJETADO aqui — e não
// do `.env.local`, que num checkout de trabalho aponta para PRODUÇÃO.
// Variável de ambiente real tem precedência sobre os arquivos `.env*` que o
+809 -877
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -3,7 +3,7 @@
import * as Sentry from "@sentry/nextjs";
import { resolveSentryDsn, isCommunityDsn } from "./lib/sentry/dsn";
import { sentryScrubHooks } from "./lib/sentry/scrub";
import { opcoesDePrivacidade } from "./lib/sentry/privacidade";
const sentryDsn = resolveSentryDsn(process.env.SENTRY_DSN);
@@ -12,8 +12,7 @@ Sentry.init({
// No Sentry da comunidade, só erro (issue #100). Ver isCommunityDsn().
tracesSampleRate: isCommunityDsn(sentryDsn) ? 0 : 1,
enableLogs: true,
sendDefaultPii: false,
...sentryScrubHooks,
// Coleta restrita + scrub, num ponto só (Sentry 11 coleta amplo por default).
...opcoesDePrivacidade,
});
+3 -4
View File
@@ -4,7 +4,7 @@
import * as Sentry from "@sentry/nextjs";
import { resolveSentryDsn, isCommunityDsn, DEFAULT_SENTRY_DSN } from "./lib/sentry/dsn";
import { sentryScrubHooks } from "./lib/sentry/scrub";
import { opcoesDePrivacidade } from "./lib/sentry/privacidade";
const sentryDsn = resolveSentryDsn(process.env.SENTRY_DSN);
const community = isCommunityDsn(sentryDsn);
@@ -14,10 +14,9 @@ Sentry.init({
// No Sentry da comunidade, só erro (issue #100). Ver isCommunityDsn().
tracesSampleRate: community ? 0 : 1,
enableLogs: true,
sendDefaultPii: false,
...sentryScrubHooks,
// Coleta restrita + scrub, num ponto só (Sentry 11 coleta amplo por default).
...opcoesDePrivacidade,
});
// Transparência de telemetria: uma linha no boot dizendo o que está ativo e como
+51 -4
View File
@@ -38882,8 +38882,14 @@ revoke execute on function public.fn_expurgar_candidatos_do_golden(int,int) from
grant execute on function public.fn_expurgar_candidatos_do_golden(int,int) to service_role;
-- ---- a retenção de mídia passa a existir (migration 0432) ----
-- Ver o cabeçalho da migration: enfileira arquivo vencido e órfão na mesma
-- fila da LGPD; o cron storage-redaction remove pelo Storage API.
-- ---- a fila de remoção de mídia deixa de ser eterna (migration 0434) ----
-- Ver o cabeçalho das DUAS migrations: a 0432 enfileira arquivo vencido e
-- órfão na mesma fila da LGPD (o cron storage-redaction remove pelo Storage
-- API); a 0434 (#1739) reabre `deleted`/`skipped` quando o mesmo caminho
-- volta a existir e expurga linha `deleted` com mais de 90 dias. O corpo
-- abaixo é a 0434 EDITADA NO LUGAR — ele tem de casar com o da migration,
-- senão quem instala pelo kit self-host fica com outra função de quem
-- aplica a cadeia (apendice-do-baseline-nao-diverge-da-cadeia).
create or replace function public.fn_enfileirar_midia_vencida(p_limite integer default 500)
returns jsonb
language plpgsql
@@ -38894,7 +38900,23 @@ declare
v_lim integer := greatest(1, least(coalesce(p_limite, 500), 5000));
v_vencidas integer := 0;
v_orfas integer := 0;
-- Janela do expurgo, em UM lugar só: é a constante que se muda amanhã.
v_janela_deleted interval := interval '90 days';
begin
-- 0. EXPURGO: a linha `deleted` da RETENÇÃO já cumpriu o papel (o arquivo
-- saiu do bucket) e nada mais precisa dela — sem isto a fila cresce sem
-- teto (#1739, item 2). Só `deleted`: `skipped` é «o objeto já não
-- existe», `failed` é a prova de uma remoção que nunca passou das 3
-- tentativas, e a issue manda não mexer em nenhuma das duas.
-- E só a de retenção (`request_id is null`): a linha de pedido LGPD é o
-- ÚNICO registro por objeto de que a mídia do titular saiu do bucket — o
-- worker só troca o `status` e nada audita a remoção física. Ela sai
-- sozinha se o pedido for apagado (FK `on delete set null`).
delete from public.storage_redaction_queue
where status = 'deleted'
and request_id is null
and coalesce(processed_at, enqueued_at) < now() - v_janela_deleted;
-- 1. VENCIDAS: arquivo de mensagem mais velho que a retenção da organização.
-- A mensagem fica (texto, status, horário); só o arquivo sai, e a tela
-- mostra «Mídia indisponível». O piso de 30 dias é o mesmo do formulário.
@@ -38914,6 +38936,13 @@ begin
-- para o mesmo arquivo da vencida. A vencida perde o caminho do mesmo
-- jeito; o arquivo sai quando a última referência vencer (aqui) ou no
-- passo 2, como órfão.
--
-- O `do update` é o conserto do #1739: se aquele caminho já saiu da fila
-- (`deleted`) ou o objeto já nem existia (`skipped`), um arquivo NOVO pode
-- estar gravado ali agora — e o `do nothing` da 0432 engolia este pedido
-- silenciosamente, deixando o arquivo novo fora da retenção PARA SEMPRE.
-- O `where` é a outra metade do conserto: `pending`/`failed` em curso não
-- são interrompidos (uma remoção em andamento não perde a tentativa).
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select distinct a.organization_id, 'whatsapp-media', a.caminho
from alvo a
@@ -38922,7 +38951,13 @@ begin
where m2.media_storage_path = a.caminho
and m2.id not in (select id from alvo)
)
on conflict (bucket, object_path) do nothing
on conflict (bucket, object_path) do update
set status = 'pending',
attempts = 0,
enqueued_at = now(),
processed_at = null,
error_message = null
where storage_redaction_queue.status in ('deleted', 'skipped')
returning 1
), limpas as (
update public.messages m
@@ -38951,15 +38986,27 @@ begin
)
and not exists (select 1 from public.messages m where m.media_storage_path = o.name)
and not exists (select 1 from public.contacts c where c.avatar_storage_path = o.name)
-- Só linha EM CURSO segura o caminho (`pending`, ou `failed` que ainda
-- é o registro de uma remoção não feita). Linha `deleted`/`skipped`
-- NÃO bloqueia mais: é justamente o caso do avatar reaproveitado
-- (#1739) — o objeto novo no caminho antigo tinha de chegar no conflito
-- lá embaixo para ser reaberto, e este `not exists` o engolia antes.
and not exists (
select 1 from public.storage_redaction_queue q
where q.bucket = 'whatsapp-media' and q.object_path = o.name
and q.status not in ('deleted', 'skipped')
)
limit v_lim
), fila as (
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select org, 'whatsapp-media', caminho from orfaos
on conflict (bucket, object_path) do nothing
on conflict (bucket, object_path) do update
set status = 'pending',
attempts = 0,
enqueued_at = now(),
processed_at = null,
error_message = null
where storage_redaction_queue.status in ('deleted', 'skipped')
returning 1
)
select count(*) into v_orfas from fila;
@@ -0,0 +1,195 @@
-- 0434 — a fila de remoção de mídia não é eterna: `deleted`/`skipped` reabrem
-- quando o MESMO caminho volta a existir, e linha `deleted` antiga é expurgada.
--
-- Achado na triagem do #1731 (retenção de mídia executada) e detalhado na issue
-- #1739. A 0432 enfileirava com `on conflict (bucket, object_path) do nothing`
-- e o worker marca a linha como `deleted`
-- (`lib/lgpd/storage-redaction-queue.ts`) sem nada a remover depois. Com
-- `unique (bucket, object_path)`, isso produz DUAS coisas ruins:
--
-- 1. reenfileirar um caminho que já saiu vira no-op silencioso: o arquivo
-- NOVO gravado naquele caminho nunca é podado nem pela retenção nem pela
-- LGPD;
-- 2. a fila cresce sem teto — toda mídia removida deixa uma linha para
-- sempre.
--
-- ## MEDIDO ANTES DE CONCERTAR (#1739, passo 1) — sim, há reaproveitamento
--
-- `grep` em TODOS os emissores de upload do bucket `whatsapp-media` (só o
-- bucket que a fila enfileira):
--
-- * `app/api/v1/cron/contact-avatars/route.ts:204` —
-- `${org}/avatars/${contactId}.jpg` com `upsert: true`: caminho ESTÁVEL
-- por contato, regravado a cada refresh de 7 dias (o comentário da própria
-- linha diz «Caminho estável por contato»). É o reaproveitamento REAL, e o
-- mesmo caminho é enfileirado em dois lugares: `lib/lgpd/redact-cascade.ts`
-- (anonimização) e o cron, quando o contato é anonimizado no meio do fetch.
-- * `workers/media-persist-worker.ts:122` — `storagePathFor(org, conversa,
-- mensagem, mime)` = `{org}/{conversa}/{mensagem}.{ext}` com
-- `upsert: true`: determinístico por MENSAGEM — reaproveita o caminho só
-- quando a MESMA mensagem é regravada (retry), nunca entre mensagens.
-- * `app/api/v1/conversations/[id]/media/route.ts:99` — `out-${randomUUID()}`
-- : nunca reaproveita.
-- * `app/api/v1/products/[id]/fotos/route.ts:107` — `${org}/${produto}/
-- ${randomUUID()}.${ext}`: nunca reaproveita.
-- * `app/api/v1/channels/partner/templates/media/route.ts:103` —
-- `templates/${randomUUID()}.${ext}`: nunca reaproveita (e `org/templates/`
-- nunca entra na fila).
--
-- Conclusão da medida: o item 1 da issue NÃO é teórico — o avatar reaproveita
-- caminho por contato. Então o conserto é o `on conflict ... do update`, e não
-- só o expurgo (que cuidaria só do item 2).
--
-- ## O QUE MUDA
--
-- * Reabre SOMENTE o que já terminou: `deleted`/`skipped` → `pending`, com
-- tentativa zerada e o carimbo da nova tentativa. `pending` e `failed` não
-- são tocados — o `where` do `do update` garante, não um comentário.
-- * O filtro de órfãos deixa de contar linha terminal como «já está na
-- fila»: sem isso o `not exists` segurava o caminho ANTES de o conflito
-- acontecer, e o `do update` jamais rodava.
-- * Expurgo das linhas `deleted` com mais de 90 dias, no MESMO cron diário
-- de retenção (`app/api/v1/cron/media-retention` → esta função): resolve o
-- item 2 sem cron novo. A linha `deleted` de pedido LGPD
-- (`request_id` não nulo) NÃO é expurgada: é o único registro por objeto
-- de que a mídia do titular saiu do bucket (nada audita a remoção física). `failed` NÃO é expurgada — a issue manda não mexer
-- nela, e ela é o registro de uma remoção que não passou das 3 tentativas.
--
-- Mesma assinatura (um argumento): criar um segundo parâmetro por `create or
-- replace` nasceria um SOBRECARGA esquecida fora do `revoke`/`grant` que vem
-- depois. O par revoke/grant é repetido para o arquivo ficar autocontido.
--
-- No `baseline.sql` o corpo é EDITADO NO LUGAR do apêndice da 0432 (mesmo
-- desenho das 0417/0426): função criada depois da varredura de `anon` seria
-- reprovada por `varredura-anon-e-o-ultimo-bloco`.
--
-- Sem coluna, sem constraint, sem índice e sem dado reescrito: o expurgo apaga
-- linha de fila que já cumpriu o papel, e nenhuma tabela tem FK para
-- `storage_redaction_queue` (medido no `baseline.sql`: só `id` como PK).
create or replace function public.fn_enfileirar_midia_vencida(p_limite integer default 500)
returns jsonb
language plpgsql
security definer
set search_path = public, storage
as $$
declare
v_lim integer := greatest(1, least(coalesce(p_limite, 500), 5000));
v_vencidas integer := 0;
v_orfas integer := 0;
-- Janela do expurgo, em UM lugar só: é a constante que se muda amanhã.
v_janela_deleted interval := interval '90 days';
begin
-- 0. EXPURGO: a linha `deleted` da RETENÇÃO já cumpriu o papel (o arquivo
-- saiu do bucket) e nada mais precisa dela — sem isto a fila cresce sem
-- teto (#1739, item 2). Só `deleted`: `skipped` é «o objeto já não
-- existe», `failed` é a prova de uma remoção que nunca passou das 3
-- tentativas, e a issue manda não mexer em nenhuma das duas.
-- E só a de retenção (`request_id is null`): a linha de pedido LGPD é o
-- ÚNICO registro por objeto de que a mídia do titular saiu do bucket — o
-- worker só troca o `status` e nada audita a remoção física. Ela sai
-- sozinha se o pedido for apagado (FK `on delete set null`).
delete from public.storage_redaction_queue
where status = 'deleted'
and request_id is null
and coalesce(processed_at, enqueued_at) < now() - v_janela_deleted;
-- 1. VENCIDAS: arquivo de mensagem mais velho que a retenção da organização.
-- A mensagem fica (texto, status, horário); só o arquivo sai, e a tela
-- mostra «Mídia indisponível». O piso de 30 dias é o mesmo do formulário.
with alvo as (
select m.id, m.organization_id, m.media_storage_path as caminho
from public.messages m
join public.organizations o on o.id = m.organization_id
where m.media_storage_path is not null
and m.created_at < now() - make_interval(days => greatest(coalesce(o.media_retention_days, 365), 30))
order by m.created_at
limit v_lim
for update of m skip locked
), fila as (
-- O arquivo só vai para a fila quando nenhuma OUTRA mensagem o usa: a foto
-- de catálogo tem caminho fixo por conversa e é reaproveitada a cada
-- reenvio (`fotos-do-produto.ts`), então a mensagem de ontem pode apontar
-- para o mesmo arquivo da vencida. A vencida perde o caminho do mesmo
-- jeito; o arquivo sai quando a última referência vencer (aqui) ou no
-- passo 2, como órfão.
--
-- O `do update` é o conserto do #1739: se aquele caminho já saiu da fila
-- (`deleted`) ou o objeto já nem existia (`skipped`), um arquivo NOVO pode
-- estar gravado ali agora — e o `do nothing` da 0432 engolia este pedido
-- silenciosamente, deixando o arquivo novo fora da retenção PARA SEMPRE.
-- O `where` é a outra metade do conserto: `pending`/`failed` em curso não
-- são interrompidos (uma remoção em andamento não perde a tentativa).
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select distinct a.organization_id, 'whatsapp-media', a.caminho
from alvo a
where not exists (
select 1 from public.messages m2
where m2.media_storage_path = a.caminho
and m2.id not in (select id from alvo)
)
on conflict (bucket, object_path) do update
set status = 'pending',
attempts = 0,
enqueued_at = now(),
processed_at = null,
error_message = null
where storage_redaction_queue.status in ('deleted', 'skipped')
returning 1
), limpas as (
update public.messages m
set media_storage_path = null, updated_at = now()
from alvo
where m.id = alvo.id
returning 1
)
select count(*) into v_vencidas from limpas;
-- 2. ÓRFÃOS: arquivo que nada no banco aponta — o rastro de conversa apagada.
-- Só as duas pastas que o CRM grava por mensagem e por contato:
-- `org/<conversa>/…` e `org/avatars/…`. `org/templates/…` (cabeçalho de
-- modelo) NUNCA entra: quem o usa guarda o link, não o caminho. Um dia de
-- carência cobre o envio que sobe o arquivo antes de gravar a mensagem.
with orfaos as (
select o.name as caminho, split_part(o.name, '/', 1)::uuid as org
from storage.objects o
where o.bucket_id = 'whatsapp-media'
and o.created_at < now() - interval '1 day'
and split_part(o.name, '/', 1) ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
and exists (select 1 from public.organizations g where g.id::text = split_part(o.name, '/', 1))
and (
split_part(o.name, '/', 2) ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
or split_part(o.name, '/', 2) = 'avatars'
)
and not exists (select 1 from public.messages m where m.media_storage_path = o.name)
and not exists (select 1 from public.contacts c where c.avatar_storage_path = o.name)
-- Só linha EM CURSO segura o caminho (`pending`, ou `failed` que ainda
-- é o registro de uma remoção não feita). Linha `deleted`/`skipped`
-- NÃO bloqueia mais: é justamente o caso do avatar reaproveitado
-- (#1739) — o objeto novo no caminho antigo tinha de chegar no conflito
-- lá embaixo para ser reaberto, e este `not exists` o engolia antes.
and not exists (
select 1 from public.storage_redaction_queue q
where q.bucket = 'whatsapp-media' and q.object_path = o.name
and q.status not in ('deleted', 'skipped')
)
limit v_lim
), fila as (
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select org, 'whatsapp-media', caminho from orfaos
on conflict (bucket, object_path) do update
set status = 'pending',
attempts = 0,
enqueued_at = now(),
processed_at = null,
error_message = null
where storage_redaction_queue.status in ('deleted', 'skipped')
returning 1
)
select count(*) into v_orfas from fila;
return jsonb_build_object('vencidas', v_vencidas, 'orfas', v_orfas);
end;
$$;
revoke execute on function public.fn_enfileirar_midia_vencida(integer) from public, anon, authenticated;
grant execute on function public.fn_enfileirar_midia_vencida(integer) to service_role;
+1
View File
@@ -441,3 +441,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260926170000` | `0428_candidatos_ao_golden_na_tabela` | **O candidato ao golden set sai do disco e vira linha de rótulo (issue #1695).** O matcher de skills (F3-09) e o classificador de etapa (F3-11) gravavam JSON em `GOLDEN_CANDIDATES_DIR`: dentro do repositório em desenvolvimento (`git add -A` publicava conversa de cliente — medida na issue) e no disco do contêiner em produção, onde nenhuma tela lê, some na atualização da imagem e fica FORA da cascata de anonimização. O #1708 redigiu o texto pelo `scrubMessage` e gitignorou os dois prefixos; esta migration tira o candidato do disco. `public.golden_candidates` guarda SÓ rótulo — `fonte` (`skill_match_miss`/`stage_classifier_divergence`) + `skill`/`motivo` ou `estagio_sugerido`/`estagio_confirmado`, com CHECK de formas disjuntas — e ponteiros `lead_id`/`job_id` SEM FK (mesmo motivo da 0421: não travar a anonimização nem cair com o histórico); a conversa que a curadoria lê fica atrás do ponteiro, não numa cópia fora da cascata. Idempotência do retry por dois índices ÚNICOS parciais (um candidato por skill+job, uma divergência por job) + `on conflict do nothing` do gravador — o "um arquivo por candidato" do tempo em disco. RLS: só SELECT por membro (`tenant_isolation_golden_candidates_select`); escrita só do servidor. `fn_expurgar_candidatos_do_golden(p_retencao_dias, p_limite)` — `security definer`, padrão 90 / piso 30 no CORPO, revogada de `public`, `anon` e `authenticated`, só `service_role` — drenada em lotes pelo cron `data-retention` (`GOLDEN_CANDIDATES_RETENTION_DAYS`). Aditiva e idempotente. Apêndice no `baseline.sql` antes da varredura de anon. |
| `20260926200200` | `0432_poda_de_midia` | **A retenção de mídia passa a existir.** `fn_enfileirar_midia_vencida(p_limite)` (security definer, só `service_role`) enfileira em `storage_redaction_queue` o arquivo de mensagem mais velho que `organizations.media_retention_days` (piso 30; zera `messages.media_storage_path`, a mensagem fica) e o arquivo órfão de `whatsapp-media` (pastas `org/<conversa>/` e `org/avatars/` sem referência, com um dia de carência; `org/templates/` nunca). O cron `storage-redaction` remove pelo Storage API. Medido em 23/09/2026: 1,30 GB órfãos levaram um Supabase gratuito à restrição 402. |
| `20260926210000` | `0433_avisos_do_jev_na_central` | **Os avisos do Jev na Central (onda 3 do Jev, bloco 3.2 — "Avisar a equipe").** Quando a empresa põe "Perceber pedido para falar com uma pessoa" ou "…para parar de receber mensagens" em **Avisar a equipe**, o Jev abre UM aviso na Central por conversa e pedido onde percebeu o pedido que a regra de hoje não pegou — e só isso: nunca passa a conversa, nunca cala o agente, nunca bloqueia, nunca responde. Três partes. (1) `jev_pedido_de_humano` e `jev_parar_de_receber` no CHECK de `agent_inbox_items.kind`, com a LISTA INTEIRA derivada do `baseline.sql` (bloco único, o da 0105). Kind próprio e não `other`: a Central dá rótulo e destino por kind, e o `other` não leva a uma conversa. (2) **Um aviso por conversa e pedido é do banco:** índice único parcial `agent_inbox_jev_pedido_unico` em (organization_id, kind, ref_id) para os dois kinds, SEM status (precedente: `agent_inbox_routing_unique`); o gravador faz o insert e, no 23505, REABRE o aviso que existe — a busca antes da escrita deixava dois drenos abrirem dois. Antes do índice, os repetidos saem (fica o aberto, e o mais novo). (3) **O aviso fecha quando o pedido foi atendido, por qualquer caminho:** `trg_fechar_avisos_do_jev_da_conversa` (em `conversations`, `update of assigned_to_user_id,status,bot_silenced_until,last_handoff_at`) fecha os dois quando a conversa sai dos estados abertos (encerrada), e SÓ o de falar com uma pessoa quando ela fica com uma pessoa — alguém assume, ou ela é PASSADA: `last_handoff_at` novo ou o robô calado (`bot_silenced_until` no futuro), o que `performHumanHandoff`, o orquestrador do clima e a pausa manual gravam (a pausa manual nem toca no status, e o gatilho de atribuição da 0228 não a veria). O de parar de receber NÃO fecha ao assumir nem na passagem: o texto dele pede que a equipe assuma E peça ao cliente o PARAR, e fechá-lo no primeiro passo sumiria com o lembrete de um pedido de descadastro antes do passo que o atende; `trg_fechar_aviso_do_jev_ao_bloquear` (em `contacts`, quando `is_blocked` passa a true — o único escritor é o STOP do próprio cliente, `lib/channels/pos-entrada.ts`; não há bloqueio à mão no produto) fecha o de parar de receber de todas as conversas do contato. Funções `security definer`, sem HTTP, filtrando a organização da própria linha, revogadas de public/anon/authenticated. O corpo do aviso NÃO leva a frase do cliente: a Central é lida por toda a organização, e a conversa só por quem a enxerga. Aditiva e idempotente. Apêndice no `baseline.sql` antes da varredura de anon (texto idêntico às partes 2 e 3); o CHECK no bloco único. Guardado por `tests/invariants/jev-aviso-na-central.test.ts` (CHECK, assumir e encerrar), `tests/invariants/jev-aviso-fecha-sozinho-e-e-unico.test.ts` (a passagem, o bloqueio, o índice único e a deduplicação) e `tests/invariants/jev-parar-de-receber-sobrevive-a-assumir.test.ts` (assumir e passar não fecham o de parar de receber; encerrar fecha). |
| `20260926223000` | `0434_reenfileiramento_de_midia` | **Reenfileirar um caminho de mídia que já saiu volta a funcionar; linha `deleted` antiga é expurgada (issue #1739, achado na triagem do #1731).** As DUAS inserções de `fn_enfileirar_midia_vencida` usavam `on conflict (bucket, object_path) do nothing` sobre um `unique (bucket, object_path)`, e o worker marca a linha como `deleted` sem nada a remover depois — dois defeitos: um arquivo NOVO gravado num caminho que já saiu era engolido em silêncio (nunca mais podado nem pela retenção nem pela LGPD) e a fila crescia sem teto. **Medido antes de consertar (#1739, passo 1): o reaproveitamento de `object_path` EXISTE** — `app/api/v1/cron/contact-avatars/route.ts:204` grava `${org}/avatars/${contactId}.jpg` com `upsert: true` (caminho estável por contato, regravado a cada refresh de 7 dias, enfileirado de novo pela cascata `lib/lgpd/redact-cascade.ts` e pelo próprio cron) e `workers/media-persist-worker.ts:122` usa `storagePathFor` = `{org}/{conversa}/{mensagem}.{ext}` (determinístico por mensagem, regravado no retry); os outros três emissores (`conversations/[id]/media`, `products/[id]/fotos`, `channels/partner/templates/media`) usam `randomUUID()` e nunca reaproveitam. Logo o item 1 da issue não é teórico e o conserto é o `on conflict ... do update`, não só o expurgo. **Regra:** `do update set status='pending', attempts=0, enqueued_at=now(), processed_at=null, error_message=null `where storage_redaction_queue.status in ('deleted','skipped')` — reabre SÓ o que já terminou; `pending` e `failed` em curso não são tocados (é o `where` que garante). O filtro de órfãos passa a contar só linha em curso como «já na fila» (`status not in ('deleted','skipped')`): sem isso o `not exists` segurava o caminho antes de o conflito acontecer e o `do update` jamais rodaria. **Expurgo:** `delete ... where status='deleted' and request_id is null and coalesce(processed_at, enqueued_at) < now() - 90 days` no CORPO da mesma função, ou seja, no cron diário `media-retention` — sem cron novo, sem coluna, sem índice, sem FK para `storage_redaction_queue` (medido no baseline). `failed` não é expurgada (a issue manda não mexer; é o registro de uma remoção que não passou das 3 tentativas). A linha `deleted` de pedido LGPD (`request_id` não nulo) também fica: é o único registro por objeto de que a mídia do titular saiu do bucket — o worker só troca o `status` e o cron `storage-redaction` não audita a remoção física (ajuste da triagem). Mesma assinatura (1 argumento) e par `revoke`/`grant` repetido; corpo EDITADO NO LUGAR no apêndice do `baseline.sql` (mesmo desenho das 0417/0426). Gate: `tests/invariants/poda-de-midia-reenfileiramento-medido.test.ts` (Postgres real: `deleted`/`skipped` reabrem, `pending`/`failed` ficam, expurgo dos 90 dias e a segunda rodada continua idempotente) e `tests/invariants/poda-de-midia-expurgo-preserva-lgpd.test.ts` (a linha `deleted` de pedido LGPD sobrevive ao expurgo). |
+6 -1
View File
@@ -514,7 +514,12 @@ test.describe("Quadro do funil — agir em vários cards de uma vez", () => {
el.scrollTop = el.scrollHeight;
});
const cabecalho = coluna(page, etapaOrigemId).locator("[data-cabecalho-da-etapa]");
await expect(cabecalho.getByRole("heading", { name: "Origem" })).toBeInViewport();
// Quem entra aqui é manager: para ele o nome da etapa é o campo
// editável do cabeçalho (#1738), não um <h2>. É o nome que tem de
// ficar à vista, qualquer que seja o elemento que o carrega.
const nomeDaEtapa = cabecalho.getByTestId("nome-etapa-quadro");
await expect(nomeDaEtapa).toHaveValue("Origem");
await expect(nomeDaEtapa).toBeInViewport();
const topoDoCabecalho = (await cabecalho.boundingBox())!.y;
const topoDoQuadro = (await quadro.boundingBox())!.y;
expect(
+5 -1
View File
@@ -105,8 +105,12 @@ test.describe("gestão de funis", () => {
// ---- o funil nasce com as quatro colunas (senão o quadro é morto) ----
await linhaDoFunil(page, NOME).getByRole("link").click();
await page.waitForURL(/\/app\/pipelines\//);
// Quem cria funil é manager+: para ele o nome da etapa é o campo editável
// do cabeçalho (#1738), e `getByText` não lê o valor de um <input>.
for (const coluna of ["Novo", "Em andamento", "Ganho", "Perdido"]) {
await expect(page.getByText(coluna, { exact: true }).first()).toBeVisible();
const nome = page.getByRole("textbox", { name: `«${coluna}»` }).first();
await expect(nome).toHaveValue(coluna);
await expect(nome).toBeVisible();
}
await page.screenshot({ path: path.join(EVIDENCIA, "funis-02-quadro-novo.png"), fullPage: true });
@@ -185,7 +185,12 @@ test("o admin zera os dados da sua organização pela tela — e a vizinha não
await expect(page.getByText(z.org_a_contato)).toHaveCount(0);
await page.goto(`/app/pipelines/${z.org_a_funil_id}`);
await expect(page.getByText(z.org_a_etapa).first()).toBeVisible({ timeout: 30_000 });
// Quem entra é admin: para ele o nome da etapa é o campo editável do
// cabeçalho (#1738), e `getByText` não lê o valor de um <input>.
await expect(page.getByRole("textbox", { name: `«${z.org_a_etapa}»` }).first()).toHaveValue(
z.org_a_etapa,
{ timeout: 30_000 },
);
await expect(page.getByText(z.org_a_lead)).toHaveCount(0);
// ── A prova pela TELA: a vizinha continua inteira ───────────────────────
@@ -0,0 +1,56 @@
/**
* O expurgo da fila de mídia (migration 0434, issue #1739) NÃO apaga a linha
* `deleted` de pedido LGPD.
*
* `storage_redaction_queue.request_id` liga a linha a `lgpd_requests`, e a
* remoção física do arquivo não é auditada em lugar nenhum: o worker
* (`lib/lgpd/storage-redaction-queue.ts`) só troca o `status`, e o cron
* `storage-redaction` não chama `audit()`. A linha `deleted` com `request_id` é,
* portanto, o único registro por objeto de que a mídia do titular saiu do
* bucket. O expurgo de 90 dias existe para a fila da RETENÇÃO (`request_id`
* nulo), que é a que cresce todo dia.
*
* Arquivo próprio porque `tests/invariants/**` é congelado; o irmão
* `poda-de-midia-reenfileiramento-medido.test.ts` cobre o resto da 0434.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { lastLine, sql } from "./gov-helpers";
const ORG = "17390000-0000-4000-8000-000000000101";
const PEDIDO = "17390000-0000-4000-8000-000000000120";
const DO_PEDIDO = `${ORG}/avatars/lgpd-antigo.jpg`;
const DA_RETENCAO = `${ORG}/avatars/retencao-antigo.jpg`;
const naFila = (p: string) =>
Number(lastLine(sql(`select count(*) from storage_redaction_queue where object_path = '${p}'`)));
function deletadaHa100Dias(caminho: string, pedido: string | null): string {
return `insert into storage_redaction_queue (organization_id, bucket, object_path, status, attempts, enqueued_at, processed_at, request_id)
values ('${ORG}', 'whatsapp-media', '${caminho}', 'deleted', 1,
now() - interval '100 days', now() - interval '100 days', ${pedido ? `'${pedido}'` : "null"});`;
}
beforeEach(() => {
sql(`
delete from storage_redaction_queue where organization_id = '${ORG}';
delete from lgpd_requests where organization_id = '${ORG}';
insert into organizations (id, slug, legal_name, display_name)
values ('${ORG}', 'org-midia-1739-lgpd', 'Org Midia 1739 LGPD LTDA', 'Org Midia 1739 LGPD')
on conflict (id) do nothing;
insert into lgpd_requests (id, organization_id, request_type, source, scope, due_at)
values ('${PEDIDO}', '${ORG}', 'redact', 'manual', 'contact', now() + interval '15 days');
`);
});
describe("fn_enfileirar_midia_vencida — expurgo preserva o rastro LGPD (0434)", () => {
it("deleted de pedido LGPD fica mesmo com mais de 90 dias; a da retenção sai", () => {
sql(deletadaHa100Dias(DO_PEDIDO, PEDIDO));
sql(deletadaHa100Dias(DA_RETENCAO, null));
sql(`select public.fn_enfileirar_midia_vencida(500)`);
expect(naFila(DO_PEDIDO)).toBe(1);
expect(naFila(DA_RETENCAO)).toBe(0);
});
});
@@ -0,0 +1,180 @@
/**
* Reenfileirar um caminho que JÁ SAIU da fila (migration 0434, issue #1739).
*
* A 0432 enfileirava com `on conflict (bucket, object_path) do nothing` sobre
* um `unique (bucket, object_path)`, e o worker marca a linha como `deleted`
* (`lib/lgpd/storage-redaction-queue.ts`) sem nada a remover depois. A
* consequência medida na issue: um arquivo NOVO gravado num caminho que já saiu
* era engolido num no-op silencioso — nunca mais podado —, e a fila crescia sem
* teto.
*
* O reaproveitamento de `object_path` NÃO é teórico (medido antes do conserto):
* `app/api/v1/cron/contact-avatars/route.ts:204` grava
* `${org}/avatars/${contactId}.jpg` com `upsert: true` — caminho estável por
* contato, regravado a cada refresh —, e `workers/media-persist-worker.ts:122`
* usa `storagePathFor` (determinístico por mensagem, regravado no retry).
*
* O que este arquivo vigia, cada um por um modo de falha concreto:
* - `deleted` reabre quando o mesmo caminho volta a ser pedido (vencida E órfão);
* - `skipped` reabre do mesmo jeito;
* - `pending` e `failed` em curso NÃO são tocados (o `where` do `do update`);
* - linha `deleted` com mais de 90 dias é expurgada no mesmo cron; a recente
* e a `pending` antiga ficam;
* - a rodada seguinte continua idempotente: reabrir não enfileira duas vezes.
*
* Arquivo NOVO porque `tests/invariants/**` é congelado.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { lastLine, sql } from "./gov-helpers";
const ORG = "17390000-0000-4000-8000-000000000001";
const CONTATO = "17390000-0000-4000-8000-000000000002";
const SESSAO = "17390000-0000-4000-8000-000000000003";
const CONVERSA = "17390000-0000-4000-8000-000000000004";
const MSG = "17390000-0000-4000-8000-000000000010";
/** Caminho de mensagem: determinístico por mensagem (`storagePathFor`). */
const MENSAGEM = `${ORG}/${CONVERSA}/reenfileirada.mp4`;
/** Caminho de avatar: ESTÁVEL por contato, reaproveitado a cada refresh do cron. */
const AVATAR_PULADO = `${ORG}/avatars/pulado.jpg`;
const AVATAR_PENDENTE = `${ORG}/avatars/pendente.jpg`;
const AVATAR_FALHO = `${ORG}/avatars/falho.jpg`;
/** Linhas do expurgo: sem objeto no bucket, nunca entram no passo 2. */
const VELHA = `${ORG}/avatars/expurgo-antigo.jpg`;
const RECENTE = `${ORG}/avatars/expurgo-recente.jpg`;
const PENDENTE_VELHA = `${ORG}/avatars/pendente-velha.jpg`;
const conta = (q: string) => Number(lastLine(sql(q)));
const naFila = (p: string) =>
conta(
`select count(*) from storage_redaction_queue where bucket = 'whatsapp-media' and object_path = '${p}'`,
);
/** `status|attempts|processed_at|error_message` da linha do caminho. */
const estado = (p: string) =>
lastLine(
sql(
`select coalesce(status, 'NULA') || '|' || attempts || '|' || coalesce(processed_at::text, 'NULO') || '|' || coalesce(error_message, 'NULO')
from storage_redaction_queue where bucket = 'whatsapp-media' and object_path = '${p}'`,
),
);
const rodar = () => JSON.parse(lastLine(sql(`select public.fn_enfileirar_midia_vencida(500)::text`)));
function objeto(nome: string, idadeDias: number): string {
return `insert into storage.objects (bucket_id, name, metadata, created_at)
values ('whatsapp-media', '${nome}', '{"size": 1000}'::jsonb, now() - interval '${idadeDias} days');`;
}
/** Linha já existente na fila, com o estado que se quer observar. */
function linhaFila(
caminho: string,
status: string,
o: { attempts?: number; enqueuedDias?: number; processadaDias?: number | null; erro?: string | null } = {},
): string {
const processada =
o.processadaDias === undefined || o.processadaDias === null
? "null"
: `now() - interval '${o.processadaDias} days'`;
const erro = o.erro === undefined || o.erro === null ? "null" : `'${o.erro}'`;
return `insert into storage_redaction_queue (organization_id, bucket, object_path, status, attempts, enqueued_at, processed_at, error_message)
values ('${ORG}', 'whatsapp-media', '${caminho}', '${status}', ${o.attempts ?? 0},
now() - interval '${o.enqueuedDias ?? 1} days', ${processada}, ${erro});`;
}
beforeEach(() => {
sql(`
insert into storage.buckets (id, name) values ('whatsapp-media', 'whatsapp-media') on conflict (id) do nothing;
delete from storage_redaction_queue where organization_id = '${ORG}';
delete from storage.objects where name like '${ORG}/%';
delete from messages where organization_id = '${ORG}';
delete from conversations where organization_id = '${ORG}';
delete from channel_sessions where organization_id = '${ORG}';
delete from contacts where organization_id = '${ORG}';
insert into organizations (id, slug, legal_name, display_name, media_retention_days)
values ('${ORG}', 'org-midia-1739', 'Org Midia 1739 LTDA', 'Org Midia 1739', 60)
on conflict (id) do update set media_retention_days = 60;
insert into contacts (id, organization_id, name, phone_number)
values ('${CONTATO}', '${ORG}', 'Cliente', '+5511900001739');
insert into channel_sessions (id, organization_id, waha_session_name, status, webhook_secret_encrypted)
values ('${SESSAO}', '${ORG}', 'midia-1739', 'WORKING', '\\x00'::bytea);
insert into conversations (id, organization_id, contact_id, channel_session_id, status, is_group)
values ('${CONVERSA}', '${ORG}', '${CONTATO}', '${SESSAO}', 'open', false);
insert into messages (id, organization_id, conversation_id, channel_session_id, contact_id,
type, direction, status, body, sent_via, sent_at, created_at, media_storage_path)
values ('${MSG}', '${ORG}', '${CONVERSA}', '${SESSAO}', '${CONTATO}', 'video', 'inbound', 'delivered',
'a mensagem que já perdeu o arquivo', 'external_device', now() - interval '100 days', now() - interval '100 days', '${MENSAGEM}');
${objeto(MENSAGEM, 100)}
`);
});
describe("fn_enfileirar_midia_vencida — reenfileiramento (0434)", () => {
it("deleted reabre pela via da VENCIDA: o pedido novo volta a pending e a mensagem perde o arquivo", () => {
// O arquivo sumiu uma vez (`deleted`); alguém gravou um arquivo NOVO no
// mesmo caminho e a retenção voltou a enfileirar. Com o `do nothing` da
// 0432 este pedido era engolido e o arquivo novo ficava fora da poda.
sql(linhaFila(MENSAGEM, "deleted", { attempts: 1, enqueuedDias: 40, processadaDias: 30 }));
const r = rodar();
expect(r.vencidas).toBeGreaterThanOrEqual(1);
expect(naFila(MENSAGEM)).toBe(1);
expect(estado(MENSAGEM)).toBe("pending|0|NULO|NULO");
expect(lastLine(sql(`select coalesce(media_storage_path, 'NULO') from messages where id = '${MSG}'`))).toBe("NULO");
});
it("skipped reabre pela via do ÓRFÃO: o avatar reaproveitado volta para a fila", () => {
// O caso do avatar: caminho estável por contato, `upsert` a cada refresh.
// «Objeto não existe» (skipped) numa rodada anterior não pode segurar o
// caminho para sempre — o objeto novo de hoje tem de entrar na fila.
sql(objeto(AVATAR_PULADO, 50));
sql(linhaFila(AVATAR_PULADO, "skipped", { attempts: 1, enqueuedDias: 20, processadaDias: 20 }));
const r = rodar();
expect(r.orfas).toBeGreaterThanOrEqual(1);
expect(naFila(AVATAR_PULADO)).toBe(1);
expect(estado(AVATAR_PULADO)).toBe("pending|0|NULO|NULO");
});
it("pending e failed em curso não são tocados — o where do do update é a garantia", () => {
sql(objeto(AVATAR_PENDENTE, 50));
sql(objeto(AVATAR_FALHO, 50));
sql(linhaFila(AVATAR_PENDENTE, "pending", { attempts: 2, enqueuedDias: 2, erro: "storage_remove_failed" }));
sql(linhaFila(AVATAR_FALHO, "failed", { attempts: 3, enqueuedDias: 6, processadaDias: 4, erro: "storage_remove_failed" }));
const r = rodar();
// Nenhum dos dois órfãos entra (o filtro conta só linha EM CURSO como
// «já na fila») e nenhum é reescrito; a única entrada é a vencida do
// fixture, que nunca teve linha nenhuma.
expect(r).toEqual({ vencidas: 1, orfas: 0 });
expect(estado(AVATAR_PENDENTE)).toBe("pending|2|NULO|storage_remove_failed");
expect(estado(AVATAR_FALHO)).toMatch(/^failed\|3\|[^N]/);
expect(estado(AVATAR_FALHO).endsWith("|storage_remove_failed")).toBe(true);
expect(naFila(AVATAR_PENDENTE)).toBe(1);
expect(naFila(AVATAR_FALHO)).toBe(1);
});
it("deleted com mais de 90 dias é expurgada; a recente e a pending antiga ficam", () => {
sql(linhaFila(VELHA, "deleted", { attempts: 1, enqueuedDias: 100, processadaDias: 100 }));
sql(linhaFila(RECENTE, "deleted", { attempts: 1, enqueuedDias: 10, processadaDias: 10 }));
sql(linhaFila(PENDENTE_VELHA, "pending", { attempts: 1, enqueuedDias: 200 }));
rodar();
expect(naFila(VELHA)).toBe(0);
expect(naFila(RECENTE)).toBe(1);
expect(naFila(PENDENTE_VELHA)).toBe(1);
});
it("a rodada seguinte continua idempotente: reabrir não enfileira duas vezes", () => {
sql(linhaFila(MENSAGEM, "deleted", { attempts: 1, enqueuedDias: 40, processadaDias: 30 }));
rodar();
const segunda = rodar();
expect(segunda).toEqual({ vencidas: 0, orfas: 0 });
expect(naFila(MENSAGEM)).toBe(1);
expect(estado(MENSAGEM)).toBe("pending|0|NULO|NULO");
});
});
+22 -3
View File
@@ -40,6 +40,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { AdminShell } from "@/components/admin/AdminShell";
import { ThemeProvider } from "@/lib/theme";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
// Único mock: o `AdminSidebar` é client component e chama `usePathname`. O
@@ -49,6 +50,16 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/admin/inbox",
}));
// O `ThemeProvider` lê `matchMedia` para resolver o tema "system", e o jsdom
// não implementa. Mesmo stub local de `lib/theme.test.tsx` — não há helper
// compartilhado no repo, e inventar um terceiro padrão aqui seria pior.
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})) as unknown as typeof window.matchMedia;
/** Consumidor sem Provider próprio, como o `TenantBadge`. */
function UsaTooltipSemProviderProprio() {
return (
@@ -73,11 +84,19 @@ describe("AdminShell", () => {
])(
"provê o TooltipProvider aos filhos com %s — tela nova nasce funcionando",
(_rotulo, userEmail) => {
// O `<ThemeProvider>` NÃO é maquete: ele repõe aqui o que o layout RAIZ
// (`app/layout.tsx:294`) envolve em torno de toda a árvore, admin
// inclusive. Passou a ser necessário quando a tarja do Modo Plataforma
// ganhou o `ThemeToggle` — até então o admin era a única superfície sem
// forma de trocar de tema. Sem esta linha o teste mediria uma casca que
// não existe em produção: montada fora dos providers da raiz.
expect(() =>
render(
<AdminShell userEmail={userEmail}>
<UsaTooltipSemProviderProprio />
</AdminShell>,
<ThemeProvider>
<AdminShell userEmail={userEmail}>
<UsaTooltipSemProviderProprio />
</AdminShell>
</ThemeProvider>,
),
).not.toThrow();
@@ -0,0 +1,87 @@
/**
* O Admin Plataforma tem como trocar de tema — e por que isso merece catraca.
*
* Até 20/09/2026 o `ThemeToggle` só era montado por `components/shell/UserMenu.tsx`,
* a casca do TENANT. Medido na época, a superfície inteira de admin
* (`app/(admin)`, `app/admin`, `components/admin`) tinha ZERO ocorrência de
* `ThemeToggle`, `useTheme`, `setTheme` ou `data-theme`.
*
* O efeito não era "falta um botão". O atalho `mod+shift+l` é registrado DENTRO
* do `ThemeToggle`, então sem o componente montado não havia botão NEM atalho:
* o único caminho era sair para `/app`, trocar lá, e voltar. E quem cai no admin
* primeiro não é um caso raro — o `install.sh` cria o dono da instalação como
* platform admin, então é a primeira tela de muita gente numa VPS nova.
*
* Por que o teste é sobre a TARJA e não sobre o `AdminShell`: o `AdminShell` não
* tem barra de topo no desktop (o único `<header>` dele é `lg:hidden`), e a tarja
* do Modo Plataforma é o único elemento persistente do topo em todas as larguras.
* Se alguém mover o controle para outro lugar, este teste fica vermelho e a
* mudança passa a ser deliberada — que é o ponto.
*
* LIMITE DECLARADO: isto prova que o controle EXISTE e que ele cicla o tema.
* NÃO prova que a tarja está montada em toda tela de admin — quem monta é
* `app/admin/(protected)/layout.tsx`, pelo `AdminShell`, e guardar esse elo
* exigiria montar um layout async que chama `requirePlatformAdmin`. É o mesmo
* limite que `admin-shell-tooltip.test.tsx` já declara.
*
* Sabotagem que confirma que a guarda vigia: remover `<ThemeToggle />` de
* `components/admin/PlatformModeBanner.tsx` deixa os dois casos vermelhos.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PlatformModeBanner } from "@/components/admin/PlatformModeBanner";
import { ThemeProvider } from "@/lib/theme";
// Mesmo stub local de `lib/theme.test.tsx`: o jsdom não implementa matchMedia,
// e o `ThemeProvider` o consulta para resolver o tema "system".
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})) as unknown as typeof window.matchMedia;
afterEach(() => {
cleanup();
try {
window.localStorage.clear();
} catch {
// localStorage indisponível no ambiente — o provider já degrada sozinho.
}
});
const tarja = () =>
render(
<ThemeProvider>
<PlatformModeBanner />
</ThemeProvider>,
);
describe("Admin Plataforma — troca de tema", () => {
it("a tarja do Modo Plataforma oferece o controle de tema", () => {
tarja();
// O `aria-label` do `ThemeToggle` começa por "Tema: " e traz o estado
// atual. Casar pelo prefixo em vez do texto inteiro evita que renomear o
// estado ("system" → "sistema") derrube a guarda por motivo errado.
expect(screen.getByRole("button", { name: /^Tema: /i })).toBeInTheDocument();
});
it("o controle CICLA de fato — não é um botão decorativo", async () => {
// Sem isto, um `<Button>` sem `onClick` passaria no caso acima: a guarda
// mediria a presença de um elemento, não a existência da capacidade.
const usuario = userEvent.setup();
tarja();
const botao = screen.getByRole("button", { name: /^Tema: /i });
const antes = botao.getAttribute("aria-label");
await usuario.click(botao);
const depois = screen
.getByRole("button", { name: /^Tema: /i })
.getAttribute("aria-label");
expect(depois).not.toBe(antes);
});
});
@@ -0,0 +1,415 @@
/**
* A LISTAGEM DA AGENDA PELO PERÍODO — o que a porta de integração pediu na #1744.
*
* ## Por que este arquivo existe
*
* `crm_list_appointments` foi feita para a IA ACHAR um compromisso, não para um
* calendário externo ler a semana. Faltavam quatro coisas, e nenhuma delas
* tinha teste: o período `de`/`ate` na porta, o teto da janela, o `dia` que
* cortava em UTC (em São Paulo o compromisso das 22h sumia da lista do próprio
* dia) e a paginação — eram 50 itens e o fim.
*
* Aqui se mede a REGRA (`lib/agenda/consulta.ts`), não a tool: rota REST e
* ferramenta MCP chamam esta mesma função, então é onde a régua é uma só. A
* camada da tool tem teste próprio (`mcp-lista-agendamentos-periodo.test.ts`).
*
* ## O dublê do banco
*
* Chain-recorder: cada `from()` registra as chamadas daquela consulta, então o
* teste afirma o FILTRO QUE FOI MONTADO (o instante exato do `gte`, a string do
* cursor) em vez de apenas "não explodiu". É a única forma de prover que o dia
* mudou de fuso — o comportamento está no valor passado ao banco, não no
* retorno.
*/
import { describe, expect, it } from "vitest";
import type { SupabaseClient } from "@supabase/supabase-js";
import { codificarCursorDaLista, listaAgendamentos } from "@/lib/agenda/consulta";
const ORG = "22222222-2222-4222-8222-222222222222";
interface Chamada {
metodo: string;
args: unknown[];
}
interface Consulta {
tabela: string;
chamadas: Chamada[];
}
interface Opcoes {
/** Linhas que `calendar_appointments` devolve. */
linhas?: Array<Record<string, unknown>>;
/** `organizations.timezone`. `null`/ausente = a organização não declarou. */
fuso?: string | null;
/** `crm_lead_links` — o vínculo compromisso→negócio. */
vinculos?: Array<{ lead_id: string; target_id: string }>;
}
function db(opts: Opcoes = {}) {
const consultas: Consulta[] = [];
const from = (tabela: string) => {
const c: Consulta = { tabela, chamadas: [] };
consultas.push(c);
const b: Record<string, unknown> = {};
const encadeia =
(metodo: string) =>
(...args: unknown[]) => {
c.chamadas.push({ metodo, args });
return b;
};
for (const m of ["select", "eq", "in", "order", "limit", "gte", "lt", "lte", "or"]) {
b[m] = encadeia(m);
}
b.maybeSingle = async () => {
c.chamadas.push({ metodo: "maybeSingle", args: [] });
if (tabela === "organizations") {
const tz = opts.fuso;
return { data: tz ? { timezone: tz } : null, error: null };
}
return { data: null, error: null };
};
// Mesmo truque dos dublês do repo: o objeto é thenable, e `await` nele
// resolve conforme a tabela. A execução é REGISTRADA — é assim que um
// teste distingue "montou a consulta" de "leu o banco".
b.then = (ok: (v: unknown) => unknown) => {
c.chamadas.push({ metodo: "execucao", args: [] });
if (tabela === "crm_lead_links") {
return Promise.resolve(ok({ data: opts.vinculos ?? [], error: null }));
}
if (tabela === "calendar_appointments") {
return Promise.resolve(ok({ data: opts.linhas ?? [], error: null }));
}
return Promise.resolve(ok({ data: [], error: null }));
};
return b;
};
return { consultas, supabase: { from } as unknown as SupabaseClient };
}
const chamadasDe = (consultas: Consulta[], tabela: string): Chamada[] =>
consultas.filter((c) => c.tabela === tabela).flatMap((c) => c.chamadas);
/** O valor do N-ésimo `gte("starts_at", …)` da consulta de compromissos. */
const limites = (consultas: Consulta[], metodo: "gte" | "lt") =>
chamadasDe(consultas, "calendar_appointments")
.filter((c) => c.metodo === metodo)
.map((c) => c.args[1]);
describe("a janela de `de`/`ate` (issue #1744)", () => {
it("⭐ acima do máximo recusa, com o número na mensagem — e NÃO consulta o banco", async () => {
// É a única coisa que separa "varre a semana" de "varre o ano por erro de
// digitação". 01/01 a 01/04 são 90 dias, o teto é 62.
const { supabase, consultas } = db();
const r = await listaAgendamentos(supabase, ORG, {
de: "2026-01-01T00:00:00Z",
ate: "2026-04-01T00:00:00Z",
limite: 20,
});
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.codigo).toBe("janela_invalida");
// A recusa ENSINA o teto para o operador…
expect(r.motivoParaOperador).toContain("62");
// …e diz o que FAZER na face do cliente (DECISÃO 20), sem nomear campo.
expect(r.motivoParaCliente).toMatch(/Divida/i);
// A face do cliente não nomeia campo nem apelido técnico (DECISÃO 20).
expect(r.motivoParaCliente).not.toContain("`");
// Recusar ANTES de qualquer leitura é o que torna o teto barato.
expect(consultas).toEqual([]);
});
it("a janela de 62 dias é ACEITA — o teto é do teto, não de um passe menor", async () => {
const { supabase, consultas } = db({ linhas: [] });
const r = await listaAgendamentos(supabase, ORG, {
de: "2026-01-01T00:00:00Z",
ate: "2026-03-04T00:00:00Z", // 62 dias
limite: 20,
});
expect(r.ok).toBe(true);
expect(chamadasDe(consultas, "calendar_appointments").length).toBeGreaterThan(0);
});
it("período invertido é recusa, não lista vazia", async () => {
// Vazio diria "não há nada marcado" quando a pergunta é que não se sustenta.
const { supabase } = db();
const r = await listaAgendamentos(supabase, ORG, {
de: "2026-03-01T00:00:00Z",
ate: "2026-01-01T00:00:00Z",
limite: 20,
});
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.codigo).toBe("janela_invalida");
expect(r.motivoParaOperador).toContain("ate");
});
it("só `de` (sem `ate`) é recusa de período incompleto, não `sem_alvo`", async () => {
// `sem_alvo` mandaria o modelo perguntar "de quem ou de que dia" — fora do
// assunto: o chamador já disse QUANDO, só não disse até quando.
const { supabase } = db();
const r = await listaAgendamentos(supabase, ORG, { de: "2026-09-01T00:00:00Z", limite: 20 });
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.codigo).toBe("janela_invalida");
expect(r.codigo).not.toBe("sem_alvo");
});
it("⭐ com os dois, a organização INTEIRA é listada — nenhum outro recorte é pedido", async () => {
// O `sem_alvo` existia justamente para impedir a varredura geral; com
// `de`+`ate` a varredura é o PEDIDO, e recusá-la deixaria um calendário
// externo sem como desenhar a semana.
const { supabase, consultas } = db({ linhas: [] });
const r = await listaAgendamentos(supabase, ORG, {
de: "2026-09-01T00:00:00Z",
ate: "2026-09-08T00:00:00Z",
limite: 50,
});
expect(r.ok).toBe(true);
// E o recorte chega ao banco como instante, dos dois lados.
expect(limites(consultas, "gte")).toContain("2026-09-01T00:00:00Z");
expect(limites(consultas, "lt")).toContain("2026-09-08T00:00:00Z");
// Sem filtro de contato/lead/responsável: é a agenda de todo mundo.
const eqs = chamadasDe(consultas, "calendar_appointments").filter((c) => c.metodo === "eq");
expect(eqs.map((c) => c.args[0])).toEqual(["organization_id"]);
});
});
describe("o `dia` é do fuso da ORGANIZAÇÃO (issue #1744)", () => {
it("⭐ 12/09 em São Paulo recorta de 03:00Z a 03:00Z — o das 22h não some mais", async () => {
// O defeito medido no código antigo: o corte em UTC fazia três horas do dia
// 11 entrarem e as três últimas do 12 caírem fora. Um compromisso às 22h de
// São Paulo era 01:00Z do dia seguinte — fora da lista do próprio dia.
const { supabase, consultas } = db({ fuso: "America/Sao_Paulo", linhas: [] });
const r = await listaAgendamentos(supabase, ORG, { dia: "2026-09-12", limite: 20 });
expect(r.ok).toBe(true);
expect(limites(consultas, "gte")).toEqual(["2026-09-12T03:00:00.000Z"]);
expect(limites(consultas, "lt")).toEqual(["2026-09-13T03:00:00.000Z"]);
});
it("fuso de offset POSITIVO também fecha certo (Japão, +9)", async () => {
// O mesmo corte, do outro lado: quem tem offset positivo tem o dia começando
// na VÉSPERA em UTC. Só provar São Paulo deixaria metade do mundo sem teste.
const { supabase, consultas } = db({ fuso: "Asia/Tokyo", linhas: [] });
await listaAgendamentos(supabase, ORG, { dia: "2026-09-12", limite: 20 });
expect(limites(consultas, "gte")).toEqual(["2026-09-11T15:00:00.000Z"]);
expect(limites(consultas, "lt")).toEqual(["2026-09-12T15:00:00.000Z"]);
});
it("organização SEM fuso declarado cai no corte antigo, em UTC — degradação declarada", async () => {
// Adivinhar seria pior que o defeito conhecido: `de`/`ate` é o recorte
// exato para quem precisa dele, e o comportamento legado continua íntegro.
const { supabase, consultas } = db({ fuso: null, linhas: [] });
await listaAgendamentos(supabase, ORG, { dia: "2026-09-12", limite: 20 });
expect(limites(consultas, "gte")).toEqual(["2026-09-12T00:00:00Z"]);
expect(limites(consultas, "lt")).toEqual(["2026-09-12T23:59:59.999Z"]);
});
it("o fuso só é lido quando `dia` vem — quem pede período não paga a consulta", async () => {
const { supabase, consultas } = db({ fuso: "America/Sao_Paulo", linhas: [] });
await listaAgendamentos(supabase, ORG, {
de: "2026-09-01T00:00:00Z",
ate: "2026-09-08T00:00:00Z",
limite: 20,
});
expect(chamadasDe(consultas, "organizations")).toEqual([]);
});
});
describe("a paginação por cursor `depois_de` (issue #1744)", () => {
const INICIO = "2026-09-12T15:00:00.000Z";
const ID = "9a1f0000-0000-4000-8000-000000000001";
it("⭐ o cursor vira o predicado (starts_at, id) — com o desempate, senão empatados repetem", async () => {
const { supabase, consultas } = db({ linhas: [] });
const r = await listaAgendamentos(supabase, ORG, {
de: "2026-09-01T00:00:00Z",
ate: "2026-09-08T00:00:00Z",
depoisDe: codificarCursorDaLista({ inicio: INICIO, id: ID }),
limite: 20,
});
expect(r.ok).toBe(true);
const ors = chamadasDe(consultas, "calendar_appointments").filter((c) => c.metodo === "or");
expect(ors).toHaveLength(1);
expect(ors[0]!.args[0]).toBe(
`starts_at.gt.${INICIO},and(starts_at.eq.${INICIO},id.gt.${ID})`,
);
});
it("a ordenação leva o `id` como segunda chave — sem ela o cursor não determina nada", async () => {
const { supabase, consultas } = db({ linhas: [] });
await listaAgendamentos(supabase, ORG, { contactId: ID, limite: 20 });
const orders = chamadasDe(consultas, "calendar_appointments").filter(
(c) => c.metodo === "order",
);
expect(orders.map((c) => c.args[0])).toEqual(["starts_at", "id"]);
});
it("cursor que esta listagem não emitiu é recusa `cursor_invalido`, não exceção", async () => {
const { supabase, consultas } = db({ linhas: [] });
const r = await listaAgendamentos(supabase, ORG, {
contactId: ID,
depoisDe: "isto-nao-e-cursor",
limite: 20,
});
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.codigo).toBe("cursor_invalido");
// A consulta foi MONTADA mas nunca EXECUTADA — a leitura não aconteceu.
expect(chamadasDe(consultas, "calendar_appointments").filter((c) => c.metodo === "execucao")).toEqual([]);
});
it("⭐ `proximo` nasce de `limite + 1` — há mais página, e o cursor aponta para o último VISTO", async () => {
const { supabase } = db({
linhas: [
{ id: ID, starts_at: INICIO, title: "Consulta", ends_at: INICIO, time_zone: "America/Sao_Paulo", status: "confirmed", revision: 1 },
{ id: "9a1f0000-0000-4000-8000-000000000002", starts_at: "2026-09-12T16:00:00.000Z", title: "Retorno", ends_at: "2026-09-12T16:30:00.000Z", time_zone: "America/Sao_Paulo", status: "confirmed", revision: 1 },
],
});
const r = await listaAgendamentos(supabase, ORG, { contactId: ID, limite: 1 });
expect(r.ok).toBe(true);
if (!r.ok) return;
// A página entrega no máximo `limite`…
expect(r.agendamentos).toHaveLength(1);
expect(r.agendamentos[0]!.id).toBe(ID);
// …e o cursor aponta para o ÚLTIMO ITEM DA PÁGINA, não para o descartado:
// é ele que a próxima chamada deve recomeçar DEPOIS.
expect(r.proximo).toBeTruthy();
expect(JSON.parse(Buffer.from(String(r.proximo), "base64url").toString("utf8"))).toEqual({
inicio: INICIO,
id: ID,
});
});
it("quando a página ESGOTA, `proximo` é `null` — o integrante para de perguntar", async () => {
const { supabase } = db({
linhas: [
{ id: ID, starts_at: INICIO, title: "Consulta", ends_at: INICIO, time_zone: "America/Sao_Paulo", status: "confirmed", revision: 1 },
],
});
const r = await listaAgendamentos(supabase, ORG, { contactId: ID, limite: 1 });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.proximo).toBeNull();
});
});
describe("o que a resposta traz para um calendário desenhar (issue #1744)", () => {
const linha = {
id: "9a1f0000-0000-4000-8000-0000000000aa",
title: "Consulta inicial",
starts_at: "2026-09-12T15:00:00.000Z",
ends_at: "2026-09-12T15:30:00.000Z",
time_zone: "America/Sao_Paulo",
status: "confirmed",
revision: 1,
meeting_state: "none",
meeting_url: null,
owner_user_id: "11111111-1111-4111-8111-111111111111",
contact_id: "22222222-2222-4222-8222-222222222222",
contacts: { name: "Maria", display_name: "Maria Silva" },
location_kind: "in_person",
location_details: "Sala 2",
calendar_event_types: { id: "t-1", name: "Consulta", slug: "consulta" },
};
it("⭐ item com contato, tipo e local — o que o calendário mostra", async () => {
const { supabase } = db({ linhas: [linha] });
const r = await listaAgendamentos(supabase, ORG, { de: "2026-09-01T00:00:00Z", ate: "2026-09-08T00:00:00Z", limite: 20 });
expect(r.ok).toBe(true);
if (!r.ok) return;
const item = r.agendamentos[0]!;
// O nome do contato vem do helper (`nomeDoContato`), não de string montada —
// e segue a ordem DELE: `name` (o que a pessoa escolheu) antes de
// `display_name` (o pushName do aparelho).
expect(item.contatoNome).toBe("Maria");
expect(item.tipo).toEqual({ slug: "consulta", nome: "Consulta" });
expect(item.local).toEqual({ tipo: "in_person", descricao: "Sala 2" });
expect(item.leadIds).toEqual([]);
});
it("compromisso SEM tipo (bloqueio do Google) devolve `tipo: null`, nunca um inventado", async () => {
const { supabase } = db({ linhas: [{ ...linha, calendar_event_types: null, location_kind: null, location_details: null }] });
const r = await listaAgendamentos(supabase, ORG, { de: "2026-09-01T00:00:00Z", ate: "2026-09-08T00:00:00Z", limite: 20 });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.agendamentos[0]!.tipo).toBeNull();
expect(r.agendamentos[0]!.local).toEqual({ tipo: null, descricao: null });
});
it("⭐ o vínculo com o negócio vem quando PEDIDO, e a consulta extra acontece", async () => {
const { supabase, consultas } = db({
linhas: [linha],
vinculos: [{ lead_id: "33333333-3333-4333-8333-333333333333", target_id: linha.id }],
});
const r = await listaAgendamentos(supabase, ORG, {
de: "2026-09-01T00:00:00Z",
ate: "2026-09-08T00:00:00Z",
comLeadIds: true,
limite: 20,
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.agendamentos[0]!.leadIds).toEqual(["33333333-3333-4333-8333-333333333333"]);
expect(chamadasDe(consultas, "crm_lead_links").length).toBeGreaterThan(0);
});
it("quem NÃO pede o vínculo (a grade da tela) não paga a consulta extra", async () => {
// Custo medido e opt-in: uma segunda ida ao banco por página, para um
// campo que a tela não publica.
const { supabase, consultas } = db({ linhas: [linha] });
await listaAgendamentos(supabase, ORG, {
de: "2026-09-01T00:00:00Z",
ate: "2026-09-08T00:00:00Z",
limite: 20,
});
expect(chamadasDe(consultas, "crm_lead_links")).toEqual([]);
});
it("CONTROLE: sem nenhuma consulta o teste acima passaria por vacuidade — há linhas de verdade", async () => {
// Rede contra falso verde: se o dublê devolvesse `[]`, os casos de cima
// falhariam, mas este prova que o caminho feliz existe.
const { supabase } = db({ linhas: [linha] });
const r = await listaAgendamentos(supabase, ORG, { de: "2026-09-01T00:00:00Z", ate: "2026-09-08T00:00:00Z", limite: 20 });
expect(r.ok && r.agendamentos.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,73 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { vi } from "vitest";
import "@testing-library/jest-dom/vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
usePathname: () => "/app/ai/agents/new",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() } }));
import { buildState } from "@/app/app/ai/agents/[id]/_components/AgentForm";
import { AgentForm } from "@/app/app/ai/agents/[id]/_components/AgentForm";
function renderizarEditorNovo() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<AgentForm
mode="create"
credentials={[] as never}
channelSessions={[] as never}
/>
</QueryClientProvider>,
);
}
describe("configuração de callbacks do editor de agentes", () => {
it("abre versões legadas como habilitadas sem mexer nos fluxos normais", () => {
const state = buildState({
version: {
followup: { enabled: true, flow_pointer_ids: ["flow-1"] },
} as never,
t: (text) => text,
});
expect(state.followup.callback_enabled).toBe(true);
expect(state.followup.enabled).toBe(true);
expect(state.followup.flow_pointer_ids).toEqual(["flow-1"]);
});
it("carrega callback_enabled=false sem desligar os fluxos normais", () => {
const state = buildState({
version: {
followup: {
enabled: true,
flow_pointer_ids: ["flow-1"],
callback_enabled: false,
},
} as never,
t: (text) => text,
});
expect(state.followup.callback_enabled).toBe(false);
expect(state.followup.enabled).toBe(true);
expect(state.followup.flow_pointer_ids).toEqual(["flow-1"]);
});
it("expõe controles separados na tela de configuração", () => {
renderizarEditorNovo();
expect(
screen.getByRole("switch", {
name: "Permitir que o agente marque novos retornos por conta própria",
}),
).toBeChecked();
expect(
screen.getByRole("switch", { name: "Habilitar gatilhos automáticos de follow-up" }),
).not.toBeChecked();
});
});
@@ -103,4 +103,22 @@ describe("versionCreateSchema aceita as flags por-agente que a tela edita", () =
expect(parsed.success && parsed.data.split_messages).toBe(false);
expect(parsed.success && parsed.data.split_max_chars).toBe(600);
});
it("aceita callback_enabled como opção independente dentro de followup", () => {
const parsed = versionCreateSchema.safeParse({
...base,
followup: {
enabled: true,
flow_pointer_ids: ["33333333-3333-4333-8333-333333333333"],
callback_enabled: false,
},
});
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.followup).toMatchObject({
enabled: true,
flow_pointer_ids: ["33333333-3333-4333-8333-333333333333"],
callback_enabled: false,
});
});
});
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* `followup.callback_enabled: false` tem de tirar `crm_schedule_followup` do
* que o turno ENTREGA ao modelo — não só da regra pura em
* `lib/followup/callback-policy.ts`. Os testes da regra ficam verdes mesmo que
* `buildMcpTurnTools` pare de chamá-la, e aí o controle da tela não faz nada.
*
* Este arquivo amarra a ponta MCP (Conversador e Operador passam por ela). A
* ponta nativa (`schedule_followup`, em `runAgentTurn`) não tem harness sem
* banco: fica como lacuna declarada, não coberta por grep de fonte.
*/
const pickToolsFromMcp = vi.fn((input: { toolIds: string[] }) =>
Object.fromEntries(input.toolIds.map((id) => [id, {}])),
);
vi.mock("@/lib/ai/runtime/tools", () => ({ pickToolsFromMcp }));
vi.mock("@/lib/ai/runtime/mcp_token", () => ({
mintEphemeralToken: vi.fn(async () => ({ id: "tok-1" })),
revokeEphemeralToken: vi.fn(async () => {}),
}));
vi.mock("@/lib/instalacao/modulos", () => ({ modulosLigados: vi.fn(async () => []) }));
vi.mock("@/lib/atendimento/fronteira-server", () => ({
currentExecutionJob: () => undefined,
currentExecutionBoundary: () => undefined,
}));
const { buildMcpTurnTools } = await import("@/lib/agent-engine/edge/crm/mcp-tools");
const TOOLS = ["crm_schedule_followup", "crm_list_followups", "crm_cancel_followup"];
async function idsEntreguesAoModelo(followup: unknown): Promise<string[]> {
const agentConfig = { agentId: "agente-1", toolIds: TOOLS, pipelineIds: [], followup };
const out = await buildMcpTurnTools(
{ supabase: {} as never },
{ organizationId: "org-1", jobId: "job-1" },
agentConfig as never,
{ warn: vi.fn() } as never,
);
expect(pickToolsFromMcp).toHaveBeenCalledTimes(1);
expect(out?.toolIds).toEqual(pickToolsFromMcp.mock.calls[0]![0].toolIds);
return pickToolsFromMcp.mock.calls[0]![0].toolIds;
}
beforeEach(() => {
pickToolsFromMcp.mockClear();
});
describe("buildMcpTurnTools respeita followup.callback_enabled", () => {
it("desligado: o criador de retorno não chega ao modelo; consultar e cancelar ficam", async () => {
const ids = await idsEntreguesAoModelo({ enabled: true, callback_enabled: false });
expect(ids).not.toContain("crm_schedule_followup");
expect(ids).toEqual(["crm_list_followups", "crm_cancel_followup"]);
});
it("controle: versão legada sem o campo entrega as três", async () => {
const ids = await idsEntreguesAoModelo({ enabled: false, flow_pointer_ids: [] });
expect(ids).toEqual(TOOLS);
});
it("controle: ligado explícito entrega as três", async () => {
const ids = await idsEntreguesAoModelo({ enabled: false, callback_enabled: true });
expect(ids).toEqual(TOOLS);
});
});
+96 -11
View File
@@ -18,6 +18,10 @@ const CREDS = {
const ESCOPO = { organizationId: "org-1", sessionRef: "106540352242922" };
/** Uma página da coleção de modelos, na forma em que a plataforma a devolve. */
const resposta = (data: unknown[]) =>
new Response(JSON.stringify({ data }), { status: 200 });
beforeEach(() => {
vi.mocked(resolveGraphPartnerCreds).mockReset();
vi.mocked(resolveGraphPartnerCreds).mockResolvedValue(CREDS);
@@ -90,23 +94,104 @@ describe("modelos do parceiro Graph-compatível", () => {
expect(body.name).toBe("novo");
});
it("apaga pelo nome", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
it("apaga UMA variante: resolve o id por nome+idioma e manda o hsm_id junto do name", async () => {
// O que a plataforma diz (api-reference/whatsapp/templates/deletar-template):
// sem `hsm_id` o DELETE leva TODOS os idiomas daquele nome; com `hsm_id`
// "apenas aquela versão é removida, e o name continua obrigatório".
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
// O filtro `name` devolve o nome em TODOS os idiomas — dois ids.
resposta([
{ id: "5400801403478858", name: "antigo", language: "pt_BR" },
{ id: "900000000000001", name: "antigo", language: "en_US" },
]),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 }));
// `language` é obrigatório no contrato desde o changelog de 28/08 do
// provedor intermediado: lá, apagar por nome SEM idioma apaga TODAS as
// variantes, e a assinatura obriga quem chama a dizer QUAL morre. O adapter
// do Graph ignora o campo — o que se mede aqui é a URL —, mas o contrato é
// um só para todos os canais.
await graphPartnerTemplateOps.remove({ ...ESCOPO, name: "antigo", language: "pt_BR" });
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toContain("/message_templates?name=antigo");
const [consulta] = fetchSpy.mock.calls[0]!;
expect(String(consulta)).toContain("/message_templates?limit=100");
expect(String(consulta)).toContain("fields=id,name,language");
expect(String(consulta)).toContain("&name=antigo");
const [url, init] = fetchSpy.mock.calls[1]!;
expect(String(url)).toBe(
"https://cloud.example.test/v1/366634483210360/message_templates?name=antigo&hsm_id=5400801403478858",
);
expect(init?.method).toBe("DELETE");
});
it("sem a variante na conta não há id — e sem id nada sai daqui", async () => {
// O caminho antigo (DELETE pelo nome) apagaria o pt_BR junto com o en_US.
// Aqui a consulta acha SÓ a en_US, e a recusa vem antes de qualquer DELETE.
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(resposta([{ id: "900000000000001", name: "antigo", language: "en_US" }]));
await expect(
graphPartnerTemplateOps.remove({ ...ESCOPO, name: "antigo", language: "pt_BR" }),
).rejects.toThrow(/graph_partner_template_variante_ausente/);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls.every(([url, init]) => init?.method !== "DELETE")).toBe(true);
});
it("⭐ edita a variante por ID — POST no nó dela, nunca na coleção", async () => {
const NOVOS = [{ type: "BODY", text: "Oi {{1}}" }];
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
resposta([
{ id: "777", name: "boas_vindas", language: "pt_BR" },
{ id: "888", name: "boas_vindas", language: "en_US" },
{ id: "999", name: "outro", language: "pt_BR" },
]),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ success: true, id: "777", name: "boas_vindas", category: "UTILITY" }),
{ status: 200 },
),
);
const r = await graphPartnerTemplateOps.update({
...ESCOPO,
name: "boas_vindas",
language: "pt_BR",
patch: { components: NOVOS },
});
const [consulta] = fetchSpy.mock.calls[0]!;
expect(String(consulta)).toContain("&name=boas_vindas");
const [url, init] = fetchSpy.mock.calls[1]!;
// O caminho da edição é SÓ o id, sem o waba_id (OpenAPI `editar-template`).
expect(String(url)).toBe("https://cloud.example.test/v1/777");
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({ components: NOVOS });
// O nó não devolve o idioma (ele identifica a variante e não muda): ele
// volta de quem chamou, que o escolheu na tela.
expect(r).toMatchObject({ name: "boas_vindas", language: "pt_BR" });
});
it("sem a variante escolhida a edição também não sai do lugar", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(resposta([{ id: "888", name: "boas_vindas", language: "en_US" }]));
await expect(
graphPartnerTemplateOps.update({
...ESCOPO,
name: "boas_vindas",
language: "pt_BR",
patch: { components: [{ type: "BODY", text: "Oi" }] },
}),
).rejects.toThrow(/graph_partner_template_variante_ausente/);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(
fetchSpy.mock.calls.every(([, init]) => (init as { method?: string } | undefined)?.method !== "POST"),
).toBe(true);
});
it("pagina pelo `next` do mesmo host, e nunca leva o token para outro", async () => {
const pagina = (next: string | undefined, name: string) =>
new Response(
@@ -6,6 +6,12 @@ import type * as Canais from "@/lib/channels";
/**
* A rota de modelos do canal Datafy (recorte do #1130, @vgamkt): desligada por
* padrão, com papel, corpo validado, organização da sessão e contrato derivado.
*
* Inclui o editar/apagar que a #1734 religou: a rota recusava os dois com 422
* porque o DELETE desta plataforma, por nome só, levava TODAS as variantes de
* idioma enquanto a tela apagaria uma (#1728). O alvo agora resolve o id da
* variante por nome+idioma, e a rota passa pela mesma `executarGestao` do outro
* parceiro — inclusive pela pergunta "onde este modelo está em uso?".
*/
const h = vi.hoisted(() => ({
role: vi.fn(),
@@ -18,6 +24,7 @@ const h = vi.hoisted(() => ({
remove: vi.fn(),
upserts: [] as Record<string, unknown>[],
espelho: [] as Record<string, unknown>[],
tabelas: {} as Record<string, unknown[]>,
}));
vi.mock("@/lib/auth/require-role", () => ({ requireRole: h.role }));
vi.mock("@/lib/impersonate/support", () => ({ requireSupportWrite: async () => null }));
@@ -35,7 +42,12 @@ vi.mock("@/lib/supabase/admin", () => ({
const q = {
select: () => q,
eq: () => q,
in: () => q,
not: () => q,
order: () => q,
// O apagar tira a linha do espelho depois de falar com a plataforma
// (lib/channels/gestao-de-modelos.ts) — sem `delete` o mock estourava.
delete: () => q,
maybeSingle: async () => ({
data: { id: "sess-1", provider: "datafy", datafy_phone_number_id: "PN" },
error: null,
@@ -45,7 +57,10 @@ vi.mock("@/lib/supabase/admin", () => ({
return { error: null };
},
then: (ok: (r: unknown) => unknown) =>
ok({ data: tabela === "meta_templates" ? h.espelho : null, error: null }),
ok({
data: tabela === "meta_templates" ? h.espelho : (h.tabelas[tabela] ?? []),
error: null,
}),
};
return q;
},
@@ -64,6 +79,7 @@ beforeEach(() => {
vi.clearAllMocks();
h.upserts = [];
h.espelho = [];
h.tabelas = {};
h.ligado.mockReturnValue(true);
h.role.mockResolvedValue({ ok: true, org: { orgId: "org-da-sessao" }, user: { id: "u-1", idioma: "pt-BR" } });
h.find.mockResolvedValue({ id: "sess-1", archivedAt: null });
@@ -102,16 +118,68 @@ describe("rota de modelos do canal parceiro Graph", () => {
expect(h.list).not.toHaveBeenCalled();
});
it("⭐ editar e apagar não existem nesta rota: 422 e a plataforma não é tocada", async () => {
// O DELETE desta plataforma é por nome e leva TODAS as variantes de idioma,
// enquanto a tela apagaria uma só (#1728). Até existir o apagar por variante,
// a rota recusa — e o adapter tem update/remove para a recusa ser da ROTA.
it("⭐ editar e apagar miram a VARIANTE (nome + idioma) e passam pela gestão", async () => {
// Era 422 e a plataforma não era tocada: o DELETE desta plataforma, por
// nome só, apagava TODAS as variantes de idioma enquanto a tela apagaria
// uma (#1728). Desde a #1734 o alvo resolve o id da variante por
// nome+idioma antes de falar com a plataforma (#1734), e a rota usa a
// mesma `executarGestao` da rota do outro parceiro.
h.espelho = [{ id: "tpl-1", name: "boas_vindas", language: "pt_BR" }];
const alvo = { name: "boas_vindas", language: "pt_BR" };
expect((await post({ acao: "apagar", ...alvo, confirmado: true })).status).toBe(422);
expect((await post({ acao: "editar", ...alvo, components: CORPO })).status).toBe(422);
const apagado = await post({ acao: "apagar", ...alvo, confirmado: true });
expect(apagado.status).toBe(200);
expect(h.remove).toHaveBeenCalledTimes(1);
expect(h.remove).toHaveBeenCalledWith({
organizationId: "org-da-sessao",
sessionRef: "PN",
name: "boas_vindas",
language: "pt_BR",
});
expect(h.audit).toHaveBeenCalledWith(
expect.objectContaining({
action: "template.deleted",
actorUserId: "u-1",
organizationId: "org-da-sessao",
metadata: { name: "boas_vindas", language: "pt_BR" },
}),
);
const editado = await post({ acao: "editar", ...alvo, components: CORPO });
expect(editado.status).toBe(200);
expect(h.update).toHaveBeenCalledWith({
organizationId: "org-da-sessao",
sessionRef: "PN",
name: "boas_vindas",
language: "pt_BR",
patch: { components: CORPO },
});
expect(h.audit).toHaveBeenCalledWith(
expect.objectContaining({ action: "template.updated", actorUserId: "u-1" }),
);
// A organização é a da SESSÃO: um corpo mandando outra não muda o dono.
await post({ acao: "apagar", ...alvo, organization_id: "org-do-atacante", confirmado: true });
expect(h.remove).toHaveBeenLastCalledWith(
expect.objectContaining({ organizationId: "org-da-sessao" }),
);
expect(h.update.mock.calls[0]![0]).toMatchObject({ organizationId: "org-da-sessao" });
// Sem idioma não há variante que mirar — a chamada é 422 e nada é tocado.
h.remove.mockClear();
expect((await post({ acao: "apagar", name: "boas_vindas" })).status).toBe(422);
expect(h.remove).not.toHaveBeenCalled();
});
it("apagar modelo em uso responde 409 com onde ele está, e não apaga", async () => {
h.espelho = [{ id: "tpl-1", name: "boas_vindas", language: "pt_BR" }];
h.tabelas.followup_flow_pointers = [
{ name: "Remarketing", active_version_id: null, draft_graph: { t: "tpl-1" } },
];
const r = await post({ acao: "apagar", name: "boas_vindas", language: "pt_BR" });
expect(r.status).toBe(409);
const j = (await r.json()) as { error: { code: string; details: { usos: string[] } } };
expect(j.error.code).toBe("template_in_use");
expect(j.error.details.usos).toEqual(["Follow-up «Remarketing»"]);
expect(h.remove).not.toHaveBeenCalled();
expect(h.update).not.toHaveBeenCalled();
expect(h.list).not.toHaveBeenCalled();
expect(h.audit).not.toHaveBeenCalled();
});
+36
View File
@@ -0,0 +1,36 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* QUANTO O SERVIDOR SEGURA UMA CONEXÃO OCIOSA — na imagem e no e2e, o mesmo.
*
* Com o padrão do Node (5 s + 1 s de `keepAliveTimeoutBuffer`), o servidor fecha
* a conexão ociosa aos 6 s. Quem reaproveita conexão sem prazo próprio — o
* `page.request` do Playwright, o proxy na frente da VPS — escreve no socket no
* instante em que ele morre e recebe `ECONNRESET`/`socket hang up`. Medido nas
* runs 36069450590 e 36188123417: o GET que morreu veio 5988 e 5998 ms depois da
* chamada anterior. O porquê do valor está no comentário do `Dockerfile`.
*
* Os dois lugares têm de andar juntos: se só o e2e sobe, a suíte deixa de ver a
* corrida que o produto ainda tem.
*/
const raiz = path.resolve(__dirname, "../..");
const ler = (arquivo: string) => fs.readFileSync(path.join(raiz, arquivo), "utf8");
describe("keep-alive do servidor", () => {
const naImagem = Number(/KEEP_ALIVE_TIMEOUT=(\d+)/.exec(ler("Dockerfile"))?.[1]);
const noE2e = Number(/--keepAliveTimeout (\d+)/.exec(ler("playwright.config.ts"))?.[1]);
it("o e2e sobe o servidor com o mesmo prazo da imagem", () => {
expect(naImagem).toBeGreaterThan(0);
expect(noE2e).toBe(naImagem);
});
it("o prazo passa do reaproveitamento do proxy (Caddy: 2 min; Traefik: 90 s)", () => {
// Quem fecha primeiro tem de ser o proxy; senão ele manda a requisição num
// socket morto e o usuário vê 502.
expect(naImagem).toBeGreaterThan(120_000);
});
});
@@ -0,0 +1,254 @@
/**
* `crm_list_appointments` COMO PORTA DE INTEGRAÇÃO (issue #1744).
*
* ## A fronteira
*
* `agenda-lista-por-periodo-e-cursor.test.ts` mede a REGRA em
* `lib/agenda/consulta.ts`. Aqui se mede o que a FERRAMENTA acrescenta por cima
* dela: o repasse dos parâmetros novos, o formato do que um calendário externo
* lê (contato, atendente, tipo, local, vínculos) e a face de cada recusa.
*
* A coleta fica mockada de propósito — as duas suítes medindo a mesma coisa
* seria a terceira lista da qual o repo avisa.
*
* ⚠️ O NOME DO ATENDENTE NÃO É MOCKADO: ele passa pelo helper de verdade
* (`lib/mcp/tools/_users.ts`), que é a regra de exposição de responsável. Se
* alguém trocar o helper por uma montagem de nome à mão, este arquivo cai — e
* se a exposição alargar além do `full_name`, a asserção de não-vazamento cai.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SupabaseClient } from "@supabase/supabase-js";
import type * as AgendaConsulta from "@/lib/agenda/consulta";
import type { McpContext } from "@/lib/mcp/types";
vi.mock("@/lib/agenda/consulta", async (original) => {
const real = await original<typeof AgendaConsulta>();
return { ...real, listaAgendamentos: vi.fn() };
});
const { listaAgendamentos } = await import("@/lib/agenda/consulta");
const { crmListAppointments } = await import("@/lib/mcp/tools/agendamento");
const DONO = "11111111-1111-4111-8111-111111111111";
const CONTATO = "22222222-2222-4222-8222-222222222222";
/**
* O client do agente É o do admin no MCP — e é nele que o helper de nomes lê
* `user_metadata.full_name`. Aqui se reproduz só essa superfície: o resto da
* lista está mockado.
*/
const ctx: McpContext = {
organizationId: "org-1",
role: "agent",
actor: { type: "ai_agent", id: "ag-1", role: "ai_operator" },
apiTokenId: "tok-1",
requestId: "req-1",
supabase: {
auth: {
admin: {
getUserById: async () => ({
data: {
user: {
user_metadata: {
full_name: "Ana Souza",
// LGPD: isto NUNCA pode aparecer na resposta.
email: "ana@clinica.com.br",
phone: "+5511999990000",
},
},
},
error: null,
}),
},
},
} as unknown as SupabaseClient,
};
const ITEM = {
id: "9a1f0000-0000-4000-8000-0000000000aa",
titulo: "Consulta inicial",
iniciaEm: "2026-09-12T15:00:00.000Z",
terminaEm: "2026-09-12T15:30:00.000Z",
fuso: "America/Sao_Paulo",
situacao: "confirmed",
donoId: DONO,
contatoId: CONTATO,
contatoNome: "Maria Silva",
tipo: { slug: "consulta", nome: "Consulta" },
local: { tipo: "in_person", descricao: "Sala 2" },
leadIds: ["33333333-3333-4333-8333-333333333333"],
};
beforeEach(() => vi.clearAllMocks());
describe("os parâmetros novos chegam à regra", () => {
it("⭐ `de`, `ate` e `depois_de` são repassados, com o vínculo pedido", async () => {
vi.mocked(listaAgendamentos).mockResolvedValue({ ok: true, agendamentos: [] });
await crmListAppointments.handler(
{
de: "2026-09-01T00:00:00-03:00",
ate: "2026-09-08T00:00:00-03:00",
depois_de: "eyJpbmljaW8iOiIyMDI2",
limite: 30,
},
ctx,
);
const params = vi.mocked(listaAgendamentos).mock.calls[0]![2];
expect(params.de).toBe("2026-09-01T00:00:00-03:00");
expect(params.ate).toBe("2026-09-08T00:00:00-03:00");
expect(params.depoisDe).toBe("eyJpbmljaW8iOiIyMDI2");
expect(params.limite).toBe(30);
// Um calendário lê o vínculo com o negócio; a grade da tela não — por isso
// é opção na regra e OBRIGATÓRIA aqui.
expect(params.comLeadIds).toBe(true);
});
it("o schema aceita instante ISO com fuso e recusa data sem hora", () => {
const shape = crmListAppointments.inputSchema;
expect(() => shape.de.parse("2026-09-01T00:00:00-03:00")).not.toThrow();
expect(() => shape.de.parse("2026-09-01")).toThrow();
expect(() => shape.ate.parse("2026-09-08T00:00:00Z")).not.toThrow();
});
it("a descrição da tool anuncia o período, o teto e o cursor", () => {
const d = crmListAppointments.description;
expect(d).toContain("de+ate");
expect(d).toContain("62 dias");
// Sem isto o integrante guarda 50 itens e acha que acabou a agenda.
expect(d).toContain("depois_de");
expect(d).toContain("proximo");
});
it("⭐ o campo `dia` diz que o dia é do fuso da ORGANIZAÇÃO", () => {
// A issue aceitava dois caminhos — trocar o corte OU avisar na descrição.
// Aqui se fazem os DOIS: o corte mudou (medido na outra suíte) e a
// descrição não deixa ninguém de surpresa.
const dia = crmListAppointments.inputSchema.dia as unknown as { description?: string };
expect(dia.description ?? "").toMatch(/FUSO DA ORGANIZAÇÃO/i);
expect(dia.description ?? "").toContain("AAAA-MM-DD");
});
});
describe("a resposta tem o que um calendário mostra (issue #1744)", () => {
it("⭐ contato, atendente, tipo, local e vínculos no mesmo item", async () => {
vi.mocked(listaAgendamentos).mockResolvedValue({ ok: true, agendamentos: [ITEM] });
const r = (await crmListAppointments.handler({ de: "2026-09-01T00:00:00Z", ate: "2026-09-08T00:00:00Z" }, ctx)) as {
compromissos: Array<Record<string, unknown>>;
proximo: string | null;
};
expect(r.compromissos).toHaveLength(1);
expect(r.compromissos[0]).toMatchObject({
id: ITEM.id,
titulo: ITEM.titulo,
inicio: ITEM.iniciaEm,
fim: ITEM.terminaEm,
fuso: ITEM.fuso,
situacao: ITEM.situacao,
contato: { id: CONTATO, nome: "Maria Silva" },
atendente: { id: DONO, nome: "Ana Souza" },
tipo: { slug: "consulta", nome: "Consulta" },
local: { tipo: "in_person", descricao: "Sala 2" },
lead_ids: ["33333333-3333-4333-8333-333333333333"],
});
expect(r.proximo).toBeNull();
});
it("⭐ as chaves antigas contato_id/atendente_id seguem na resposta (compatibilidade)", async () => {
vi.mocked(listaAgendamentos).mockResolvedValue({ ok: true, agendamentos: [ITEM] });
const r = (await crmListAppointments.handler({ contact_id: CONTATO }, ctx)) as {
compromissos: Array<Record<string, unknown>>;
};
// `toMatchObject` acima não reprova chave a menos: esta asserção é a que vê
// um integrador que lia a forma anterior à #1744 passar a receber `undefined`.
expect(r.compromissos[0]?.contato_id).toBe(CONTATO);
expect(r.compromissos[0]?.atendente_id).toBe(DONO);
});
it("⭐ o nome do atendente vem do helper — e SÓ o nome sai", async () => {
vi.mocked(listaAgendamentos).mockResolvedValue({ ok: true, agendamentos: [ITEM] });
const r = await crmListAppointments.handler({ contact_id: CONTATO }, ctx);
const json = JSON.stringify(r);
// É a mesma régua de exposição de responsável: `full_name`, e nada além.
expect(json).toContain("Ana Souza");
expect(json).not.toContain("ana@clinica.com.br");
expect(json).not.toContain("+5511999990000");
// E o contato NÃO é montado aqui: o rótulo vem da regra da biblioteca.
expect(json).toContain("Maria Silva");
});
it("compromisso sem dono não consulta ninguém nem promete nome", async () => {
vi.mocked(listaAgendamentos).mockResolvedValue({
ok: true,
agendamentos: [{ ...ITEM, donoId: null }],
});
const r = (await crmListAppointments.handler({ contact_id: CONTATO }, ctx)) as {
compromissos: Array<{ atendente: { id: string | null; nome: string | null } }>;
};
expect(r.compromissos[0]!.atendente).toEqual({ id: null, nome: null });
});
it("⭐ `proximo` é repassado adiante — é o integrante que decide continuar", async () => {
vi.mocked(listaAgendamentos).mockResolvedValue({
ok: true,
agendamentos: [ITEM],
proximo: "eyJpbmljaW8iOiIyMDI2In0",
});
const r = (await crmListAppointments.handler({ de: "2026-09-01T00:00:00Z", ate: "2026-09-08T00:00:00Z" }, ctx)) as {
proximo: string | null;
};
expect(r.proximo).toBe("eyJpbmljaW8iOiIyMDI2In0");
});
});
describe("as recusas saem como RESPOSTA, na face do cliente (DECISÃO 20)", () => {
it.each([
["janela_invalida", "Divida em partes de até"],
["cursor_invalido", "Comece de novo do início"],
["sem_alvo", "Pergunte de qual cliente"],
])("%s vira { compromissos: [], motivo, mensagem } sem exceção", async (codigo, trecho) => {
vi.mocked(listaAgendamentos).mockResolvedValue({
ok: false,
codigo,
motivoParaOperador: `operador: ${codigo}`,
motivoParaCliente: trecho,
} as never);
const r = (await crmListAppointments.handler({ de: "2026-09-01T00:00:00Z", ate: "2026-10-10T00:00:00Z" }, ctx)) as {
compromissos: unknown[];
motivo: string;
mensagem: string;
};
expect(r.compromissos).toEqual([]);
expect(r.motivo).toBe(codigo);
expect(r.mensagem).toBe(trecho);
// A face do OPERADOR (que nomeia campo) não vaza para o modelo/cliente.
expect(r.motivo).not.toContain("operador:");
});
it("CONTROLE: o sucesso não traz motivo nem mensagem", async () => {
// Sem este par, um handler que devolvesse `motivo` SEMPRE passaria nos
// cinco casos acima.
vi.mocked(listaAgendamentos).mockResolvedValue({ ok: true, agendamentos: [] });
const r = (await crmListAppointments.handler({ contact_id: CONTATO }, ctx)) as {
motivo?: string;
mensagem?: string;
compromissos: unknown[];
};
expect(r.motivo).toBeUndefined();
expect(r.mensagem).toBeUndefined();
expect(r.compromissos).toEqual([]);
});
});
@@ -0,0 +1,74 @@
// RENOMEAR A ETAPA NO CABEÇALHO DO QUADRO (extraído do #1738).
//
// O corte de papel é da ROTA (`requireRole("manager")`); a tela só não oferece
// o campo a quem a rota recusaria. O que este arquivo guarda é o contrato do
// campo: salva ao confirmar, com o texto aparado, e nunca salva o que não mudou
// nem o que foi cancelado com Escape.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DragDropContext } from "@hello-pangea/dnd";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (s: string) => s }));
vi.mock("@/components/kanban/KanbanCard", () => ({ KanbanCard: () => null }));
import { StageColumn } from "@/components/kanban/StageColumn";
import type { Stage } from "@/lib/kanban/types";
const etapa = {
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
pipeline_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
name: "Proposta",
position: 1,
color: null,
} as unknown as Stage;
function montar(podeRenomear: boolean) {
const onRenomear = vi.fn();
render(
<DragDropContext onDragEnd={() => {}}>
<StageColumn
stage={etapa}
leads={[]}
pipelineId={etapa.pipeline_id}
podeRenomear={podeRenomear}
onRenomear={onRenomear}
/>
</DragDropContext>,
);
return onRenomear;
}
const campo = () => screen.getByTestId("nome-etapa-quadro") as HTMLInputElement;
describe("nome da etapa no cabeçalho do quadro", () => {
it("quem não pode renomear vê só o título, sem campo", () => {
montar(false);
expect(screen.getByRole("heading", { name: "Proposta" })).toBeTruthy();
expect(screen.queryByTestId("nome-etapa-quadro")).toBeNull();
});
it("Enter salva o nome aparado", async () => {
const onRenomear = montar(true);
await userEvent.clear(campo());
await userEvent.type(campo(), " Negociação {Enter}");
expect(onRenomear).toHaveBeenCalledExactlyOnceWith("Negociação");
});
it("nome igual ou vazio não salva e o campo volta ao nome gravado", async () => {
const onRenomear = montar(true);
await userEvent.type(campo(), " {Enter}");
await userEvent.clear(campo());
await userEvent.type(campo(), " {Enter}");
expect(onRenomear).not.toHaveBeenCalled();
expect(campo().value).toBe("Proposta");
});
it("Escape desfaz sem salvar", async () => {
const onRenomear = montar(true);
await userEvent.type(campo(), " fechada{Escape}");
expect(onRenomear).not.toHaveBeenCalled();
expect(campo().value).toBe("Proposta");
});
});
@@ -0,0 +1,285 @@
/**
* A PODA DA CAPTAÇÃO ORDENA O LOTE E NÃO ENGOLE A FALHA — issue #1721.
*
* ─── O defeito que este arquivo existe para impedir ─────────────────────────
*
* A poda de `webhook_lead_captures` nasceu no molde antigo e ficou nele em dois
* pontos, achados na triagem do #1719 (a poda de rascunhos, a décima do
* `data-retention`, que a casa acabou de consertar):
*
* 1. `.limit(lote)` SEM `order`. O PostgREST 12.2 recusa isso com 400
* PGRST109 (medido no v12.2.12 pelo mantenedor; com `order=id` volta
* 200) — de modo que, em vez de apagar, a poda recebia o erro e devolvia
* ZERO. E mesmo onde o banco aceitasse: sem ordem cada lote apaga um
* subconjunto ARBITRÁRIO, e duas rodadas com o mesmo backlog não apagam
* as mesmas linhas. A drenagem deixa de ser reproduzível.
* 2. O erro do DELETE era ENGOLIDO: `logger.warn` e `{ apagadas: 0 }`. Na
* resposta do cron, "o banco recusou o DELETE" e "não havia nada vencido"
* são a MESMA linha. O único sinal vivia num log de contêiner atrás de um
* `curl -fsS` que joga tudo para /dev/null.
*
* ─── Por que estes casos são do RITUAL, não do detalhe ──────────────────────
*
* A régua aqui não é a forma da chamada — é que a poda não possa voltar a ser
* morda. Por isso o dublê de banco recusa `limit` sem `order` ANTES de devolver
* qualquer coisa, como o PostgREST 12.2 recusa (o mesmo truque que o mantenedor
* usou no dublê da décima poda, no #1719): tirar o `.order()` do código faz
* ESTE arquivo reprovar, e não um teste de presença que passaria por
* vacuidade. E o canal da falha é medido no HANDLER do cron, que é quem
* responde — a exceção tem de virar log de erro, linha de trilha com
* `falhou: true` e Sentry, e o relatório do arquivo forense (que JÁ estava
* feito) não pode ser engolido junto.
*
* O que estes casos NÃO medem: o que o Postgres aceita de fato, medido contra
* um banco real em `tests/invariants/`. Aqui o dublê só carrega a régua.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { podarHistoricoDeCaptacao } from "@/lib/webhooks/retencao-da-captacao";
vi.mock("@/lib/env", () => ({
env: {
INTERNAL_CRON_SECRET: "segredo",
INTERNAL_SECRET: "",
WEBHOOK_LOG_BODY_RETENTION_DAYS: "",
WEBHOOK_LOG_ROW_RETENTION_DAYS: "",
LEAD_CAPTURE_RETENTION_DAYS: "",
},
}));
const auditou = vi.fn();
vi.mock("@/lib/audit", () => ({ audit: (...args: unknown[]) => auditou(...args) }));
const capturou = vi.fn();
vi.mock("@sentry/nextjs", () => ({
captureException: (...args: unknown[]) => capturou(...args),
}));
/** O que o DELETE da captação devolve nesta rodada. */
let respostaDelete: { data: { id: string }[]; error: { message: string } | null };
/** As linhas que a busca do arquivo forense enxerga (o outro par da rodada). */
let linhasDoArquivo: { id: string }[] = [];
/** O que a poda mandou ao banco, na ordem em que mandou. */
let ordensPedidas: { coluna: string }[] = [];
/** O tamanho do lote que chegou no `limit`. */
let lotePedido = 0;
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
from: (tabela: string) => {
if (tabela === "webhook_lead_captures") {
// O heart da cerca: `limit` sem `order` LANÇA, como o PostgREST 12.2
// faz (400 PGRST109). O `ordenado` é lido pelo `limit` abaixo.
let ordenado = false;
const apagando: Record<string, unknown> = {
delete: () => apagando,
lt: () => apagando,
select: () => apagando,
order: (coluna: string) => {
ordenado = true;
ordensPedidas.push({ coluna });
return apagando;
},
limit: (n: number) => {
if (!ordenado) {
throw new Error("PGRST109: A 'limit' was applied without an explicit 'order'");
}
lotePedido = n;
return apagando;
},
then: (r: (v: unknown) => unknown) => Promise.resolve(respostaDelete).then(r),
};
return apagando;
}
// A busca e o DELETE da poda do ARQUIVO FORENSE: não é o que este
// arquivo mede, e precisam devolver ALGUMA coisa para a rodada não
// quebrar. O `delete` devolve lista vazia (o arquivo forense não é o
// alvo do #1721); o que o handler faz com esse resultado é o que os
// casos de falha medem — que o trabalho JÁ feito não se perde.
const busca: Record<string, unknown> = {
select: () => busca,
is: () => busca,
lt: () => busca,
order: () => busca,
update: () => busca,
in: () => busca,
delete: () => buscaApagadas,
limit: () => busca,
then: (r: (v: unknown) => unknown) =>
Promise.resolve({ data: linhasDoArquivo, error: null }).then(r),
};
const buscaApagadas: Record<string, unknown> = {
select: () => buscaApagadas,
is: () => buscaApagadas,
lt: () => buscaApagadas,
order: () => buscaApagadas,
limit: () => buscaApagadas,
then: (r: (v: unknown) => unknown) => Promise.resolve({ data: [], error: null }).then(r),
};
return busca;
},
}),
}));
/**
* Prepara a rodada: escolhe o que o DELETE devolve e zera o que se mede.
* Devolve o MESMO cliente que o módulo recebe — o dublê mora no mock acima, e
* duplicá-lo aqui seria ter dois bancos com a mesma memória.
*/
async function banco(sobrescreve?: Partial<typeof respostaDelete>) {
respostaDelete = { data: [], error: null, ...sobrescreve };
ordensPedidas = [];
lotePedido = 0;
linhasDoArquivo = [];
const { createAdminClient } = await import("@/lib/supabase/admin");
return createAdminClient() as never;
}
describe("o DELETE da captação ordena o lote antes de limitá-lo", () => {
it("manda `order` pela chave primária, e na ordem crescente", async () => {
// A coluna e a direção são as da DÉCIMA poda (`app/api/v1/cron/
// data-retention/route.ts`, `.order("id")`), pela mesma razão: `id` é a
// chave primária, logo a ordem é estável, o recorte é repetível e o
// planner não precisa trocar de caminho para casar com o índice de
// `received_at`. Ordenar por `received_at` pareceria mais "natural" e
// trocaria a ordem estável por uma que muda quando entra linha nova — e,
// no PostgREST 12.2, trocaria também a resposta: sem `order` o DELETE
// inteiro é recusado com 400 PGRST109.
const admin = await banco({ data: [{ id: "c1" }] });
await podarHistoricoDeCaptacao(admin, { diasBrutos: "365" });
expect(ordensPedidas).toEqual([{ coluna: "id" }]);
});
it("ordena ANTES de limitar — o dublê recusa `limit` sem `order` (PGRST109)", async () => {
// A direção que este teste NÃO mede é a do banco real: aqui o dublê LANÇA
// no `limit` sem `order`, como o PostgREST 12.2 (400 PGRST109, medido pelo
// mantenedor no v12.2.12). Tirar o `.order()` do código faz este caso
// reprovar — e, sem o `.order()`, a poda não apagaria NADA em nenhum clone
// novo, que é o defeito que a casa já corrigiu na décima poda.
const admin = await banco({ data: [{ id: "c1" }] });
const r = await podarHistoricoDeCaptacao(admin, { diasBrutos: "365", lote: 7 });
// Chega aqui porque o `order` veio antes do `limit` — e o lote pedido é o
// que o chamador mandou.
expect(r.apagadas).toBe(1);
expect(lotePedido).toBe(7);
});
it("a ordem deixa a drenagem REPRODUZÍVEL: dois lotes do mesmo recorte, mesma ordem", async () => {
// O motivo de a ordem existir, separado do PGRST109. Sem `order`, o banco
// devolve um subconjunto qualquer dentro de `lt(received_at, limite)`, e
// duas rodadas com o mesmo backlog apagam linhas DIFERENTES: a sequência
// de lotes não é reproduzível, e ninguém consegue dizer, depois, o que a
// poda pegou. Este caso mede a propriedade com o recorte do chamador
// fixado: o que a poda pede ao banco é sempre "as N primeiras linhas por
// `id`", nunca "N linhas quaisquer".
const admin = await banco({ data: [{ id: "c1" }] });
await podarHistoricoDeCaptacao(admin, { diasBrutos: "365", lote: 500 });
const primeira = JSON.stringify(ordensPedidas);
ordensPedidas = [];
await podarHistoricoDeCaptacao(admin, { diasBrutos: "365", lote: 500 });
expect(JSON.stringify(ordensPedidas)).toBe(primeira);
});
});
describe("a falha do DELETE sobe — ela não vira `apagadas: 0`", () => {
it("propaga a mensagem do banco, com o nome da tabela na frente", async () => {
// O `warn` que vivia aqui devolvia zero e seguia: na resposta do cron, "o
// banco recusou" e "não havia nada vencido" eram a mesma linha. A mensagem
// precisa chegar ao operador com o NOME da tabela — `webhook_lead_captures`
// — e não com o texto cru do Postgres, que sozinho não diz qual das duas
// tabelas da rodada falhou.
const admin = await banco({
error: { message: "permission denied for table webhook_lead_captures" },
});
await expect(podarHistoricoDeCaptacao(admin, { diasBrutos: "365" })).rejects.toThrow(
/webhook_lead_captures: permission denied/,
);
});
it("NÃO devolve um resultado de sucesso — o chamador não pode ler `apagadas: 0`", async () => {
// A direção que segura a porta de vacuidade: um `catch` que devolvesse
// `{ apagadas: 0 }` satisfaria qualquer asserção de "não lança" e manteria
// o defeito inteiro. Aqui a promessa é REJEITA.
const admin = await banco({ error: { message: "connection reset by peer" } });
await expect(podarHistoricoDeCaptacao(admin, { diasBrutos: "365" })).rejects.toBeInstanceOf(
Error,
);
});
});
describe("o handler do cron — a falha da captação sai pelo mesmo canal das irmãs", () => {
beforeEach(() => {
auditou.mockClear();
capturou.mockClear();
});
/** O handler com o cron autorizado, montando a `url` que ele também lê. */
async function chamarCron(): Promise<Response> {
const { GET } = await import("@/app/api/v1/cron/webhook-log-retention/route");
// A `url` é LIDA pelo handler (o ajuste de `?lote=`), então um objeto de
// cabeçalhos só não basta — é o mesmo formato que o `cron-auth` mede
// (`tests/unit/cron-auth.test.ts`).
return GET({
url: "http://localhost/api/v1/cron/webhook-log-retention",
headers: new Headers({ authorization: "Bearer segredo" }),
} as never);
}
it("rodada em dia responde 200 e a captação apagou o que veio", async () => {
await banco({ data: [{ id: "c1" }, { id: "c2" }] });
const resposta = await chamarCron();
expect(resposta.status).toBe(200);
const corpo = (await resposta.json()) as { data: { captacao: { apagadas: number } } };
expect(corpo.data.captacao.apagadas).toBe(2);
expect(auditou).not.toHaveBeenCalled();
});
it("banco recusa o DELETE: a linha `falhou` ENTRA na trilha", async () => {
// O laço de retorno, o mesmo das irmãs: sem esta linha, uma captação que
// parou de funcionar num clone é indistinguível, na trilha, de um dia sem
// nada vencido. E a linha tem de dizer QUAL poda falhou — `metadata.poda` é
// o que separa esta falha da falha do arquivo forense na mesma rodada.
await banco({ error: { message: "permission denied for table webhook_lead_captures" } });
await chamarCron();
expect(auditou).toHaveBeenCalledTimes(1);
expect(auditou.mock.calls[0]?.[0]).toMatchObject({
action: "retention.sweep_run",
metadata: { falhou: true, poda: "webhook_lead_captures" },
});
});
it("banco recusa o DELETE: a rodada responde 500, como as irmãs", async () => {
// O `curl -fsS` do scheduler (`docker/scheduler/entrypoint.sh`) é quem
// dispara este tique; um 200 de "tudo certo" depois de uma falha seria o
// defeito que a issue descreve, só que com mais um degrau. O 500 é o mesmo
// que o `data-retention` e o `media-retention` devolvem quando uma das
// suas podas falha.
await banco({ error: { message: "connection reset by peer" } });
linhasDoArquivo = [{ id: "e1" }];
const resposta = await chamarCron();
expect(resposta.status).toBe(500);
const corpo = (await resposta.json()) as {
error: { code: string; details: { arquivo_forense: { esvaziadas: number } } };
};
expect(corpo.error.code).toBe("internal_error");
// E o que o arquivo forense JÁ tinha feito fica no erro — a rodada falhou,
// e mesmo assim isto é verdade, e descartar seria perder trabalho feito.
expect(corpo.error.details.arquivo_forense.esvaziadas).toBe(1);
});
it("banco recusa o DELETE: o Sentry é chamado", async () => {
// Terceiro canal, e o único que sobrevive a um contêiner cujo stdout
// ninguém lê. A chamada é por import DINÂMICO — por isso o `await` de um
// tique de event loop depois do handler: a promise do `import` é resolvida
// DEPOIS de a resposta ser devolvida (é o que a deixa `void`, como em
// `reportAuditFailure`), e um teste que afirmasse no mesmo tique mediria a
// ordem das microtasks, não o canal. Com `SENTRY_DSN=off` numa instalação
// real, o `.catch` do fim engole a import que falha — e a falha do banco
// continua dita pelo log e pela linha de trilha, que são os canais que
// existem sempre.
await banco({ error: { message: "connection reset by peer" } });
await chamarCron();
await new Promise((r) => setTimeout(r, 0));
expect(capturou).toHaveBeenCalledTimes(1);
expect((capturou.mock.calls[0]?.[0] as Error).message).toMatch(/webhook_lead_captures/);
});
});
@@ -19,8 +19,11 @@ import {
* casos são o que impede a proteção de sumir num refactor.
*
* Os demais casos são sobre o que a poda NÃO faz: não filtra por organização
* (não sabe ser dirigida), não pede lote sem teto (não segura a tabela), e não
* mente sobre o que aconteceu quando o banco recusa.
* (não sabe ser dirigida), não pede lote sem teto (não segura a tabela), e — no
* que o #1721 mudou de doutrina — não engole a falha do banco. A ordem do
* DELETE e o canal de reporte da falha são medidos em
* `retencao-captacao-ordena-e-fala-a-falha.test.ts`, que é o arquivo criado
* junto com o conserto.
*
* A política (padrão, piso, aviso) vem de `lib/retencao/politica.ts`, o mesmo
* módulo da poda da fila e do expurgo da auditoria — estes casos provam que ela
@@ -59,6 +62,12 @@ function fakeAdmin(resposta: { data?: { id: string }[]; error?: { message: strin
select() {
return q;
},
// O `order` existe no dublê porque a poda o chama (issue #1721) — sem
// isto o teste do PISO quebraria por um detalhe de superfície, e o
// defeito (a ordem) ficaria escondido atrás de um erro de dublê.
order() {
return q;
},
limit(n: number) {
p.loteRecebido = n;
pedidos.push(p);
@@ -171,15 +180,22 @@ describe("poda do histórico de captação — o que ela NÃO faz", () => {
});
});
it("erro do banco devolve zero e NÃO lança — a poda não derruba o cron", () => {
// O cron poda o arquivo forense antes; uma exceção aqui perderia aquele
// trabalho. Falha aberta na ação, e a causa vai para o log (não silêncio).
it("erro do banco SOBE — a poda não engole a falha (issue #1721)", async () => {
// Este caso mudou de doutrina no #1721, e é a mudança que a issue pediu.
// Antes: `logger.warn` + `{ apagadas: 0 }`, porque o cron do arquivo
// forense roda antes e uma exceção aqui perderia aquele trabalho. O
// conserto é do MESMOJEITO que a casa já aplicou nas podas irmãs: a falha
// sobe, e quem chama a pega num `try` PRÓPRIO — cada lote fecha a sua
// transação, então o que já foi apagado no banco não se perde com ela.
//
// O que NÃO pode voltar é o `apagadas: 0`: na resposta do cron, "o banco
// recusou o DELETE" e "não havia nada vencido" eram a mesma linha, e o
// único sinal vivia num log de contêiner. O canal da falha agora é o das
// irmãs — medido em `retencao-captacao-ordena-e-fala-a-falha.test.ts`.
const { admin } = fakeAdmin({ error: { message: "connection reset" } });
return podarHistoricoDeCaptacao(admin, { diasBrutos: "365" }).then((r) => {
expect(r.apagadas).toBe(0);
expect(r.temMais).toBe(false);
expect(r.diasAplicados).toBe(365);
});
await expect(podarHistoricoDeCaptacao(admin, { diasBrutos: "365" })).rejects.toThrow(
/webhook_lead_captures: connection reset/,
);
});
});
@@ -147,7 +147,7 @@ describe("BulkActionBar — tag em lote", () => {
describe("a página do funil ENTREGA as tags do quadro à barra", () => {
it("as tags dos leads do quadro chegam ao menu de tag em lote", async () => {
const { PipelinePageClient } = await import("@/app/app/pipelines/[id]/_client");
render(<PipelinePageClient pipelineId="p-1" initialName="Funil" />);
render(<PipelinePageClient pipelineId="p-1" initialName="Funil" role="admin" />);
await userEvent.click(await screen.findByRole("button", { name: /tag/i }));
expect(await screen.findByRole("menuitem", { name: "google" })).toBeTruthy();
+12
View File
@@ -89,6 +89,18 @@ describe("os elos que somem sem barulho", () => {
expect(fonte).toMatch(/Modelos do parceiro/);
});
it("a aba Graph GERENCIA modelo — o gerenciar={false} saiu (#1734)", () => {
// Ele existia porque o DELETE desta plataforma, por nome só, apagava TODAS
// as variantes de idioma enquanto a tela apagaria uma (#1728). Desde a
// #1734 o alvo resolve o id da variante por nome+idioma, então a aba usa o
// MESMO cliente do outro parceiro, sem apagar botão.
const fonte = readFileSync("components/connections/ConexoesShell.tsx", "utf8");
expect(fonte).toContain('<TemplatesParceiroClient rota={rotaDeTemplates("graph")} />');
expect(fonte, "a aba Graph ainda entrega o cliente com gerenciar desligado").not.toContain(
"gerenciar={false}",
);
});
it("a rota passa pelo SEAM, e não fala com a plataforma direto", () => {
const fonte = readFileSync("app/api/v1/channels/partner/templates/route.ts", "utf8");
expect(fonte).toMatch(/adapter\.templates\.list/);
+5 -5
View File
@@ -32,12 +32,13 @@ import { StaleServiceBoundaryError } from "@/lib/atendimento/fronteira";
// Mesma lógica de `sentry.server.config.ts`/`sentry.edge.config.ts`
// (reaproveitada, não duplicada): DSN resolvido por `resolveSentryDsn`,
// amostragem de trace condicionada ao Sentry da comunidade via
// `isCommunityDsn` (issue #100), e os hooks de scrub de `lib/sentry/scrub.ts`.
// `isCommunityDsn` (issue #100), e a coleta restrita + scrub de
// `lib/sentry/privacidade.ts`.
// O `@sentry/nextjs` funciona fora do Next — aqui é só `Sentry.init` puro,
// sem `instrumentation.ts` porque o worker não é um processo Next.
import * as Sentry from "@sentry/nextjs";
import { resolveSentryDsn, isCommunityDsn, DEFAULT_SENTRY_DSN } from "@/lib/sentry/dsn";
import { sentryScrubHooks } from "@/lib/sentry/scrub";
import { opcoesDePrivacidade } from "@/lib/sentry/privacidade";
const sentryDsn = resolveSentryDsn(process.env.SENTRY_DSN);
const sentryCommunity = isCommunityDsn(sentryDsn);
@@ -47,10 +48,9 @@ Sentry.init({
// No Sentry da comunidade, só erro (issue #100). Ver isCommunityDsn().
tracesSampleRate: sentryCommunity ? 0 : 1,
enableLogs: true,
sendDefaultPii: false,
...sentryScrubHooks,
// Coleta restrita + scrub, num ponto só (Sentry 11 coleta amplo por default).
...opcoesDePrivacidade,
});
// Transparência de telemetria (mesma mensagem de sentry.server.config.ts,