Merge branch 'melgarafael:main' into develop

This commit is contained in:
Ian Couto
2026-09-14 14:13:43 -03:00
committed by GitHub
74 changed files with 4079 additions and 1141 deletions
+3 -2
View File
@@ -109,8 +109,9 @@ Se precisar do modo sem perguntas (`--yes` com `.env` pronto), duas armadilhas m
- escreva `SENTRY_DSN=off` se a pessoa **não** quer enviar relatórios de erro. Com o `.env` copiado
do exemplo, a pergunta de telemetria não aparece e a instalação sai enviando (issue #668).
Também medido (issue #670): o instalador **exige** uma chave de IA válida, mesmo que a documentação
diga que dá para deixar vazia e cadastrar depois. Enquanto isso não muda, peça a chave antes.
A chave de IA é **opcional** (issue #670, resolvida): dá para instalar sem conta em provedor de IA
e cadastrar depois em IA › Credenciais — a tela final da instalação lembra quem pulou, com o
caminho. Se a pessoa já tiver a chave em mãos, ótimo: ela é validada na hora.
### 6. Depois do "Instalação concluída!"
@@ -21,7 +21,6 @@ a do provedor escolhido. No modo `--yes`, sem escolha, o padrão é Anthropic.
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | anon key (Settings › API) | lê o papel dentro da chave e testa |
| `SUPABASE_SERVICE_ROLE_KEY` | service_role key — **secreta** | idem, contra a admin API |
| `SUPABASE_DB_URL` | connection string **Session pooler, modo URI** (Settings › Database) — secreta | recusa `[YOUR-PASSWORD]`, recusa host `db.<ref>.supabase.co` (Direct, só IPv6), confere que é o mesmo projeto da URL, e **abre uma conexão de verdade** |
| chave do provedor de IA | `ANTHROPIC_API_KEY` (`sk-ant-`), `OPENROUTER_API_KEY` (`sk-or-`) ou `OPENAI_API_KEY` (`sk-`) | prefixo + chamada real ao provedor (uma chave sem crédito passa; "recusada" é 401) |
| `OWNER_EMAIL` | e-mail do primeiro admin (o dono) | formato |
| `OWNER_PASSWORD` | senha do dono — secreta | mínimo 8 caracteres |
@@ -33,6 +32,7 @@ projeto** e preenche as quatro sozinho (`hostgator-setup-kit/supabase-provision.
| campo | se pular |
|---|---|
| `SUPABASE_ACCESS_TOKEN` (token pessoal, **não fica salvo**) | projeto criado à mão (4 cópias); os e-mails de acesso saem no modelo em inglês do Supabase e o **Site URL fica `localhost:3000`** — "esqueci minha senha", confirmação de cadastro e aceite de convite chegam com link quebrado até alguém configurar Authentication › URL Configuration (`Site URL = https://DOMÍNIO`, `Redirect = https://DOMÍNIO/auth/confirm`). O instalador imprime essa pendência no fim; `hostgator-setup-kit/marca-emails.sh` resolve depois, com o token |
| **chave do provedor escolhido** (`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` ou `OPENAI_API_KEY`) | o agente **não responde** até alguém cadastrar a chave em **IA › Credenciais** — cifrada no banco, sem mexer no `.env` e sem reiniciar. A tela final da instalação lembra o caminho. Quem digita uma chave tem ela validada na hora (prefixo + chamada real ao provedor: uma chave sem crédito passa; "recusada" é 401) |
| `OPENAI_API_KEY` extra (quando a IA não é OpenAI) | o agente **não ouve áudio** nem indexa/consulta a base de conhecimento até alguém cadastrar a chave da OpenAI em IA › Credenciais (não precisa mexer no `.env`) |
| `APP_NAME` (padrão `DeskcommCRM`) | é a **semente** do nome; depois muda em Configurações › Marca |
| `APP_LOCALE` (1 = Português, 2 = Español) | grava o idioma da organização |
@@ -109,9 +109,11 @@ o baseline de hoje deduplica sozinho ao atualizar (`update.sh`), depois reinicie
## Instalação e atualização
**Instalador exige chave de IA "válida" e para** (issue #670): a documentação diz que dá para
deixar vazia e cadastrar depois, mas o instalador não aceita. Enquanto isso não muda, consiga a
chave antes (OpenRouter é o caminho mais rápido de criar).
**Instalador exigia chave de IA "válida" e parava** (issue #670, **resolvida**): a documentação
dizia que dava para deixar vazia e cadastrar depois, mas o instalador não aceitava — era preciso
conseguir a chave antes (OpenRouter é o caminho mais rápido de criar). Agora a chave é opcional:
dá para instalar sem ela e cadastrar depois em IA › Credenciais; a tela final lembra quem pulou.
Vale para quem atualizar para a versão seguinte.
**Telemetria ligada sem ninguém escolher** (issue #668): acontece quando o `.env` foi copiado do
exemplo. Para desligar: `SENTRY_DSN=off` no `.env` e `docker compose -f docker-compose.prod.yml up -d`.
@@ -5,7 +5,7 @@ Todos vivem em `hostgator-setup-kit/` e rodam **de dentro da VPS**, a partir da
| script | quando a pessoa diz | o que faz | como ler a saída |
|---|---|---|---|
| `install.sh` | "instala", "troca a configuração", "coloquei um dado errado" | instala do zero **ou** re-roda sobre o que existe (idempotente): retoma respostas, corrige pelo número da conferência, re-aplica o schema, sobe tudo | termina em "Instalação concluída!" com a pendência de e-mails (se sem token). `--yes` = sem perguntas, exige `.env` completo |
| `install.sh` | "instala", "troca a configuração", "coloquei um dado errado" | instala do zero **ou** re-roda sobre o que existe (idempotente): retoma respostas, corrige pelo número da conferência, re-aplica o schema, sobe tudo | termina em "Instalação concluída!" com a pendência de e-mails (se sem token). `--yes` = sem perguntas, exige `.env` preenchido — a chave de IA pode ficar vazia |
| `healthcheck.sh` | "está tudo no ar?", "o site caiu?" | lista os contêineres, chama `/api/v1/health` **de dentro** do contêiner do app, confere o cron do agente e o log dele | `✓ app saudável` se o JSON traz `"status":"ok"`; `⚠` nomeia o subsistema (supabase/redis/waha) degradado |
| `diagnostico.sh` | "o agente parou de melhorar", "a versão está solta", "atualizei e nada mudou" | **só lê** (nada de escrever, puxar ou reiniciar): diz se a instalação foi afetada pelo worker que nunca era atualizado. Roda até avulso: `curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh \| bash` | código de saída 1 = afetada; explica o conserto |
| `update.sh` | "atualiza", "tem versão nova?" | confere se há versão nova (senão sai na hora), **faz backup antes**, puxa o código, re-aplica o `baseline.sql` (idempotente e auto-curativo; muitos "já existe" são esperados), puxa as imagens da tag, confere a saúde | código 3 = recusou e **nada foi tocado** (ex.: sem internet para confirmar o que é mais novo, ou a versão pedida é anterior). `--force` volta no tempo de propósito; `--to <tag>` fixa uma versão; `--skip-backup` não recomendado |
+3 -2
View File
@@ -109,8 +109,9 @@ Se precisar do modo sem perguntas (`--yes` com `.env` pronto), duas armadilhas m
- escreva `SENTRY_DSN=off` se a pessoa **não** quer enviar relatórios de erro. Com o `.env` copiado
do exemplo, a pergunta de telemetria não aparece e a instalação sai enviando (issue #668).
Também medido (issue #670): o instalador **exige** uma chave de IA válida, mesmo que a documentação
diga que dá para deixar vazia e cadastrar depois. Enquanto isso não muda, peça a chave antes.
A chave de IA é **opcional** (issue #670, resolvida): dá para instalar sem conta em provedor de IA
e cadastrar depois em IA › Credenciais — a tela final da instalação lembra quem pulou, com o
caminho. Se a pessoa já tiver a chave em mãos, ótimo: ela é validada na hora.
### 6. Depois do "Instalação concluída!"
@@ -21,7 +21,6 @@ a do provedor escolhido. No modo `--yes`, sem escolha, o padrão é Anthropic.
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | anon key (Settings › API) | lê o papel dentro da chave e testa |
| `SUPABASE_SERVICE_ROLE_KEY` | service_role key — **secreta** | idem, contra a admin API |
| `SUPABASE_DB_URL` | connection string **Session pooler, modo URI** (Settings › Database) — secreta | recusa `[YOUR-PASSWORD]`, recusa host `db.<ref>.supabase.co` (Direct, só IPv6), confere que é o mesmo projeto da URL, e **abre uma conexão de verdade** |
| chave do provedor de IA | `ANTHROPIC_API_KEY` (`sk-ant-`), `OPENROUTER_API_KEY` (`sk-or-`) ou `OPENAI_API_KEY` (`sk-`) | prefixo + chamada real ao provedor (uma chave sem crédito passa; "recusada" é 401) |
| `OWNER_EMAIL` | e-mail do primeiro admin (o dono) | formato |
| `OWNER_PASSWORD` | senha do dono — secreta | mínimo 8 caracteres |
@@ -33,6 +32,7 @@ projeto** e preenche as quatro sozinho (`hostgator-setup-kit/supabase-provision.
| campo | se pular |
|---|---|
| `SUPABASE_ACCESS_TOKEN` (token pessoal, **não fica salvo**) | projeto criado à mão (4 cópias); os e-mails de acesso saem no modelo em inglês do Supabase e o **Site URL fica `localhost:3000`** — "esqueci minha senha", confirmação de cadastro e aceite de convite chegam com link quebrado até alguém configurar Authentication › URL Configuration (`Site URL = https://DOMÍNIO`, `Redirect = https://DOMÍNIO/auth/confirm`). O instalador imprime essa pendência no fim; `hostgator-setup-kit/marca-emails.sh` resolve depois, com o token |
| **chave do provedor escolhido** (`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` ou `OPENAI_API_KEY`) | o agente **não responde** até alguém cadastrar a chave em **IA › Credenciais** — cifrada no banco, sem mexer no `.env` e sem reiniciar. A tela final da instalação lembra o caminho. Quem digita uma chave tem ela validada na hora (prefixo + chamada real ao provedor: uma chave sem crédito passa; "recusada" é 401) |
| `OPENAI_API_KEY` extra (quando a IA não é OpenAI) | o agente **não ouve áudio** nem indexa/consulta a base de conhecimento até alguém cadastrar a chave da OpenAI em IA › Credenciais (não precisa mexer no `.env`) |
| `APP_NAME` (padrão `DeskcommCRM`) | é a **semente** do nome; depois muda em Configurações › Marca |
| `APP_LOCALE` (1 = Português, 2 = Español) | grava o idioma da organização |
@@ -109,9 +109,11 @@ o baseline de hoje deduplica sozinho ao atualizar (`update.sh`), depois reinicie
## Instalação e atualização
**Instalador exige chave de IA "válida" e para** (issue #670): a documentação diz que dá para
deixar vazia e cadastrar depois, mas o instalador não aceita. Enquanto isso não muda, consiga a
chave antes (OpenRouter é o caminho mais rápido de criar).
**Instalador exigia chave de IA "válida" e parava** (issue #670, **resolvida**): a documentação
dizia que dava para deixar vazia e cadastrar depois, mas o instalador não aceitava — era preciso
conseguir a chave antes (OpenRouter é o caminho mais rápido de criar). Agora a chave é opcional:
dá para instalar sem ela e cadastrar depois em IA › Credenciais; a tela final lembra quem pulou.
Vale para quem atualizar para a versão seguinte.
**Telemetria ligada sem ninguém escolher** (issue #668): acontece quando o `.env` foi copiado do
exemplo. Para desligar: `SENTRY_DSN=off` no `.env` e `docker compose -f docker-compose.prod.yml up -d`.
@@ -5,7 +5,7 @@ Todos vivem em `hostgator-setup-kit/` e rodam **de dentro da VPS**, a partir da
| script | quando a pessoa diz | o que faz | como ler a saída |
|---|---|---|---|
| `install.sh` | "instala", "troca a configuração", "coloquei um dado errado" | instala do zero **ou** re-roda sobre o que existe (idempotente): retoma respostas, corrige pelo número da conferência, re-aplica o schema, sobe tudo | termina em "Instalação concluída!" com a pendência de e-mails (se sem token). `--yes` = sem perguntas, exige `.env` completo |
| `install.sh` | "instala", "troca a configuração", "coloquei um dado errado" | instala do zero **ou** re-roda sobre o que existe (idempotente): retoma respostas, corrige pelo número da conferência, re-aplica o schema, sobe tudo | termina em "Instalação concluída!" com a pendência de e-mails (se sem token). `--yes` = sem perguntas, exige `.env` preenchido — a chave de IA pode ficar vazia |
| `healthcheck.sh` | "está tudo no ar?", "o site caiu?" | lista os contêineres, chama `/api/v1/health` **de dentro** do contêiner do app, confere o cron do agente e o log dele | `✓ app saudável` se o JSON traz `"status":"ok"`; `⚠` nomeia o subsistema (supabase/redis/waha) degradado |
| `diagnostico.sh` | "o agente parou de melhorar", "a versão está solta", "atualizei e nada mudou" | **só lê** (nada de escrever, puxar ou reiniciar): diz se a instalação foi afetada pelo worker que nunca era atualizado. Roda até avulso: `curl -fsSL https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/hostgator-setup-kit/diagnostico.sh \| bash` | código de saída 1 = afetada; explica o conserto |
| `update.sh` | "atualiza", "tem versão nova?" | confere se há versão nova (senão sai na hora), **faz backup antes**, puxa o código, re-aplica o `baseline.sql` (idempotente e auto-curativo; muitos "já existe" são esperados), puxa as imagens da tag, confere a saúde | código 3 = recusou e **nada foi tocado** (ex.: sem internet para confirmar o que é mais novo, ou a versão pedida é anterior). `--force` volta no tempo de propósito; `--to <tag>` fixa uma versão; `--skip-backup` não recomendado |
+3 -1
View File
@@ -135,7 +135,9 @@ NEXT_PUBLIC_ADMIN_URL=https://crm.seudominio.com.br
# -----------------------------------------------------------------------------
# 4) IA (Anthropic via AI Gateway) — paga por uso, chave sua
# -----------------------------------------------------------------------------
# ou ANTHROPIC_API_KEY abaixo
# ou ANTHROPIC_API_KEY abaixo. As chaves de IA são OPCIONAIS: o CRM sobe sem
# nenhuma delas e você cadastra a sua depois pela tela, em IA › Credenciais
# (fica cifrada no banco). Sem chave, o agente de IA não responde.
AI_GATEWAY_API_KEY=
ANTHROPIC_API_KEY=
+150 -1
View File
@@ -8,6 +8,154 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es
## [Não lançado]
## [1.21.0] — 2026-09-14
### Adicionado
- **Quem publica o CRM atrás de um Nginx Proxy Manager sobrevive a atualizações** Instalações que já tinham um Nginx Proxy Manager nas portas 80/443 (em vez do
Caddy do próprio kit, ou de um Traefik) precisavam plugar o contêiner `app` na
rede do NPM à mão (`docker network connect`). Isso sumia na primeira
atualização: `update.sh` recria o `app`, a conexão manual se perde, e o
domínio volta a responder 502 — foi o que aconteceu numa VPS real em
2026-09-11.
Agora `REVERSE_PROXY=npm` no `.env` (junto de `PROXY_NETWORK_NAME` e
`PROXY_NETWORK_APP_IP`, se a rede ou o IP do seu Proxy Host não forem os
padrões) mantém o `app` sempre na rede certa, entra automaticamente em toda
atualização e no cron de auto-update, e nunca sobe o Caddy por engano por
cima do NPM. Se a rede do NPM sumir (`docker network prune`, por exemplo), a
atualização para com uma mensagem explicando o que fazer, em vez de travar no
erro opaco do Docker.
Configurar pela primeira vez continua sendo manual — o NPM não anuncia sua
configuração como o Traefik faz por labels — mas está documentado no
cabeçalho de `docker-compose.npm.yml`.
### Corrigido
- **A foto do contato volta a aparecer nos números com nono dígito** A tarefa que busca as fotos de perfil pedia a imagem pelo telefone. Em número
de celular brasileiro com nono dígito, o telefone que o CRM guarda e o que o
WhatsApp usa internamente podem divergir, e a busca voltava vazia — o contato
ficava sem foto sem que nada indicasse erro. Agora a tarefa pede primeiro pela
identidade interna do WhatsApp e só recorre ao telefone se ela não existir.
Achado e corrigido por @HigorLira.
- **A tela de atualização passa a dizer em que pé está, do começo ao fim** Ao clicar em "Atualizar agora", a tela mostrava a lista dos quatro passos com
todos eles vazios e o título "Atualizando para a versão X" — e ficava assim,
sem mexer nada, por vários minutos. Não era travamento: o clique só registra o
pedido, e o servidor confere se há algo a fazer de poucos em poucos minutos. Mas
não havia como saber disso olhando, e a tela afirmava um trabalho que ainda nem
tinha começado.
Agora a espera tem nome próprio ("Pedido enviado — esperando o servidor pegar"),
diz por que demora, avisa que ficar parada nesse tempo é normal e mostra um
relógio contando desde o pedido. A lista de passos só aparece quando existe um
passo de verdade. Você pode fechar a página: o pedido não se perde.
Do outro lado acontecia o inverso, e era pior. Terminada a atualização, o
sistema voltava e a tela oferecia de novo o botão "Atualizar agora" para a
versão que **acabava de ser instalada** — quem clicava refazia tudo, ou concluía
que não tinha funcionado. Isso durava até o servidor reportar a versão nova, o
que leva alguns minutos. Agora a tela reconhece o fim na hora e diz "Pronto —
você está na versão X", sem oferecer nada.
Você não precisa fazer nada para adotar.
- **Cada tela passa a dizer o próprio nome na aba do navegador** Dezessete telas do aplicativo caíam no título padrão do produto, então quem
trabalha com várias abas abertas via a mesma legenda em todas elas e só
descobria qual era qual clicando. Agora cada uma nomeia a si mesma.
Achado e corrigido por @AnditecDev.
- **O WhatsApp oficial conectado pela tela volta a enviar — sem depender do .env** Uma instalação que conectou o número oficial pela **Central de Conexões** guarda a credencial **cifrada no banco** e não escreve nada no `.env` — e as mensagens ficavam paradas na fila, sem erro, com o canal conectado e funcionando na tela.
A pergunta "dá para tentar enviar?" era respondida só pelo `.env`, num ponto que não consegue consultar o banco. Agora quem decide é o próprio envio, que resolve a credencial da sessão primeiro — e o `.env` continua valendo como fallback para instalações antigas de número único. Sem credencial nenhuma, a mensagem fica na fila com o motivo nomeado (em vez de nunca ser tentada); falha na consulta da credencial vira erro visível na mensagem, em vez de silêncio.
Quem já tinha a chave no `.env` não vê diferença nenhuma.
- **A instalação deixa de exigir chave de IA — dá para cadastrar depois pela tela** O instalador exigia uma chave de IA que **passasse numa chamada real** ao
provedor: sem ela, a instalação morria na Fase 2/4. Só que a documentação
(`docs/deploy-selfhost`) sempre prometeu outra coisa — *"deixe vazio e cadastre
a chave depois"* —, e o próprio sistema concorda com a doc: faltar todas as
chaves é um aviso, não um erro.
Agora o campo é opcional de verdade: dá para instalar sem abrir conta em
provedor de IA e cadastrar a chave depois pela tela, em **IA › Credenciais**,
onde ela fica cifrada no banco. A tela final da instalação lembra quem pulou o
passo, com o caminho exato.
Quem digita uma chave continua com ela validada na hora — o que mudou é que
pular deixou de ser erro.
- **As duas verificações opcionais de segurança agora ligam de verdade** Em Agentes › Confere antes de enviar, ligar "Detectar tentativa de manipular o
assistente" ou "Conferir promessas em texto livre" não gravava nada: o pedido
era recusado e o interruptor voltava sozinho, sem explicação na tela. As duas
verificações ficavam no que o servidor definia, e quem quisesse ligá-las por
organização não conseguia — em nenhuma instalação.
Agora o interruptor grava a escolha. Se você tentou ligar alguma das duas e
achou que o clique não pegava, era isto; tente de novo. Crédito: @rafaelbatistazz.
- **O botão "Reativar" de tipo de agendamento passa a funcionar** Em Configurações › Agenda, um tipo de agendamento desativado mostra o botão
"Reativar" — e ele **nunca funcionou**, desde que a tela existe. Clicar devolvia
sempre o mesmo erro: "Nenhum campo para alterar." Quem tinha desativado um tipo
por engano ficava sem saída pela tela: só criando outro com nome diferente, já
que o nome original continuava ocupado pelo tipo desligado.
A causa era um campo que o servidor descartava em silêncio. A tela pedia para
ligar o tipo de volta usando a mesma porta que altera nome, duração e
responsável — e essa porta não conhece o campo "ativo", então recebia um pedido
que, do lado dela, não mudava nada.
Agora reativar tem porta própria no servidor, com a mesma exigência de papel do
desativar (gerente ou administrador), e fica registrado na trilha de auditoria
como "tipo reativado" — separado de uma alteração comum de campo, para que um
tipo religado não se confunda com um tipo que teve a duração mudada.
Você não precisa fazer nada para adotar. Desativar continua igual, e nenhum
compromisso já marcado é afetado.
- **Revogar e devolver acesso aparecem na hora na lista de Equipe** Em Equipe, revogar o acesso de alguém — ou devolvê-lo — deixava a linha da
pessoa parada até recarregar a página. Quem clicava não via nada acontecer e
clicava de novo, sem saber se o primeiro clique tinha valido.
O servidor sempre fez a parte dele; era a tela que só se atualizava depois. E o
problema só apareceu agora porque antes o membro revogado sumia da lista: some
ou não some era resposta suficiente. Desde que ele passa a ficar na lista com o
estado mudado, uma linha que não muda é uma tela que mente sobre o que acabou de
acontecer.
Agora a linha muda no clique e, se o servidor recusar, ela volta ao que era e o
erro aparece — nunca fica dizendo "ativo" para quem não foi reativado. Devolver
acesso também passou a confirmar que deu certo, como revogar já fazia: é
justamente a ação que se faz com receio de ter errado.
Você não precisa fazer nada para adotar.
Crédito: @paulolimajr77.
- **Sentry para de derrubar um coletor de Web Vitals no console de quem usa o DSN da comunidade** A integração `BrowserTracing` do Sentry instrumenta Web Vitals (CLS/LCP/TTFB) mesmo sem enviar
nenhum trace — a amostragem decide se o dado é enviado, não se o coletor roda. Numa instalação
real (2026-09-09), uma extensão do navegador mexendo na Performance API da página derrubava
esse coletor com um erro no console (`TypeError: Cannot read properties of undefined (reading
'startTime')`), sem nenhum trace chegando a existir para explicar o motivo. Quem está no DSN da
comunidade não tinha telemetria nenhuma sendo enviada por essa integração — só o risco do
crash. Ela deixa de ser carregada para essa população; quem aponta para o próprio Sentry
mantém o tracing normalmente.
- **A sugestão de resposta diz por que falhou, e a rejeitada sai da tela** Duas coisas na caixa de entrada, medidas numa instalação real.
**A sugestão rejeitada não saía da tela.** O painel mostrava a sugestão mais recente sem olhar a situação dela — e uma rejeitada continua sendo a mais recente. O texto ficava ali, numa caixa desabilitada, sem botão de fechar (não havia nenhum). Pior no caso comum: quem rejeita costuma pedir outra em seguida; se essa segunda falha, nada substitui a primeira e a tela **trava** naquele texto.
Agora a sugestão rejeitada — e também a obsoleta e a já enviada — solta o painel, que volta ao botão **Sugerir resposta**. A sugestão que falhou continua aparecendo de propósito: a frase dela é a única pista que sobra.
**O erro não dizia nada.** Qualquer falha ao gerar virava a mesma frase — "Confira a publicação e a configuração do agente" —, mesmo quando o problema era outro, e **o motivo real era descartado sem ser registrado**. A tela ainda mostrava o identificador da requisição junto, o que fazia a mensagem parecer rastreável: não era, porque não havia nada gravado para procurar.
Agora a tela diz qual dos motivos foi — nenhum agente publicado atende o canal, ou a conversa não pode receber sugestão (contato que pediu para não receber mensagens, contato anonimizado, histórico ilegível) — e, quando a causa é outra, admite que é outra e **registra** no servidor, onde o identificador finalmente encontra alguma coisa.
Nada muda para quem opera: sem passo manual, sem mexer em configuração.
## [1.20.0] — 2026-09-12
### Adicionado
@@ -3646,7 +3794,8 @@ 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.20.0...HEAD
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.21.0...HEAD
[1.21.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.20.0...v1.21.0
[1.20.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.19.0...v1.20.0
[1.19.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.18.1...v1.19.0
[1.18.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.18.0...v1.18.1
+11 -1
View File
@@ -495,7 +495,17 @@ Antes de declarar uma task pronta:
11. **Mudança de schema saiu como migration versionada + linha no MANIFEST** (ver Doutrina de Migrations) — clones conseguem atualizar
12. **Se tocou UI/fluxo de usuário: provado pela tela como um leigo faria**, em ambiente fresco estilo VPS, com evidência visual (ver Doutrina de QA Visual com Recursos Reais) — curl não conta
13. **Living System Checklist respondido** (lei em `docs/doctrine/sistema-vivo.md`; racional no manual `docs/doctrine/sistema-vivo/`) — a feature não é ilha: tem entrada + saída, emite atividade/log, aparece na tela, tem porta na navegação, tem mecanismo anti-morte, **declara seu laço de retorno** (invariante 7 — o que muda no sistema quando ela erra), e o mapa vivo (`docs/architecture/`) reflete peça nova com ≥2 arestas. Resposta que não **nomeia o artefato concreto** (consumidor real, tela real, log real) não conta
14. **Tela nova tem porta** — declarada em `lib/navigation/registry.ts` com seu grupo, ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes: o CI reprova tela que existe mas em que só se chega digitando a URL
14. **Tela nova tem porta** — declarada em **`lib/navigation/catalogo.ts`** (no `NAV_CATALOG`, com seu grupo), ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes: o CI reprova tela que existe mas em que só se chega digitando a URL.
⚠️ Esta linha dizia `lib/navigation/registry.ts`, e quem a seguisse abria um
arquivo **sem um único lugar onde declarar**: o `registry.ts` só deriva
(`NAV_DESTINATIONS` sai de `NAV_CATALOG`) e reexporta. Ele é a FACE do
módulo — é dele que o teste importa, e por isso o engano é fácil. Quem
declara é o catálogo. Para conferir sem acreditar nesta linha:
```bash
grep -c 'href:' lib/navigation/catalogo.ts lib/navigation/registry.ts
```
15. **Se tocou Dockerfile, compose ou setup kit: a mudança chega a quem já instalou** (lei em `docs/doctrine/packaging.md`) — nenhum serviço de produção ficou `build:`-only; variável nova tem default que não quebra `.env` antigo; a atualização não pede edição manual de arquivo; e, se mudou o que a imagem contém, o `update.sh` alcança essa peça. Rode `pnpm test:shell` — é o único gate que exercita o kit
16. **Se o PR muda comportamento, procure a afirmação de estado sobre esse comportamento.** Só
sobre o que você mudou, e só nos documentos de autoridade — não saia caçando pelo repo. A
+91
View File
@@ -0,0 +1,91 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* REATIVAR UM TIPO DE AGENDAMENTO — o outro lado do `DELETE` da rota irmã.
*
* ─── O defeito que esta rota fecha ────────────────────────────────────────
*
* A tela de Configurações › Agenda sempre teve o botão "Reativar", e ele
* **nunca funcionou**. Ele mandava `PATCH /api/v1/agenda/tipos` com
* `{ id, is_active: true }`, e o `alterarSchema` daquela rota é
* `criarSchema.partial()` — `is_active` não está entre os doze campos de
* `camposDoTipo`. Zod descarta chave desconhecida **em silêncio**, então o
* corpo chegava vazio ao `update` e a rota respondia 422 "Nenhum campo para
* alterar." — uma recusa que não nomeia o que aconteceu, numa tela em que o
* usuário só vê "não deu".
*
* Quem escondeu o defeito do compilador foi um `as never` na chamada (o único
* do arquivo da tela). Ele saiu junto com este conserto.
*
* ─── Por que uma ROTA, e não `is_active` no schema do PATCH ───────────────
*
* O caminho curto seria acrescentar `is_active` a `camposDoTipo`. Ele custa uma
* linha e cobra duas coisas:
*
* 1. O MESMO pedido que muda duração passaria a poder **desligar** um tipo. E
* desligar já tem porta própria (o `DELETE`), com a guarda e a decisão de
* "desativar, nunca apagar" escritas lá.
* 2. A trilha perderia a distinção. Reativar cairia em `api_audit_log` como
* `agenda.tipo_alterado { campos: ["is_active"] }` — indistinguível, para
* quem audita depois, de "mudaram a duração". Ligar de volta um tipo que
* alguém desligou é ato de gestão e merece verbo próprio na trilha:
* `agenda.tipo_reativado`.
*
* Sub-rota de ação é a forma que esta casa já usa para o ato que não é um campo
* — `agenda/google/desconectar`, `contacts/merge`, `lgpd/anonymize`.
*
* ─── Simetria com o `DELETE` ──────────────────────────────────────────────
*
* Mesmo papel (`manager`), mesma guarda de suporte, mesmo filtro explícito de
* `organization_id` no client de service role, mesmo 404 quando não há linha.
* Dizer "reativei" sobre o que não existe é a mesma família de mentira que o
* `DELETE` recusa do outro lado.
*/
import { type NextRequest } from "next/server";
import { z } from "zod";
import { fail, ok } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const corpo = z.object({ id: z.string().uuid() });
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = req.headers.get("x-request-id") ?? undefined;
const autorizado = await requireRole("manager", { requestId, resource: "calendar_event_types" });
if (!autorizado.ok) return autorizado.response;
const t = (texto: string) => traduzir(texto, autorizado.user.idioma);
const lido = corpo.safeParse(await req.json().catch(() => ({})));
if (!lido.success) return fail("validation_failed", t("corpo inválido"), 422, { requestId });
const admin = createAdminClient();
const { data, error } = await admin
.from("calendar_event_types")
.update({ is_active: true })
.eq("id", lido.data.id)
// O tenant vem da SESSÃO, nunca do corpo — o corpo carrega só o `id` do
// tipo, e sem este filtro a service role reativaria tipo de outra casa.
.eq("organization_id", autorizado.org.orgId)
.select("id")
.maybeSingle();
if (error) return fail("internal_error", error.message, 500, { requestId });
if (!data) return fail("not_found", t("Tipo de agendamento não encontrado."), 404, { requestId });
await audit({
actorUserId: autorizado.user.id,
action: "agenda.tipo_reativado",
organizationId: autorizado.org.orgId,
resourceType: "calendar_event_types",
resourceId: lido.data.id,
metadata: {},
});
return ok(data, { requestId });
}
+14
View File
@@ -18,6 +18,20 @@ import { requireSupportWrite } from "@/lib/impersonate/support";
* DELETE aqui grava `is_active = false`: some da tela de marcar e continua
* respondendo pelo passado. É o mesmo raciocínio do anti-pattern 7 da doutrina
* (cascade fantasma).
*
* ─── E a volta mora AO LADO, não aqui ────────────────────────────────────
*
* Reativar é `POST /api/v1/agenda/tipos/reativar`. `is_active` está fora de
* `camposDoTipo` DE PROPÓSITO: aceitá-lo no PATCH deixaria o mesmo pedido que
* muda a duração poder desligar o tipo, e a trilha registraria a religada como
* `agenda.tipo_alterado { campos: ["is_active"] }` — indistinguível de uma
* alteração de campo qualquer.
*
* ⚠️ Essa exclusão é silenciosa e já custou: Zod DESCARTA chave desconhecida sem
* dizer nada, então o botão "Reativar" da tela mandou `is_active` para cá
* durante toda a vida dele e recebeu 422 "Nenhum campo para alterar." — uma
* recusa que não nomeia o que foi descartado. Quem vigia a travessia hoje é
* `tests/unit/agenda-reativar-tipo.test.ts`.
*/
import { type NextRequest } from "next/server";
import { z } from "zod";
@@ -6,6 +6,8 @@ import { createClient } from "@/lib/supabase/server";
import { getRequestPool } from "@/lib/agent-engine/db/request-pool";
import { requestTurnDeps } from "@/lib/agent-engine/agent/request-deps";
import { generateReplyDraft } from "@/lib/agent-engine/agent/reply-drafts";
import { motivoDaFalha } from "@/lib/agent-engine/agent/sugestao-de-resposta";
import { logger } from "@/lib/logger";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { traduzir } from "@/lib/i18n/dicionario";
@@ -71,12 +73,18 @@ export async function POST(_req: NextRequest, ctx: Ctx) {
{ draft: draft.original_body ?? "", draft_id: draft.id, status: draft.status },
{ requestId },
);
} catch {
return fail(
"reply_unavailable",
c.t("Não foi possível gerar a sugestão. Confira a publicação e a configuração do agente."),
422,
{ requestId },
);
} catch (erro) {
const motivo = motivoDaFalha(erro);
// O `catch` daqui era SEM NOME, e a causa morria nesta linha. Registrar é
// metade do conserto: a outra metade é a frase, que antes mandava conferir
// a publicação do agente mesmo quando o problema era outro.
logger.error("[draft-reply] não foi possível gerar a sugestão", {
requestId,
organizationId: c.auth.org.orgId,
conversationId,
motivo: motivo.codigo,
erro: erro instanceof Error ? erro.message : String(erro),
});
return fail(motivo.codigo, c.t(motivo.texto), 422, { requestId });
}
}
+22 -6
View File
@@ -43,13 +43,29 @@ interface ContactRow {
id: string;
organization_id: string;
wa_identity: string | null;
wa_lid: string | null;
phone_number: string | null;
avatar_storage_path: string | null;
}
/** `lid:123…` / `phone:+55…` → o chatId que o adapter espera. */
function chatIdFromIdentity(identity: string): string | null {
if (identity.startsWith("lid:")) return `${identity.slice(4)}@lid`;
if (identity.startsWith("phone:")) return `${identity.slice(6).replace(/\D/g, "")}@c.us`;
/**
* Identidade do contato → o chatId que o adapter espera.
*
* MESMA ORDEM de `resolveWahaChatId` (lib/waha/send.ts) e de `chatIdOf`
* (session-reconciler): `wa_lid` primeiro, `wa_identity` depois, telefone por
* último. Esta função só lia `wa_identity` — que é GERADA com o telefone antes
* do lid (migration 0122). Num número BR cujo wa_id não tem o nono dígito, isso
* produzia `55AA9BBBBCCCC@c.us`, endereço inexistente: o provider devolvia
* `profilePictureURL: null`, o job carimbava "sem foto" e o avatar nunca vinha.
* O lid não depende do telefone, por isso vem na frente.
*/
function chatIdDoContato(c: ContactRow): string | null {
if (c.wa_lid) return `${c.wa_lid}@lid`;
if (c.wa_identity?.startsWith("lid:")) return `${c.wa_identity.slice(4)}@lid`;
if (c.wa_identity?.startsWith("phone:")) {
return `${c.wa_identity.slice(6).replace(/\D/g, "")}@c.us`;
}
if (c.phone_number) return `${c.phone_number.replace(/\D/g, "")}@c.us`;
return null;
}
@@ -75,7 +91,7 @@ async function handle(req: NextRequest): Promise<Response> {
// declarada irreversível no produto; esta linha é o que sustenta isso.
const { data: contatos, error: queryError } = await admin
.from("contacts")
.select("id, organization_id, wa_identity, avatar_storage_path")
.select("id, organization_id, wa_identity, wa_lid, phone_number, avatar_storage_path")
.not("wa_identity", "is", null)
.eq("is_anonymized", false)
.or(`avatar_updated_at.is.null,avatar_updated_at.lt.${cutoff}`)
@@ -93,7 +109,7 @@ async function handle(req: NextRequest): Promise<Response> {
let falhas = 0;
for (const c of rows) {
const chatId = c.wa_identity ? chatIdFromIdentity(c.wa_identity) : null;
const chatId = chatIdDoContato(c);
// Carimba mesmo sem conseguir resolver o chatId: sem isso o contato voltaria
// em TODA rodada do cron, para sempre, batendo no canal à toa.
//
+78
View File
@@ -359,6 +359,84 @@ describe("GET /api/v1/system/version", () => {
expect(body.data.run.from_version).toBe("1.0.0");
});
it("terminou BEM e o host ainda não bateu: a tela já sabe, e o botão some", async () => {
// O defeito: `run_result` com sucesso fecha o run e NÃO toca
// `current_version` — quem escreve essa coluna é o heartbeat do host, de 5
// em 5 minutos. Nessa janela `latest !== current` continuava verdadeiro e a
// tela voltava do reinício oferecendo "Atualizar agora" para a versão que
// acabou de ser instalada. Quem clicou fazia tudo de novo.
versionRow.current_version = "1.0.0";
versionRow.latest_version = "1.1.0";
versionRow.updated_at = "2026-09-11T13:55:00.000Z";
runRow = {
id: "77777777-7777-4777-8777-777777777777",
status: "success",
last_step: "banco",
dispatched_at: "2026-09-11T13:58:00.000Z",
finished_at: "2026-09-11T14:00:00.000Z",
from_version: "1.0.0",
to_version: "1.1.0",
log_tail: "",
};
vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
const { GET } = await import("../version/route");
const body = await (await GET(get())).json();
expect(body.data.current_version).toBe("1.1.0");
expect(
body.data.update_available,
"a tela voltaria oferecendo a versão que acabou de ser instalada",
).toBe(false);
expect(body.data.just_updated).toBe(true);
});
it("o host confirmou: a janela se fecha sozinha, sem ninguém limpar nada", async () => {
// O fim de validade. Depois da batida, quem manda volta a ser o host —
// senão um run de sucesso nomearia a versão no ar para sempre, que é
// exatamente o defeito que o `rollbackFoiSuperado` já pagou uma vez.
versionRow.current_version = "1.1.0";
versionRow.latest_version = "1.1.0";
versionRow.updated_at = "2026-09-11T14:05:00.000Z";
runRow = {
id: "77777777-7777-4777-8777-777777777777",
status: "success",
last_step: "banco",
dispatched_at: "2026-09-11T13:58:00.000Z",
finished_at: "2026-09-11T14:00:00.000Z",
from_version: "1.0.0",
to_version: "1.1.0",
log_tail: "",
};
vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
const { GET } = await import("../version/route");
const body = await (await GET(get())).json();
expect(body.data.current_version).toBe("1.1.0");
expect(body.data.just_updated).toBe(false);
});
it("o pedido em espera carrega `dispatched_at` — é o relógio da tela", async () => {
// Entre o clique e o agente pegar o pedido passam até 5 minutos, e nesse
// tempo `last_step` é nulo: a tela mostrava quatro círculos vazios, imóveis,
// sem nada que distinguisse "esperando" de "travou". Sem esta data não há
// como contar o tempo — e recarregar a página não pode zerar a conta, então
// ela vem do servidor, nunca de quando a aba abriu.
runRow = {
id: "88888888-8888-4888-8888-888888888888",
status: "dispatched",
last_step: null,
dispatched_at: "2026-09-11T13:58:00.000Z",
finished_at: null,
from_version: "1.0.0",
to_version: "1.1.0",
log_tail: "",
};
vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
const { GET } = await import("../version/route");
const body = await (await GET(get())).json();
expect(body.data.run.dispatched_at).toBe("2026-09-11T13:58:00.000Z");
expect(body.data.run.last_step).toBeNull();
expect(body.data.just_updated).toBe(false);
});
it("sem `finished_at`, o run velho ainda decide — ausência de prova não é prova de deploy", async () => {
// A guarda acima só pode agir quando existe o par de datas. Um run gravado
// por uma versão antiga do agente não tem `finished_at`, e aí o
+25 -2
View File
@@ -12,7 +12,13 @@ import { loadAuthUser } from "@/lib/auth/server";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
import { extractChangelogRange } from "@/lib/system/changelog";
import { isRunStale, rollbackFoiSuperado, type RunStatus, type RunStep } from "@/lib/system/update-run";
import {
isRunStale,
rollbackFoiSuperado,
sucessoJaInstalado,
type RunStatus,
type RunStep,
} from "@/lib/system/update-run";
export const dynamic = "force-dynamic";
@@ -90,10 +96,18 @@ export async function GET(_req: NextRequest): Promise<Response> {
current,
run,
);
// O outro lado do mesmo silêncio: o run deu CERTO e o host ainda não bateu.
// `current_version` segue nomeando a versão antiga por até 5 minutos, e sem
// isto `update_available` continua verdadeiro — a tela volta do reinício
// oferecendo "Atualizar agora" para a versão que acabou de ser instalada.
const acabouDeInstalar = sucessoJaInstalado(version?.updated_at, run?.finished_at, run);
const running =
run?.status === "failed_rolled_back" && run.from_version && !rollbackSuperado
? run.from_version
: current;
: acabouDeInstalar && run?.to_version
? run.to_version
: current;
if (!user.is_platform_admin) {
return ok({ current_version: running, is_owner: false });
@@ -127,6 +141,11 @@ export async function GET(_req: NextRequest): Promise<Response> {
// não tocada por nenhum heartbeat, coluna com o default da migration).
has_known_release: version?.has_known_release ?? true,
agent_online: !Number.isNaN(lastSeen) && now.getTime() - lastSeen < AGENT_OFFLINE_AFTER_MS,
// A janela em que a atualização TERMINOU e o host ainda não contou. É o que
// deixa a tela dizer "pronto, está na versão X" em vez de cair no texto
// genérico de quem nunca atualizou nada — e ela se fecha sozinha na batida
// seguinte do agente.
just_updated: acabouDeInstalar,
notes:
faixa && faixa.secoes.length > 0
? {
@@ -147,6 +166,10 @@ export async function GET(_req: NextRequest): Promise<Response> {
run: run
? {
id: run.id,
// A tela CONTA o tempo desde aqui. Sem esta data, o intervalo entre o
// clique e o agente pegar o pedido é uma lista de quatro círculos
// vazios, parada, sem nada que se mexa.
dispatched_at: run.dispatched_at,
// `unknown` é derivado aqui, não gravado: um agente morto não
// consegue anunciar a própria morte.
status:
+2
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { requireAuth } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
import { ActivityReportClient } from "./_components/ActivityReportClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Atividades" };
export default async function ActivitiesReportPage() {
const user = await requireAuth();
@@ -65,7 +65,8 @@ function Conferencia({
estado: CamadaDeSeguranca | undefined;
podeEditar: boolean;
salvando: boolean;
onToggle: (layer: string, v: boolean) => void;
/** Só o valor: QUAL camada gravar é decisão de quem monta o item, não do item. */
onToggle: (v: boolean) => void;
}) {
const t = useT();
// Prefixo próprio para o ITEM: os controles dentro dele têm testid começando em
@@ -95,7 +96,7 @@ function Conferencia({
data-testid={`conferencia-${c.nome}-liga`}
checked={estado?.efetivo ?? false}
disabled={!podeEditar || salvando}
onCheckedChange={(v) => onToggle(c.nome, v)}
onCheckedChange={(v) => onToggle(v)}
aria-label={t(c.rotulo)}
/>
<span className="text-xs text-muted-foreground">
@@ -130,12 +131,18 @@ export function PainelDeSeguranca() {
const porNome = new Map((camadas.data?.camadas ?? []).map((c) => [c.layer as string, c]));
const podeEditar = camadas.data?.podeEditar ?? false;
const props = (camada: string | null) => ({
// `camada` — a chave de `org_guardrail_layers` — e NÃO `nome`, que é o
// identificador de tela. O interruptor mandava `c.nome` ("jailbreak_detect"),
// a rota valida contra o enum de camadas ("jailbreak"), e ligar qualquer uma
// das duas devolvia 422 em toda instalação. O `as` de antes escondia isso do
// typecheck; sem ele, trocar de campo vira erro de compilação.
const props = (camada: ConferenciaDeSaida["camada"]) => ({
estado: camada === null ? undefined : porNome.get(camada),
podeEditar,
salvando: gravar.isPending,
onToggle: (layer: string, v: boolean) =>
gravar.mutate({ layer: layer as CamadaDeSeguranca["layer"], enabled: v }),
onToggle: (v: boolean) => {
if (camada !== null) gravar.mutate({ layer: camada, enabled: v });
},
});
return (
+2
View File
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { NavHub } from "@/components/shell/NavHub";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Agente de IA" };
/**
* Hub da área de IA.
+2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { NavHub } from "@/components/shell/NavHub";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Análise" };
/**
* Hub da Análise.
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
@@ -6,6 +7,7 @@ import { ROLE_RANK } from "@/lib/auth/types";
import { AuditClient } from "./_client";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Audit Log" };
export default async function AuditPage() {
const user = await requireAuth();
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
@@ -6,6 +7,7 @@ import { ConexoesShell } from "@/components/connections/ConexoesShell";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Conexões" };
export default async function ConnectionsPage() {
const user = await requireAuth();
+2
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { ContactsListClient } from "./_client";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Contatos" };
export default function ContactsPage() {
return <ContactsListClient />;
+2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { NavHub } from "@/components/shell/NavHub";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "CRM" };
/**
* Hub do CRM.
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Link from "next/link";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
@@ -5,6 +6,7 @@ import { InboxLayout } from "@/components/inbox/InboxLayout";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Inbox" };
export default async function InboxPage({
searchParams,
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Kanban } from "@/lib/ui/icons";
@@ -8,6 +9,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
import { FunisClient, type FunilDaLista } from "./_client";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Funis" };
/**
* A lista de funis — e o lugar onde eles se gerenciam.
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
import { ROLE_RANK } from "@/lib/auth/types";
@@ -5,6 +6,7 @@ import { ROLE_RANK } from "@/lib/auth/types";
import { MetricsClient } from "./_components/MetricsClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Desempenho" };
export default async function MetricsPage() {
const user = await requireAuth();
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
@@ -9,6 +10,7 @@ import { createClient } from "@/lib/supabase/server";
import { ProdutosClient } from "./_client";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Produtos" };
/**
* O CATÁLOGO DA LOJA — onde o preço que a IA responde é cadastrado.
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
@@ -5,6 +6,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
import { RiskRadarList } from "./_components/RiskRadarList";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Radar" };
export default async function RadarPage() {
const user = await requireAuth();
@@ -1,6 +1,6 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { ApiError } from "@/lib/api/types";
@@ -88,6 +88,25 @@ export function UpdatePanel() {
const nova = semV(data.latest_version);
if (rodando) {
/**
* ESTE É O TRECHO EM QUE A TELA PARECIA TRAVADA.
*
* O `POST /update` não executa nada: ele registra o pedido e vai embora.
* Quem executa é o `agent.sh` no host, e ele roda de 5 em 5 minutos — entre
* o clique e o primeiro passo passam até 5 minutos. Nesse intervalo
* `last_step` é nulo, os quatro círculos ficam todos vazios e nada se mexe.
*
* A pessoa que clicou não tem como distinguir isso de "quebrou". E a lista
* de passos, mostrada antes de qualquer passo existir, afirma que o sistema
* está trabalhando quando ele ainda nem recebeu a ordem.
*
* Então o estado de espera é SEU PRÓPRIO estado, com nome, com a razão da
* demora e com um relógio que anda.
*/
if (!data.run?.last_step) {
return <AguardandoOServidor dispatchedAt={data.run?.dispatched_at} destino={nova} />;
}
return (
<Layout titulo={`${t("Atualizando para a versão")} ${nova}`}>
<ol className="space-y-2 text-sm">
@@ -111,6 +130,35 @@ export function UpdatePanel() {
);
}
/**
* O OUTRO LADO DO MESMO SILÊNCIO — e este era pior que parecer travado.
*
* `run_result` com sucesso fecha o run e não toca `current_version`: quem
* escreve essa coluna é o heartbeat do host, de 5 em 5 minutos. Nesse
* intervalo `update_available` continuava verdadeiro, e a tela voltava do
* reinício oferecendo "Atualizar agora" para a versão que ACABOU de ser
* instalada — quem clicou fazia tudo de novo, ou concluía que não funcionou.
*
* `just_updated` é essa janela, e só ela: na batida seguinte o host confirma,
* o campo vira `false` sozinho e a tela cai no texto normal de quem está em
* dia. Não é um estado que alguém precise fechar.
*/
if (data.just_updated) {
return (
<Layout titulo={`${t("Pronto — você está na versão")} ${versao}`}>
<p className="text-sm">
{t("A atualização terminou e o sistema já está no ar na versão")}{" "}
<strong>{versao}</strong>.
</p>
<p className="mt-3 text-sm text-muted-foreground">
{t(
"O servidor confirma isso na próxima vez que falar comigo, daqui a alguns minutos — até lá, esta tela já sabe.",
)}
</p>
</Layout>
);
}
// As versões de uma falha vêm do RUN, nunca de `current_version`: essa
// última é o `git describe` do host, e a troca de código já tinha
// acontecido quando o app quebrou — ela nomeia a versão que FALHOU. O run
@@ -372,6 +420,74 @@ export function UpdatePanel() {
);
}
/**
* A ESPERA, com nome e com relógio.
*
* O pedido fica numa fila que o servidor lê de tempos em tempos — o `agent.sh`
* roda de 5 em 5 minutos no host. Antes disto a tela já mostrava a lista dos
* quatro passos, todos vazios, dizendo "Atualizando para a versão X": afirmava
* trabalho que ainda não tinha começado, e ficava imóvel por até cinco minutos.
*
* Três coisas, e nenhuma é enfeite:
*
* 1. **O nome certo do estado.** "Pedido enviado" ≠ "atualizando".
* 2. **A razão da demora**, dita antes de ela assustar. Quem sabe que o
* servidor confere a cada poucos minutos não lê a imobilidade como defeito.
* 3. **Um relógio que anda.** É o que separa "esperando" de "travou" quando
* não há mais nada se mexendo na tela — e ele conta o tempo do SERVIDOR
* (`dispatched_at`), não o de quando a aba foi aberta: recarregar a página
* não zera a conta.
*/
function AguardandoOServidor({
dispatchedAt,
destino,
}: {
dispatchedAt: string | undefined;
destino: string;
}) {
const t = useT();
// Um segundo, e não os 5s do poll: o poll traz dado do servidor, este tique
// só faz o relógio andar. Sem ele o número saltaria de 5 em 5 segundos.
const [agora, setAgora] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setAgora(Date.now()), 1_000);
return () => clearInterval(id);
}, []);
const inicio = dispatchedAt ? Date.parse(dispatchedAt) : NaN;
const segundos = Number.isNaN(inicio) ? null : Math.max(0, Math.floor((agora - inicio) / 1000));
return (
<Layout titulo={t("Pedido enviado — esperando o servidor pegar")}>
<p className="text-sm">
{t("Anotei o pedido de atualizar para a versão")} <strong>{destino}</strong>.{" "}
{t(
"O servidor confere se há algo a fazer de poucos em poucos minutos, então a atualização pode levar até cerca de cinco minutos para começar.",
)}
</p>
<p className="mt-3 text-sm">
<strong>{t("Esta tela ficar parada nesse tempo é normal")}</strong>
{t(" — ela se mexe sozinha assim que o servidor começar.")}
</p>
{segundos !== null && (
<p className="mt-3 text-sm tabular-nums text-muted-foreground" data-testid="espera-decorrida">
{t("Esperando há")} {formataEspera(segundos)}.
</p>
)}
<p className="mt-3 text-sm text-muted-foreground">
{t("Pode fechar esta página: o pedido já está registrado e não se perde.")}
</p>
</Layout>
);
}
/** "43 segundos" / "2 min 05 s" — sem biblioteca, e sem virar "0:5". */
function formataEspera(segundos: number): string {
if (segundos < 60) return `${segundos} s`;
const min = Math.floor(segundos / 60);
return `${min} min ${String(segundos % 60).padStart(2, "0")} s`;
}
function BotaoAtualizar({
mutate,
isPending,
+2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from "next";
import { NavHub } from "@/components/shell/NavHub";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Configurações" };
/**
* Hub de Organização.
+9 -1
View File
@@ -412,7 +412,15 @@ export function TiposDeAgendamentoClient({
disabled={salvando}
onClick={() =>
void comErro(
() => apiClient.patch("/api/v1/agenda/tipos", { id: tipo.id, is_active: true } as never),
// Rota PRÓPRIA, e o `as never` que estava aqui saiu.
//
// Este botão nunca funcionou: mandava `is_active` num
// PATCH cujo schema é `criarSchema.partial()`, onde
// esse campo não existe. Zod descarta chave
// desconhecida em silêncio, o corpo chegava vazio e a
// resposta era 422 "Nenhum campo para alterar.". O
// cast era o que impedia o typecheck de acusar.
() => apiClient.post("/api/v1/agenda/tipos/reativar", { id: tipo.id }),
"Tipo reativado.",
)
}
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
@@ -6,6 +7,7 @@ import { ROLE_RANK } from "@/lib/auth/types";
import { TarefasClient } from "./_components/TarefasClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Tarefas" };
/**
* TAREFAS — "ligar de volta na terça", num lugar que não é a memória de ninguém.
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
@@ -10,6 +11,7 @@ import { TeamInvitesClient } from "./_components/TeamInvitesClient";
import { AttendantsClient } from "./_components/AttendantsClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Equipe" };
/**
* As duas abas são endereçáveis, e isso não é conveniência.
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
@@ -5,6 +6,7 @@ import { ROLE_RANK } from "@/lib/auth/types";
import { TemplatesClient } from "./_components/TemplatesClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Respostas rápidas" };
export default async function TemplatesPage() {
const user = await requireAuth();
+2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { requireAuth, resolveActiveOrg } from "@/lib/auth/server";
import { ROLE_RANK } from "@/lib/auth/types";
@@ -5,6 +6,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
import { WebhooksClient } from "./_components/WebhooksClient";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Webhooks" };
export default async function WebhooksPage() {
const user = await requireAuth();
+15 -5
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { useT } from "@/hooks/i18n/useT";
import { sugestaoParaMostrar } from "@/lib/agent-engine/agent/sugestao-de-resposta";
type Draft = {
id: string;
revision: string;
@@ -43,7 +44,9 @@ export function ReplyReviewPanel({
message: string;
kind: "success" | "error";
} | null>(null);
const draft = query.data?.data.drafts[0];
// Antes: `drafts[0]`, o mais recente, QUALQUER que fosse o estado dele — então
// uma sugestão rejeitada ficava na tela para sempre, sem botão de fechar.
const draft = sugestaoParaMostrar(query.data?.data.drafts);
const body = draft ? (edits[draft.id] ?? draft.edited_body ?? draft.original_body ?? "") : "";
async function generate() {
setNotice(null);
@@ -184,11 +187,18 @@ export function ReplyReviewPanel({
)}
</>
)}
{/*
A confirmação da rejeição ("o feedback será usado na próxima sugestão")
estava amarrada a `draft` existir. Agora a rejeitada some da tela — que é
o conserto —, e sem esta mudança a confirmação sumiria junto com ela: a
pessoa clicaria em Rejeitar e a tela apenas esvaziaria, sem dizer nada.
Quando ainda há sugestão, o aviso continua amarrado a ela.
*/}
{notice &&
draft &&
notice.draftId === draft.id &&
(notice.kind === "error" ||
["approved", "sending", "sent", "dismissed"].includes(draft.status)) && (
(!draft ||
(notice.draftId === draft.id &&
(notice.kind === "error" ||
["approved", "sending", "sent", "dismissed"].includes(draft.status)))) && (
<p role="status" className="text-xs">
{notice.message}
</p>
+51
View File
@@ -0,0 +1,51 @@
# docker-compose.npm.yml — override para VPS que já tem Nginx Proxy Manager (ou
# proxy equivalente que NÃO lê labels Docker, ao contrário do Traefik) ocupando
# as portas 80/443.
#
# docker compose -f docker-compose.prod.yml -f docker-compose.npm.yml --env-file .env up -d
#
# QUANDO USAR
# -----------
# O `docker-compose.traefik.yml` já existente resolve o caso de um Traefik na
# frente (lê labels). O NPM não lê labels — o roteamento é configurado na UI
# dele (porta 81), num "Proxy Host" que aponta pro app por IP fixo dentro de
# uma rede Docker compartilhada. Este arquivo faz só a parte que falta nesse
# cenário: garantir que o `app` SEMPRE entre nessa rede, no MESMO IP que o
# Proxy Host espera.
#
# Sem isto, qualquer recriação do container `app` (deploy, `--force-recreate`,
# atualização de imagem) derruba o roteamento e o domínio volta a responder
# 502 — foi exatamente o que aconteceu no incidente de 2026-09-11: alguém
# tinha plugado a rede manualmente com `docker network connect`, o app foi
# recriado, a conexão manual se perdeu e o NPM ficou apontando pro vácuo.
#
# CONFIGURAR PRA OUTRA INSTALAÇÃO
# --------------------------------
# Ajuste PROXY_NETWORK_NAME e PROXY_NETWORK_APP_IP no .env se a rede ou o IP
# do seu Proxy Host forem outros. Pra descobrir os valores atuais: Nginx Proxy
# Manager > Proxy Hosts > <domínio> > Edit > Forward Hostname / IP, e a rede é
# a que o container do próprio NPM já está conectado (`docker inspect
# <container-do-npm> --format '{{json .NetworkSettings.Networks}}'`).
#
# O `caddy` deste projeto nunca deve subir aqui: as portas 80/443 já são do
# NPM, e o `docker-compose.prod.yml` sozinho tenta criá-lo mesmo assim (fica
# em `Created`, falhando o bind — inofensivo, mas suja o `docker ps`). Mesmo
# truque de profile do `docker-compose.traefik.yml`.
services:
app:
networks:
internal: {}
npm:
ipv4_address: ${PROXY_NETWORK_APP_IP:-172.18.0.3}
caddy:
profiles: ["caddy-nao-usado-com-proxy-externo"]
# `external: true` = quem cria esta rede não é este compose — é o stack do NPM
# (ou de quem hospeda o proxy). Rede ausente é recusada ANTES de criar
# qualquer coisa: "network X declared as external, but could not be found".
networks:
npm:
external: true
name: ${PROXY_NETWORK_NAME:-proxy_network}
+15 -2
View File
@@ -52,14 +52,27 @@ prompt e contribuição o Claude também leu as referências e rodou `quem-sou.s
Resumo em `antes.json`. Em nenhuma das cinco a IA achou um guia — não havia; ela varreu o
repositório (até 191 ferramentas numa pergunta) e respondeu por conta própria.
## Antigravity — o CLI (`agy --print`), 11/set
O app instala um CLI (`agy`) com modo sem interface. Rodei as cinco perguntas nele (modo plano,
permissões auto-aprovadas — sem isso o primeiro comando pedido pela doutrina global do dono
encerra a corrida sem resposta). Resultado: **o CLI não enxerga as skills do workspace** —
perguntado diretamente "quais skills do workspace você tem no contexto?", respondeu "NENHUMA".
Nas cinco corridas, o guia certo foi encontrado **por exploração** em duas (montar clínica: leu o
`SKILL.md` e as referências depois de 30 ferramentas; instalar: foi buscar o guia no GitHub), e não
foi encontrado em três (métricas, prompt, contribuir), que responderam por conta própria. Resumo
em `antigravity.json`. A prova do **app** Antigravity (que, pelas docs, lê `.agents/skills/`)
segue à mão.
## O que NÃO ficou provado
- **Hook de início de sessão** (`.claude/settings.json` → `sessao.sh`): em `claude -p` os hooks
de projeto **não executaram** nesta versão, mesmo com a pasta confiada em `~/.claude.json` —
medido com um hook que gravava um marcador em disco (nunca apareceu). A sessão interativa não foi
medida. O acionamento não depende dele: a descrição basta.
- **Cursor e Antigravity**: sem modo sem interface nesta máquina. Roteiro à mão em
"Decisão Implementações/CRED-003".
- **Cursor**: o CLI (`agent`) foi instalado em 11/set e exige `agent login` — a prova fica para
depois do login. **Antigravity**: o CLI não carrega skills do workspace (acima); o app segue com o
roteiro à mão em "Decisão Implementações/CRED-003".
- **Codex com muitas skills globais**: numa instalação com centenas de skills pessoais o Codex
encurta e depois remove as descrições da lista (medido em 08/set); o gate limita o que o repo
gasta desse orçamento, mas não o que a pessoa já tem.
@@ -0,0 +1,75 @@
[
{
"fase": "depois (CLI do Antigravity, agy --print, modo plan, permissões auto-aprovadas)",
"cli": "antigravity-cli",
"pergunta": "p1_instalar",
"skill_esperada": "deskcomm-instalar",
"skill_encontrada_por_exploracao": true,
"ferramentas": 19,
"turnos": 1,
"duracao_s": 78,
"leituras_de_skill": [
"run_command → {\"CommandLine\":\"python3 -c '\\nimport urllib.request\\n\\nurl = \\\"https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/.agents/skills/deskc",
"run_command → {\"CommandLine\":\"python3 -c '\\nimport urllib.request\\n\\nurl = \\\"https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/.agents/skills/deskc",
"run_command → {\"CommandLine\":\"python3 -c '\\nimport urllib.request\\n\\nurl = \\\"https://raw.githubusercontent.com/melgarafael/DeskcommCRM/main/.agents/skills/deskc"
],
"inicio_da_resposta": "Criei um plano passo a passo no documento de implantação:\n👉 [plano_instalacao_deskcomm_hostgator.md](file:///Users/rafaelmelgaco/.gemini/antigravity-cli/brain/dea7dc1f-79dc-4dc5-be8f-35cc291c5147/plano_instalacao_deskcomm_hostgator.md)\n\nO DeskcommCRM possui um instalador automatizado oficial feito "
},
{
"fase": "depois (CLI do Antigravity, agy --print, modo plan, permissões auto-aprovadas)",
"cli": "antigravity-cli",
"pergunta": "p2_cliente",
"skill_esperada": "deskcomm-cliente-novo",
"skill_encontrada_por_exploracao": true,
"ferramentas": 42,
"turnos": 1,
"duracao_s": 170,
"leituras_de_skill": [
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/wt/prova-skills/.agents/skills/DeskcommCRM/SKILL.md\"}",
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/DeskcommCRM/.agents/skills/DeskcommCRM/SKILL.md\"}",
"run_command → {\"CommandLine\":\"find /Users/rafaelmelgaco/wt/prova-skills -name \\\"SKILL.md\\\"\"}",
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/wt/prova-skills/.agents/skills/deskcomm-cliente-novo/SKILL.md\"}",
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/wt/prova-skills/.agents/skills/deskcomm-cliente-novo/references/nichos.md\"}",
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/wt/prova-skills/.agents/skills/deskcomm-cliente-novo/references/pela-tela.md\"}",
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/wt/prova-skills/.agents/skills/deskcomm-cliente-novo/references/prompt-do-agente.md\"}",
"view_file → {\"AbsolutePath\":\"/Users/rafaelmelgaco/wt/prova-skills/.agents/skills/deskcomm-cliente-novo/references/triagem.md\"}"
],
"inicio_da_resposta": "Elaborei o plano detalhado de configuração para o atendimento da sua clínica odontológica no artefato:\n👉 [plano_atendimento_clinica_odontologica.md](file:///Users/rafaelmelgaco/.gemini/antigravity-cli/brain/a166fdf3-fed1-4c68-b4f7-bd806086bd37/plano_atendimento_clinica_odontologica.md).\n\nO document"
},
{
"fase": "depois (CLI do Antigravity, agy --print, modo plan, permissões auto-aprovadas)",
"cli": "antigravity-cli",
"pergunta": "p3_metricas",
"skill_esperada": "deskcomm-metricas",
"skill_encontrada_por_exploracao": false,
"ferramentas": 63,
"turnos": 1,
"duracao_s": 228,
"leituras_de_skill": [],
"inicio_da_resposta": "Criei o plano detalhado de diagnóstico e análise no artefato [plano_analise_agente_vendas_ia.md](file:///Users/rafaelmelgaco/.gemini/antigravity-cli/brain/fcdd38b3-0879-4a1d-883a-16a50b91187e/plano_analise_agente_vendas_ia.md).\n\nO plano cobre de forma aprofundada os três pilares que você solicitou:\n"
},
{
"fase": "depois (CLI do Antigravity, agy --print, modo plan, permissões auto-aprovadas)",
"cli": "antigravity-cli",
"pergunta": "p4_prompt",
"skill_esperada": "deskcomm-prompt",
"skill_encontrada_por_exploracao": false,
"ferramentas": 7,
"turnos": 1,
"duracao_s": 66,
"leituras_de_skill": [],
"inicio_da_resposta": "Criei o plano detalhado para reestruturação do seu agente no arquivo [plano_otimizacao_agente_imobiliario.md](file:///Users/rafaelmelgaco/.gemini/antigravity-cli/brain/84a3b56b-ae01-499c-89d8-90ece737ad12/plano_otimizacao_agente_imobiliario.md).\n\n### Por onde começar (O Diagnóstico)\nO agente costuma"
},
{
"fase": "depois (CLI do Antigravity, agy --print, modo plan, permissões auto-aprovadas)",
"cli": "antigravity-cli",
"pergunta": "p5_contribuir",
"skill_esperada": "deskcomm-contribuir",
"skill_encontrada_por_exploracao": false,
"ferramentas": 21,
"turnos": 1,
"duracao_s": 81,
"leituras_de_skill": [],
"inicio_da_resposta": "Criei o plano detalhado de contribuição no artifact [`plano_contribuicao_bug_inbox.md`](file:///Users/rafaelmelgaco/.gemini/antigravity-cli/brain/d2a6362d-06f0-46f2-bcd0-d34551c67747/plano_contribuicao_bug_inbox.md).\n\nNo **DeskcommCRM**, o fluxo correto para contribuir pela primeira vez segue regras"
}
]
+7
View File
@@ -14,6 +14,11 @@ export interface SystemVersion {
/** O host já viu ao menos uma tag `v*` publicada neste repositório. */
has_known_release?: boolean;
agent_online?: boolean;
/**
* A atualização terminou bem e o host ainda não confirmou (janela de até 5
* min). Fora dela é `false` — o campo se fecha sozinho.
*/
just_updated?: boolean;
notes?: {
/** Um por versão da faixa que tem aviso, do mais novo ao mais antigo. */
requires_attention: Array<{ version: string; texto: string }>;
@@ -26,6 +31,8 @@ export interface SystemVersion {
id: string;
status: string;
last_step: string | null;
/** Quando o pedido foi registrado — a tela conta o tempo a partir daqui. */
dispatched_at?: string;
/** Versão que estava instalada quando o run começou. */
from_version: string;
/** Versão que o run tentou instalar. */
+51 -2
View File
@@ -1,18 +1,67 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import { useT } from "@/hooks/i18n/useT";
import type { TeamMember } from "@/hooks/team/useTeamMembers";
/** O inverso de `useRevokeMember`. Espelho dele, de propósito. */
const MEMBERS_KEY = ["team", "members"] as const;
/**
* Devolve o acesso de um membro revogado — o inverso de `useRevokeMember`.
*
* ─── Por que OTIMISTA, e não só invalidar ───────────────────────────────────
*
* Achado pela tela em 2026-09-10: só invalidar deixava a linha parada até
* alguém recarregar a página. Quem clicava não via nada acontecer e clicava de
* novo.
*
* Antes desta mudança o defeito era invisível: o revogado SUMIA da lista (a
* rota o filtrava fora), então qualquer atraso de atualização parecia efeito.
* Agora que a linha fica e só o estado muda, a atualização tem de ser imediata
* — senão a tela mente sobre o que acabou de acontecer.
*
* Mesmo desenho de `useChangeRole`: pinta na hora, desfaz se o servidor
* recusar, e reconcilia no fim.
*/
export function useReactivateMember() {
const qc = useQueryClient();
const t = useT();
return useMutation({
mutationFn: async (userId: string) =>
apiClient.post<{
data: { user_id: string; reactivated_at?: string; already_active?: boolean };
}>(`/api/v1/team/${userId}/reactivate`, {}),
onError: showApiError,
onMutate: async (userId) => {
await qc.cancelQueries({ queryKey: MEMBERS_KEY });
const previous = qc.getQueryData<{ data: TeamMember[] }>(MEMBERS_KEY);
qc.setQueryData<{ data: TeamMember[] }>(MEMBERS_KEY, (old) =>
old
? {
...old,
data: old.data.map((m) =>
m.user_id === userId ? { ...m, revoked_at: null } : m,
),
}
: old,
);
return { previous };
},
onError: (err, _userId, context) => {
// Desfaz: sem isto a tela ficaria dizendo "ativo" para alguém que o
// servidor recusou reativar — pior que não ter atualizado.
if (context?.previous) qc.setQueryData(MEMBERS_KEY, context.previous);
showApiError(err);
},
onSuccess: () => {
// Revogar avisa; devolver nao avisava. Achado pela tela: quem clicava
// ficava sem confirmacao de que o clique valeu — e a acao e justamente
// a que a pessoa faz com receio de ter errado.
toast.success(t("Acesso devolvido."));
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["team"] });
},
});
+32 -2
View File
@@ -1,8 +1,20 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { showApiError } from "@/components/feedback/ApiErrorToast";
import type { TeamMember } from "@/hooks/team/useTeamMembers";
const MEMBERS_KEY = ["team", "members"] as const;
/**
* Revoga o acesso de um membro.
*
* OTIMISTA pelo mesmo motivo de `useReactivateMember`: desde que o revogado
* passou a FICAR na lista (antes ele era filtrado fora pela rota), só invalidar
* deixava a linha parada até alguém recarregar a página — e quem clicava não
* via nada acontecer. Medido pela tela em 2026-09-10.
*/
export function useRevokeMember() {
const qc = useQueryClient();
return useMutation({
@@ -11,8 +23,26 @@ export function useRevokeMember() {
`/api/v1/team/${userId}/revoke`,
{},
),
onError: showApiError,
onSuccess: () => {
onMutate: async (userId) => {
await qc.cancelQueries({ queryKey: MEMBERS_KEY });
const previous = qc.getQueryData<{ data: TeamMember[] }>(MEMBERS_KEY);
qc.setQueryData<{ data: TeamMember[] }>(MEMBERS_KEY, (old) =>
old
? {
...old,
data: old.data.map((m) =>
m.user_id === userId ? { ...m, revoked_at: new Date().toISOString() } : m,
),
}
: old,
);
return { previous };
},
onError: (err, _userId, context) => {
if (context?.previous) qc.setQueryData(MEMBERS_KEY, context.previous);
showApiError(err);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["team"] });
},
});
+2 -1
View File
@@ -47,7 +47,8 @@ discordarem, vale o guia; quando o guia e `install.sh` discordarem, vale o insta
- **Qual IA vai atender** — o instalador pergunta antes das chaves: OpenRouter (uma chave, muitos
modelos, o mais simples), Anthropic (Claude, padrão do Enter) ou OpenAI. Se não for OpenAI, ele
pede a chave da OpenAI à parte e **opcional** (áudio e base de conhecimento — dá para cadastrar
depois pela tela).
depois pela tela). A chave do provedor principal **também é opcional**: dá para instalar sem ela
e cadastrar depois pela tela (IA › Credenciais).
- O **e-mail e a senha** que ela quer para entrar no CRM (o primeiro admin).
## Passo a passo que você conduz
+3 -3
View File
@@ -42,8 +42,8 @@ bash install.sh
> contrato desse modo. Se preferir instalar por conta própria, responda `n` e rode
> `curl -fsSL https://get.docker.com | sh` antes.
O instalador pergunta o que precisa (domínio, chaves do Supabase e da Anthropic,
e-mail/senha do admin), gera o resto e sobe tudo.
O instalador pergunta o que precisa (domínio, chaves do Supabase, provedor de IA
— a chave pode ficar para depois —, e-mail/senha do admin), gera o resto e sobe tudo.
> Modo não-interativo: copie `.env.hostgator.example` (do repositório) para `.env`,
> preencha, e rode `bash install.sh --yes`.
@@ -85,7 +85,7 @@ Owner/Admin. Não dá para hospedar vários clientes numa conta só.
| VPS (Docker) | HostGator — VPS com Docker (n8n/OpenClaw/GatorClaw). Outras hospedagens com Docker também servem — se a sua já tiver proxy próprio nas portas 80/443, [veja aqui](#vps-que-já-vem-com-proxy-próprio-hostinger-coolify-dokploy) |
| Domínio | Registro de domínio (aponte um A-record pro IP do VPS) |
| Banco de dados | Conta grátis no [supabase.com](https://supabase.com) (3 chaves + connection string) |
| IA | Chave da [Anthropic](https://console.anthropic.com) |
| IA | Chave da [Anthropic](https://console.anthropic.com) — opcional: dá para instalar sem ela e cadastrar depois pela tela (IA › Credenciais) |
| WhatsApp | Seu número — conectado por QR code no onboarding |
| Token do Supabase (opcional) | [supabase.com/dashboard/account/tokens](https://supabase.com/dashboard/account/tokens) — com ele o instalador configura sozinho os links dos e-mails de acesso. **Ele não fica salvo:** é usado uma vez e some com o processo |
+46 -13
View File
@@ -4,6 +4,7 @@ set -euo pipefail
COMPOSE="docker-compose.prod.yml"
COMPOSE_TRAEFIK="docker-compose.traefik.yml"
COMPOSE_NPM="docker-compose.npm.yml"
# Proxy reverso desta instalação. Vem do .env (load_env), com default 'caddy' —
# ou seja, toda instalação que já existe continua exatamente como está.
@@ -12,26 +13,30 @@ COMPOSE_TRAEFIK="docker-compose.traefik.yml"
# traefik → a VPS JÁ tem um Traefik nessas portas (Hostinger, Coolify,
# Dokploy...). Entra o override, que desliga o Caddy e publica o app
# por labels. Ver o cabeçalho de docker-compose.traefik.yml.
# npm → a VPS JÁ tem um Nginx Proxy Manager nessas portas (não lê labels
# Docker — o roteamento é manual, na UI dele). Entra o override, que
# desliga o Caddy e garante o `app` na rede/IP que o Proxy Host
# espera. Ver o cabeçalho de docker-compose.npm.yml.
#
# Todo `docker compose` do kit passa por aqui: com proxy externo, um comando sem
# o override subiria o Caddy e ele iria bater de frente com o Traefik.
# o override subiria o Caddy e ele iria bater de frente com o proxy da hospedagem.
dc() {
if [ "${REVERSE_PROXY:-caddy}" = "traefik" ]; then
docker compose -f "$COMPOSE" -f "$COMPOSE_TRAEFIK" "$@"
else
docker compose -f "$COMPOSE" "$@"
fi
case "${REVERSE_PROXY:-caddy}" in
traefik) docker compose -f "$COMPOSE" -f "$COMPOSE_TRAEFIK" "$@" ;;
npm) docker compose -f "$COMPOSE" -f "$COMPOSE_NPM" "$@" ;;
*) docker compose -f "$COMPOSE" "$@" ;;
esac
}
# A mesma lista de -f, como texto, para as mensagens que ensinam o comando ao
# dono. Se a mensagem omitisse o override numa instalação com proxy externo, o
# próprio dono derrubaria o site seguindo a instrução do kit.
dc_files() {
if [ "${REVERSE_PROXY:-caddy}" = "traefik" ]; then
printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_TRAEFIK"
else
printf -- '-f %s' "$COMPOSE"
fi
case "${REVERSE_PROXY:-caddy}" in
traefik) printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_TRAEFIK" ;;
npm) printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_NPM" ;;
*) printf -- '-f %s' "$COMPOSE" ;;
esac
}
# ── A rede externa por onde o proxy de fora alcança o app ────────────────────
@@ -171,6 +176,18 @@ veredito_rede_do_proxy() { # veredito_rede_do_proxy <driver encontrado> <rede>
# Define TRAEFIK_NETWORK quando ela vem vazia — de propósito, é o mesmo default
# que o instalador grava no .env.
garantir_rede_do_proxy() {
# NPM nunca é criado por nós: a rede é sempre do stack do Proxy Manager (ou de
# quem hospeda), então não há "nossa" bridge para oferecer — só checar e, se
# sumiu (prune, down -v), morrer explicando em vez do opaco erro do compose.
if [ "${REVERSE_PROXY:-caddy}" = "npm" ]; then
local rede
rede="${PROXY_NETWORK_NAME:-proxy_network}"
docker network inspect "$rede" >/dev/null 2>&1 && return 0
die "A rede Docker '$rede' (a do Nginx Proxy Manager) não existe.
Rode 'docker network ls', identifique a rede do seu NPM (Settings > a que o
contêiner dele já está conectado) e ponha PROXY_NETWORK_NAME=<nome> no .env
antes de tentar de novo."
fi
[ "${REVERSE_PROXY:-caddy}" = "traefik" ] || return 0
local nossa drv erro
nossa="$(rede_reservada_do_proxy)"
@@ -805,9 +822,25 @@ setup_event_log_drain_cron() {
# O dono vê o script morrer sem mensagem, numa instalação que na verdade
# funcionou.
#
# ACHADO DUAS VEZES, POR DUAS PESSOAS QUE NÃO SE FALARAM, NO MESMO DIA:
# @luiscgc91 (PR #683) e @rafaelbatistazz (issue #715 + PR #726), os dois
# instalando numa VPS limpa. Os dois escreveram EXATAMENTE a mesma linha. Isso
# não é redundância — é a medida de quanto o defeito doía, e a razão de este
# comentário ser longo: ele existe para a terceira pessoa não precisar
# descobrir de novo.
#
# A issue #715 descreve o sintoma como quem o viveu: o instalador para logo
# depois de "✓ chave de cifra ativa no banco", cai na tela "A instalação
# parou", e os contêineres estão SAUDÁVEIS. Rodar de novo passa — porque aí o
# crontab já não está vazio, o que faz o defeito parecer fantasma.
#
# Reproduzido com um dublê de `crontab` que sai 1 no `-l`: sem o `|| true`, a
# linha seguinte a este bloco nunca é alcançada. Vigiado por
# `tests/shell/cron-sem-crontab-previo.test.sh`.
# linha seguinte a este bloco nunca é alcançada. Vigiado por DOIS testes, de
# propósito: `tests/shell/cron-sem-crontab-previo.test.sh` mede cada função
# isolada, e o bloco `cron numa VPS sem crontab nenhum` de
# `hostgator-setup-kit/test-validators.sh` (de @rafaelbatistazz) roda AS DUAS
# no mesmo processo — como o `install.sh` faz — e confere que as duas linhas
# foram gravadas.
#
# Stdin vazio para o `cron_merge` é exatamente o que "sem crontab prévio" deve
# produzir — o comportamento não muda, só o status.
+69 -17
View File
@@ -22,6 +22,7 @@ COMUNIDADE_URL="https://lp-comunidade.automatiklabs.com.br"
REPO_DIR="${REPO_DIR:-deskcommcrm}"
COMPOSE="docker-compose.prod.yml"
COMPOSE_TRAEFIK="docker-compose.traefik.yml"
COMPOSE_NPM="docker-compose.npm.yml"
NONINTERACTIVE=0
[ "${1:-}" = "--yes" ] && NONINTERACTIVE=1
@@ -29,18 +30,18 @@ NONINTERACTIVE=0
# usar o _common.sh). As duas funções abaixo são gêmeas das de lá — se mexer
# numa, mexa na outra.
dc() {
if [ "${REVERSE_PROXY:-caddy}" = "traefik" ]; then
docker compose -f "$COMPOSE" -f "$COMPOSE_TRAEFIK" "$@"
else
docker compose -f "$COMPOSE" "$@"
fi
case "${REVERSE_PROXY:-caddy}" in
traefik) docker compose -f "$COMPOSE" -f "$COMPOSE_TRAEFIK" "$@" ;;
npm) docker compose -f "$COMPOSE" -f "$COMPOSE_NPM" "$@" ;;
*) docker compose -f "$COMPOSE" "$@" ;;
esac
}
dc_files() {
if [ "${REVERSE_PROXY:-caddy}" = "traefik" ]; then
printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_TRAEFIK"
else
printf -- '-f %s' "$COMPOSE"
fi
case "${REVERSE_PROXY:-caddy}" in
traefik) printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_TRAEFIK" ;;
npm) printf -- '-f %s -f %s' "$COMPOSE" "$COMPOSE_NPM" ;;
*) printf -- '-f %s' "$COMPOSE" ;;
esac
}
# ── Aparência ───────────────────────────────────────────────────────────────
@@ -1161,10 +1162,19 @@ escolher_provedor
# O campo da chave do provedor ESCOLHIDO — e só dele. Pedir as três faria a
# pessoa achar que precisa das três.
#
# O campo é `opcional` (issue #670). `docs/deploy-selfhost` promete que dá para
# "deixar vazio e cadastrar a chave depois", e o runtime concorda (`lib/env.ts`
# trata as três chaves como opcionais; faltar todas é `warn`, não erro) — mas o
# instalador exigia uma chave que PASSASSE numa chamada real, e não havia
# caminho para subir o produto sem antes abrir conta num provedor de IA. Quem
# pula instala, e o caminho de volta sai na tela final (`pendencia_da_ia`, no
# fecho). O validador continua valendo para quem digita uma chave — o que
# mudou é que pular deixou de ser erro.
case "$AI_PROVIDER" in
openrouter) CAMPO_IA="OPENROUTER_API_KEY|Chave da OpenRouter — a IA que atende (openrouter.ai/keys)||v_openrouter|secret|";;
openai) CAMPO_IA="OPENAI_API_KEY|Chave da OpenAI — a IA que atende (platform.openai.com/api-keys)||v_openai|secret|";;
*) CAMPO_IA="ANTHROPIC_API_KEY|Chave da Anthropic — a IA que atende (console.anthropic.com)||v_anthropic|secret|";;
openrouter) CAMPO_IA="OPENROUTER_API_KEY|Chave da OpenRouter — a IA que atende (openrouter.ai/keys; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openrouter|secret|opcional";;
openai) CAMPO_IA="OPENAI_API_KEY|Chave da OpenAI — a IA que atende (platform.openai.com/api-keys; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openai|secret|opcional";;
*) CAMPO_IA="ANTHROPIC_API_KEY|Chave da Anthropic — a IA que atende (console.anthropic.com; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_anthropic|secret|opcional";;
esac
# A chave da OpenAI é pedida À PARTE quando ela NÃO é o provedor de conversa,
@@ -1551,10 +1561,15 @@ esac
envq SCHEDULER_PULL_POLICY "$PULL_POLICY_ALVO"
envq DOMAIN "$DOMAIN"
envq ACME_EMAIL "$ACME_EMAIL"
printf '# Proxy reverso: "caddy" (o kit sobe o dele nas portas 80/443) ou "traefik"\n'
printf '# (o VPS já tem um Traefik nessas portas — Hostinger, Coolify, Dokploy...).\n'
printf '# Em "traefik" entra o docker-compose.traefik.yml, que desliga o Caddy e\n'
printf '# publica o app por labels. TRAEFIK_* só é lido nesse modo.\n'
printf '# Proxy reverso: "caddy" (o kit sobe o dele nas portas 80/443), "traefik"\n'
printf '# (o VPS já tem um Traefik nessas portas — Hostinger, Coolify, Dokploy...)\n'
printf '# ou "npm" (Nginx Proxy Manager, que não lê labels — ver o cabeçalho de\n'
printf '# docker-compose.npm.yml). Em "traefik" entra o docker-compose.traefik.yml,\n'
printf '# que desliga o Caddy e publica o app por labels (TRAEFIK_* só é lido nesse\n'
printf '# modo). Em "npm" entra o docker-compose.npm.yml, que também desliga o Caddy\n'
printf '# e fixa o app na rede/IP que o Proxy Host espera (PROXY_NETWORK_* só é lido\n'
printf '# nesse modo, e é sempre configuração manual — não há como detectar o NPM\n'
printf '# sozinho, ao contrário do Traefik).\n'
envq REVERSE_PROXY "$REVERSE_PROXY"
# O default mora aqui, junto dos irmãos TRAEFIK_* logo abaixo, e não numa
# atribuição solta lá atrás: em modo caddy ninguém DECIDE esta variável, e
@@ -1927,6 +1942,42 @@ $(sed 's/^/ /' "$PENDENCIA_EMAIL")
PEND
}
# ── A pendência da IA, quando a chave ficou para depois ─────────────────────
# A #670 tornou o campo da chave `opcional`: antes o instalador morria sem uma
# chave que passasse numa chamada real, contra a doc e contra o runtime.
# Instalar sem chave é legítimo; o que não pode é a pessoa terminar sem saber
# que a IA ainda não atende e ONDE cadastrar depois. Este bloco repete o
# caminho na TELA FINAL, que é a única tela que a pessoa lê inteira.
#
# Critério: nenhuma credencial DE AMBIENTE preenchida — nem a do provedor
# escolhido, nem o AI Gateway (que tem precedência na resolução do chat, ver
# `.env.hostgator.example`). Credencial cadastrada pela tela (banco) não dá
# para ver daqui; quem já cadastrou reconhece o aviso e ignora.
pendencia_da_ia() {
local chave="" rotulo=""
case "${AI_PROVIDER:-anthropic}" in
openrouter) chave="${OPENROUTER_API_KEY:-}"; rotulo="OpenRouter" ;;
openai) chave="${OPENAI_API_KEY:-}"; rotulo="OpenAI" ;;
*) chave="${ANTHROPIC_API_KEY:-}"; rotulo="Anthropic" ;;
esac
[ -n "$chave" ] && return 0
[ -n "${AI_GATEWAY_API_KEY:-}" ] && return 0
cat <<PEND
$(c_ylw " ─── A IA ainda não atende — falta cadastrar a chave ───")
Você deixou a chave de IA para depois, e o CRM está no ar sem ela. O que
ainda não funciona é o agente: ele responde quando uma credencial existir.
Quando tiver a chave da ${rotulo}, cadastre em:
IA › Credenciais
A chave fica CIFRADA no banco — não precisa mexer no .env nem reiniciar nada.
PEND
}
PENDENCIA_EMAIL="$(mktemp)"
PENDENCIA_ARQUIVO="$PENDENCIA_EMAIL" \
SUPABASE_ACCESS_TOKEN="${SUPABASE_ACCESS_TOKEN:-}" \
@@ -2139,6 +2190,7 @@ $(c_grn " Instalação concluída!")
$(c_grn "═══════════════════════════════════════════════════════")
$(pendencia_dos_emails)
$(pendencia_da_ia)
1. Acesse: https://${DOMAIN}
(o SSL leva ~1min pra emitir no primeiro acesso)
+244
View File
@@ -1337,6 +1337,45 @@ STUB
rede_e2e "overlay attachable: install/update seguem" segue overlay true
rede_e2e "overlay sem attachable: morre explicando" morre overlay false
echo "proxy reverso: NPM (Nginx Proxy Manager)"
# NPM nunca é auto-detectado (ao contrário do Traefik, ele não fala por labels) —
# é sempre REVERSE_PROXY=npm escrito à mão no .env. O que precisa de prova é o
# CALL SITE: dc()/dc_files() entram o override certo, e garantir_rede_do_proxy
# não deixa o `up -d` morrer no erro opaco do compose quando a rede do NPM sumiu
# (prune, down -v) — o mesmo risco que o Traefik já tinha, e o update.sh roda
# sozinho pelo agent.sh, sem ninguém lendo a tela.
if REVERSE_PROXY=npm dc_files | grep -q 'docker-compose.npm.yml'; then
printf ' ✓ dc_files() entra o docker-compose.npm.yml com REVERSE_PROXY=npm\n'
else
printf ' ✗ dc_files() não entrou o docker-compose.npm.yml com REVERSE_PROXY=npm (deu: %s)\n' \
"$(REVERSE_PROXY=npm dc_files)"; fail=1
fi
if REVERSE_PROXY=caddy dc_files | grep -q 'docker-compose.npm.yml'; then
printf ' ✗ dc_files() entrou o docker-compose.npm.yml SEM REVERSE_PROXY=npm (vacuidade)\n'; fail=1
else
printf ' ✓ REVERSE_PROXY=caddy (default): dc_files() não menciona o override do NPM\n'
fi
npm_rede_e2e() { # npm_rede_e2e <descrição> <segue|morre> <rede existe: 0 ok, 1 sumiu>
local desc="$1" esperado="$2" existe="$3" dir real kit="$PWD"
dir="$(mktemp -d)"; mkdir -p "$dir/bin"
cat > "$dir/bin/docker" <<STUB
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$dir/chamadas.log"
[ "\$1" = network ] && [ "\$2" = inspect ] && exit $existe
exit 0
STUB
chmod +x "$dir/bin/docker"
if (cd "$dir" && env PATH="$dir/bin:$PATH" REVERSE_PROXY=npm PROJECT_DIR="$dir" \
bash -c '. "$1/_common.sh"; garantir_rede_do_proxy' _ "$kit") >/dev/null 2>&1
then real=segue; else real=morre; fi
rm -rf "$dir"
if [ "$real" = "$esperado" ]; then printf ' ✓ %s\n' "$desc"
else printf ' ✗ %s (deu %s, esperava %s)\n' "$desc" "$real" "$esperado"; fail=1; fi
}
npm_rede_e2e "rede do NPM presente: install/update seguem" segue 0
npm_rede_e2e "rede do NPM sumiu (prune/down -v): morre explicando" morre 1
echo "proxy reverso: quanta confiança a eleição merece"
# A eleição por porta publicada traz a evidência (a coluna Ports diz ':80->'); a
# varredura por modo host não traz nenhuma — em modo host a coluna é vazia para
@@ -1912,6 +1951,106 @@ provedor_ok "OpenAI: instala e o .env sai inteiro" OPENAI_API_KEY sk-te
provedor_ok "Anthropic: instala e o .env sai inteiro" ANTHROPIC_API_KEY sk-ant-teste anthropic
echo "integração: instalar SEM chave de IA — o caminho que a documentação prometia (issue #670)"
# A issue #670: `docs/deploy-selfhost` promete "deixe vazio e cadastre a chave
# depois em IA › Credenciais", e o runtime concorda — `lib/env.ts` trata as três
# chaves como opcionais, e faltar todas é `warn`, não erro. O instalador, não:
# exigia uma chave que PASSASSE numa chamada real ao provedor, e a instalação
# inteira parava sem ela. Não havia caminho para subir o produto sem antes abrir
# conta num provedor de IA.
#
# O que este cenário mede é o caminho inteiro, com o .env de quem não tem conta
# em provedor nenhum: BASE_ENV sem as três chaves e sem o AI Gateway (que tem
# precedência na resolução do chat). Com o campo de volta a obrigatório, o
# `ask_one` morre em "Falta ANTHROPIC_API_KEY (modo --yes exige .env
# preenchido)" na coleta de configuração, e é a PRIMEIRA asserção que fica
# vermelha.
TMP_SEM_IA="$(mktemp -d)"
(
montar_vps "$TMP_SEM_IA" "crmsemia" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DOCKER_LOG"
case "$1" in
compose) case "$*" in *" exec "*) printf 'healthy\n{"data":{"status":"healthy"}}\n' ;; esac; exit 0 ;;
esac
exit 0
STUB
mkdir -p "$VPS_PROJ/supabase"; : > "$VPS_PROJ/supabase/baseline.sql"
# O .env da entrevista pulada: BASE_ENV sem NENHUMA chave de IA.
printf '%s\n' "$BASE_ENV" \
| grep -vE '^(ANTHROPIC|OPENROUTER|OPENAI)_API_KEY=|^AI_GATEWAY_API_KEY=' > "$VPS_PROJ/.env"
rodar_sem_ia() {
: > "$VPS_LOG"
(cd "$VPS_PROJ" && env PATH="$VPS_RAIZ/bin:$PATH" DOCKER_LOG="$VPS_LOG" \
CRONTAB_SANDBOX="$CRONTAB_SANDBOX" SUPABASE_ACCESS_TOKEN= \
bash "$VPS_RAIZ/install.sh" --yes 2>&1 || true) | sed -E 's/\x1b\[[0-9;]*m//g'
}
saida="$(rodar_sem_ia)"
# A marca do defeito: com o campo obrigatório, o instalador morre aqui.
if printf '%s' "$saida" | grep -q 'exige .env preenchido'; then
printf ' ✗ o instalador ainda morre sem chave de IA — o campo do provedor não é `opcional`\n'
printf ' %s\n' "$(printf '%s' "$saida" | grep -m1 'exige .env preenchido')"
exit 1
fi
# CONTROLE POSITIVO: "não morreu" só significa alguma coisa se a instalação
# chegou ao fim; sem esta âncora, um install que parasse antes passaria.
if ! printf '%s' "$saida" | grep -q 'Instalação concluída'; then
printf ' ✗ a instalação sem chave de IA não chegou à tela final — cenário inconclusivo, não verde\n'
printf ' última linha: %s\n' "$(printf '%s' "$saida" | grep -v '^$' | tail -1)"
exit 1
fi
# O .env sai INTEIRO: sem chave, a última linha do bloco continua presente —
# a mesma régua dos cenários de provedor acima.
if ! grep -qE '^OWNER_PASSWORD="' "$VPS_PROJ/.env"; then
printf ' ✗ o .env saiu pela metade na instalação sem chave de IA\n'
printf ' últimas chaves gravadas: %s\n' \
"$(grep -oE '^[A-Z_]+=' "$VPS_PROJ/.env" | tail -3 | tr '\n' ' ')"
exit 1
fi
# A chave que ninguém respondeu sai DECLARADA e vazia — a mesma distinção
# entre ausente e declarada-e-vazia que o caso do APP_ACCENT_HEX guarda.
if ! grep -qE '^ANTHROPIC_API_KEY=' "$VPS_PROJ/.env"; then
printf ' ✗ ANTHROPIC_API_KEY nem apareceu no .env (esperado: declarada e vazia)\n'; exit 1
fi
if [ -n "$(valor_no_env "$VPS_PROJ/.env" ANTHROPIC_API_KEY)" ]; then
printf ' ✗ ANTHROPIC_API_KEY veio com valor [%s] — ninguém digitou nada\n' \
"$(valor_no_env "$VPS_PROJ/.env" ANTHROPIC_API_KEY)"; exit 1
fi
# A TELA FINAL lembra o caminho de volta. A medição é no RABO (depois de
# "Instalação concluída"), como no caso do Site URL: é a única tela que a
# pessoa lê inteira, e um aviso no meio do log de dez minutos não conta.
rabo="${saida##*Instalação concluída}"
if ! printf '%s' "$rabo" | grep -q 'A IA ainda não atende'; then
printf ' ✗ a tela final não avisa que a IA ainda não atende\n'; exit 1
fi
if ! printf '%s' "$rabo" | grep -q 'IA › Credenciais'; then
printf ' ✗ o aviso da tela final não diz ONDE cadastrar a chave (IA › Credenciais)\n'; exit 1
fi
printf ' ✓ sem chave de IA: instala, .env inteiro, e a tela final dá o caminho de volta\n'
# ── O outro lado: com a chave, o aviso NÃO aparece ────────────────────────
# Sem isto, um `pendencia_da_ia` que imprimisse sempre passaria no caso acima
# e viraria ruído em toda instalação que já tem chave — inclusive nas rodadas
# de `provedor_ok` logo acima.
printf '%s\n' "$BASE_ENV" > "$VPS_PROJ/.env"
saida="$(rodar_sem_ia)"
if ! printf '%s' "$saida" | grep -q 'Instalação concluída'; then
printf ' ✗ (controle) a segunda rodada, com chave, não chegou à tela final — cenário inconclusivo\n'
exit 1
fi
rabo="${saida##*Instalação concluída}"
if printf '%s' "$rabo" | grep -q 'A IA ainda não atende'; then
printf ' ✗ com a chave presente, a tela final avisou que falta chave de IA\n'; exit 1
fi
printf ' ✓ com a chave presente, o lembrete não aparece (o aviso não é ruído permanente)\n'
) || fail=1
rm -rf "$TMP_SEM_IA"
echo "integração: instalação NOVA numa VPS com Traefik em modo host"
# O install.sh roda contra um `docker` dublê que imita a Hostinger: 80/443
# ocupadas, NINGUÉM publicando, um Traefik em `--network host`, e a rede do
@@ -2471,6 +2610,84 @@ NEXT_PUBLIC_APP_URL='https://crm.exemplo.com.br'")"
) || fail=1
rm -rf "$TMP6"
echo "integração: update.sh quando a rede do NPM sumiu"
# NPM nunca é "nossa" bridge — ninguém cria de novo, só morre explicando ANTES
# do `up -d`, em vez do opaco "network X declared as external, but could not
# be found" (o mesmo cuidado que o Traefik já tinha, agora pro segundo proxy
# que não fala por labels).
TMP7="$(mktemp -d)"
(
montar_vps "$TMP7" "crmupdatenpm" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DOCKER_LOG"
case "$1" in
compose) case "$*" in *" exec "*) printf 'healthy\n{"data":{"status":"healthy"}}\n' ;; esac; exit 0 ;;
network) case "$2" in inspect) exit 1 ;; esac; exit 0 ;;
esac
exit 0
STUB
(cd "$VPS_PROJ" && git init -q -b main . \
&& git -c user.email=t@exemplo -c user.name=teste add -A \
&& git -c user.email=t@exemplo -c user.name=teste commit -qm base \
&& git tag v9.9.9) >/dev/null 2>&1
saida="$(rodar update.sh --skip-backup "REVERSE_PROXY='npm'
PROXY_NETWORK_NAME='proxy_network'
INTERNAL_SECRET='segredo-de-teste'
NEXT_PUBLIC_APP_URL='https://crm.exemplo.com.br'")"
if grep -q -E '^compose .* up -d$' "$VPS_LOG"; then
printf ' ✗ o update.sh subiu a stack mesmo com a rede do NPM ausente\n'; exit 1
fi
if ! printf '%s' "$saida" | grep -q 'PROXY_NETWORK_NAME'; then
printf ' ✗ a morte não ensina a saída (PROXY_NETWORK_NAME no .env)\n'
printf ' saída: %s\n' "$(printf '%s' "$saida" | tail -3)"; exit 1
fi
printf ' ✓ o update.sh para ANTES do "up -d" e ensina a saída\n'
) || fail=1
rm -rf "$TMP7"
echo "integração: update.sh com proxy externo nunca recria o Caddy sozinho"
# `up -d --force-recreate --no-deps caddy` NOMEIA o serviço — e nomear um
# serviço ATIVA o profile dele no Compose mesmo com o override presente (é o
# mesmo defeito que o docker-compose.traefik.yml já documenta). Com um segundo
# proxy (Traefik OU NPM) já nas portas 80/443, isso sobe um Caddy que bate de
# frente com ele. A checagem por CADA valor evita que só o Traefik continue
# coberto e o NPM (o proxy novo) reproduza o defeito que motivou o guard.
caddy_skip_e2e() { # caddy_skip_e2e <descrição> <REVERSE_PROXY> <linha extra do .env> <deve tentar recriar: sim|nao>
local desc="$1" rp="$2" extra="$3" esperado="$4" dir tentou
dir="$(mktemp -d)"
(
montar_vps "$dir" "crmcaddyskip" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$DOCKER_LOG"
case "$1" in
compose) case "$*" in *" exec "*) printf 'healthy\n{"data":{"status":"healthy"}}\n' ;; esac; exit 0 ;;
esac
exit 0
STUB
(cd "$VPS_PROJ" && git init -q -b main . \
&& git -c user.email=t@exemplo -c user.name=teste add -A \
&& git -c user.email=t@exemplo -c user.name=teste commit -qm base \
&& git tag v9.9.9) >/dev/null 2>&1
rodar update.sh --skip-backup "REVERSE_PROXY='${rp}'
${extra}
INTERNAL_SECRET='segredo-de-teste'
NEXT_PUBLIC_APP_URL='https://crm.exemplo.com.br'" >/dev/null
if grep -qF -- '--force-recreate --no-deps caddy' "$VPS_LOG"; then
tentou=sim
else
tentou=nao
fi
if [ "$tentou" = "$esperado" ]; then printf ' ✓ %s\n' "$desc"
else printf ' ✗ %s (tentou recriar: %s, esperado: %s)\n' "$desc" "$tentou" "$esperado"; exit 1; fi
) || fail=1
rm -rf "$dir"
}
caddy_skip_e2e "caddy (default): recria o próprio proxy" caddy "" sim
caddy_skip_e2e "traefik: nunca recria o Caddy" traefik "TRAEFIK_NETWORK='crmcaddyskip_proxy'" nao
caddy_skip_e2e "npm: nunca recria o Caddy" npm "PROXY_NETWORK_NAME='proxy_network'" nao
echo "nome do projeto que o docker compose usa"
# O compose faz TrimLeft("_-") no basename. Sem isso, uma pasta /root/_deskcomm
# faz o kit calcular "_deskcomm" enquanto os contêineres carregam "deskcomm" — a
@@ -2566,6 +2783,33 @@ reexec_neg() {
reexec_neg
reexec_ok "o bloco de variáveis conhecidas acha o kit depois do cd"
echo "cron numa VPS sem crontab nenhum (#715)"
# VPS nova não tem crontab para o root: `crontab -l` sai 1. As rodadas acima
# nunca mediram isso, porque o sandbox já tinha linhas quando elas agendavam — e
# o install.sh morria em "Ativando as automações" em toda VPS recém-criada. As
# duas funções rodam aqui sob o MESMO `set -euo pipefail` do install.sh, com o
# dublê de crontab apontado para um arquivo que não existe.
cron_vazio() (
# Subshell: `montar_vps` define VPS_* globais, e os blocos seguintes da suíte
# não podem herdar esta fixture.
montar_vps "$SUITE_TMP/cron-vazio" projeto < <(printf '#!/bin/sh\nexit 0\n')
local sandbox="$SUITE_TMP/crontab-vazio.txt"; rm -f "$sandbox"
local out rc
out="$(cd "$VPS_PROJ" && env PATH="$VPS_RAIZ/bin:$PATH" CRONTAB_SANDBOX="$sandbox" \
INTERNAL_SECRET=segredo-de-teste NEXT_PUBLIC_APP_URL=https://crm.exemplo.com.br PROJECT_DIR="$VPS_PROJ" \
bash -c 'set -euo pipefail; . "$1/_common.sh"; psql_run() { :; }
setup_event_log_drain_cron; setup_update_agent_cron; echo CHEGOU-AO-FIM' _ "$VPS_RAIZ" 2>&1)" \
&& rc=0 || rc=$?
if [ $rc -ne 0 ] || ! printf '%s' "$out" | grep -q CHEGOU-AO-FIM; then
printf ' ✗ agendar o cron numa VPS sem crontab derrubou o script (saída %s)\n' "$rc"; return 1
fi
if [ "$(grep -c '# deskcomm:' "$sandbox" 2>/dev/null)" != 2 ]; then
printf ' ✗ esperava 2 linhas (drain + agente) no crontab, veio %s\n' "$(grep -c '# deskcomm:' "$sandbox" 2>/dev/null || echo 0)"; return 1
fi
printf ' ✓ sem crontab prévio, drain e agente agendados e o script segue\n'
)
cron_vazio || fail=1
echo "isolamento: a suíte não escreve no crontab da máquina"
# Isto não é hipótese defensiva: os testes JÁ escreveram 10 linhas órfãs no
# crontab do mantenedor, uma delas um `curl` com Bearer disparando a cada minuto
+7 -4
View File
@@ -285,13 +285,16 @@ dc up -d
# com o Traefik nas portas 80/443. O resultado era um "⚠ não consegui recriar o
# proxy" em TODA atualização de quem usa proxy externo: alarme falso, num
# momento em que o dono precisa confiar no que está lendo.
if [ "${REVERSE_PROXY:-caddy}" = "traefik" ]; then
c_grn "✓ proxy externo (Traefik): o Caddy não é usado aqui — nada a recarregar"
else
case "${REVERSE_PROXY:-caddy}" in
traefik|npm)
c_grn "✓ proxy externo (${REVERSE_PROXY}): o Caddy não é usado aqui — nada a recarregar"
;;
*)
dc up -d --force-recreate --no-deps caddy >/dev/null 2>&1 \
&& c_grn "✓ proxy recarregado com a configuração desta versão" \
|| c_ylw "⚠ não consegui recriar o proxy — rode: docker compose $(dc_files) up -d --force-recreate caddy"
fi
;;
esac
# ── 6. O app voltou no ar? ───────────────────────────────────────────────────
step "Conferindo se o app voltou no ar"
@@ -0,0 +1,122 @@
/**
* As duas regras que a tela de sugestão de resposta erra quando ficam soltas.
*
* Vivem aqui, fora da rota e fora do componente, porque as duas são decisões
* puras — entra um valor, sai um valor — e porque as duas já falharam em
* produção sem que nenhum teste pudesse alcançá-las de onde estavam.
*/
/** Os estados que uma sugestão pode ter, iguais aos de `ai_reply_drafts`. */
export type StatusDaSugestao =
| "generating"
| "pending"
| "approved"
| "sending"
| "sent"
| "dismissed"
| "stale"
| "failed";
/**
* Uma sugestão nestes estados não tem mais nada a oferecer a quem atende.
*
* ## O defeito, medido em produção em 2026-09-12
*
* O painel pedia as cinco últimas sugestões da conversa (`order by created_at
* desc limit 5`), mostrava a primeira e **não olhava o estado dela**. Rejeitar
* não fecha nada: a sugestão rejeitada continua sendo a mais recente, então o
* painel seguia exibindo o texto morto — com a caixa de edição desabilitada,
* sem botão de aprovar, sem botão de fechar (não existe nenhum no componente).
*
* E o caso que travou de vez: quem rejeita costuma pedir outra em seguida. Se
* essa geração falha, nada substitui a rejeitada, e a tela fica presa naquele
* texto indefinidamente. Foi exatamente o que aconteceu.
*
* `sent` entra na lista porque o texto enviado já aparece na conversa, logo
* acima — repeti-lo numa caixa desabilitada é dizer duas vezes a mesma coisa.
*
* `failed` NÃO entra, de propósito: ela ainda diz o que conferir, e essa frase
* é a única pista que sobra para quem não sabe por que a sugestão não veio.
*/
const SEM_NADA_A_OFERECER: ReadonlySet<string> = new Set<StatusDaSugestao>([
"dismissed",
"stale",
"sent",
]);
/**
* Qual sugestão o painel mostra — ou nenhuma, e aí ele volta ao estado neutro
* (o título genérico e o botão "Sugerir resposta").
*
* Olha **só a mais recente**, de propósito. A alternativa — procurar na lista a
* primeira que ainda sirva — ressuscitaria uma sugestão antiga logo depois de a
* atual ser rejeitada, e quem atende veria um texto que já tinha sumido voltar
* sozinho à tela. Entre mostrar demais e mostrar de menos, aqui o erro barato é
* mostrar de menos: o botão de gerar outra está sempre ali.
*/
export function sugestaoParaMostrar<T extends { status: string }>(
sugestoes: readonly T[] | undefined,
): T | undefined {
const maisRecente = sugestoes?.[0];
if (!maisRecente) return undefined;
return SEM_NADA_A_OFERECER.has(maisRecente.status) ? undefined : maisRecente;
}
/** O que a tela diz quando a geração falha, e com que código. */
export interface MotivoDaFalha {
readonly codigo: string;
/** Em português; quem chama passa pelo `t()` para o idioma de quem lê. */
readonly texto: string;
/**
* `true` quando a causa é conhecida e acionável — quem lê sabe o que fazer.
* `false` quando caímos no genérico, e aí só o registro no servidor ajuda.
*/
readonly acionavel: boolean;
}
/**
* Traduz a exceção de `generateReplyDraft` na frase que quem atende lê.
*
* ## O defeito que isto conserta
*
* A rota fazia `} catch {` — **sem nome**. A causa era descartada ali mesmo, e
* três situações sem nada em comum viravam a mesma frase: "Não foi possível
* gerar a sugestão. Confira a publicação e a configuração do agente."
*
* Pior: a tela mostra o identificador da requisição junto, o que faz a mensagem
* parecer rastreável. Não era — nada tinha sido registrado, em lugar nenhum,
* então o identificador não levava a nada. Um número que promete e não entrega
* é pior que nenhum número: manda a pessoa procurar onde não há o que achar.
*
* Medido: o dono da instalação levou o erro para o suporte com o identificador
* em mãos, e não havia como descobrir a causa nem lendo o código — só sabotando
* cada caminho para ver qual produzia aquela frase.
*
* As duas causas nomeadas são as que `generateReplyDraft` lança de propósito;
* o resto (falha do provedor de IA, tempo esgotado, rede, erro de SQL) cai no
* genérico — e aí a frase deixa de fingir que sabe, e diz que o motivo ficou
* registrado.
*/
export function motivoDaFalha(erro: unknown): MotivoDaFalha {
const marca = erro instanceof Error ? erro.message : String(erro ?? "");
if (marca === "reply_no_agent")
return {
codigo: "reply_no_agent",
texto:
"Nenhum agente publicado atende este canal. Publique uma versão do agente em IA › Agentes.",
acionavel: true,
};
if (marca === "reply_context_unavailable")
return {
codigo: "reply_context_unavailable",
texto:
"Não dá para sugerir nesta conversa: o contato pediu para não receber mensagens, foi anonimizado, ou o histórico não pôde ser lido.",
acionavel: true,
};
return {
codigo: "reply_unavailable",
texto:
"Não foi possível gerar a sugestão. O motivo ficou registrado no servidor com o identificador abaixo.",
acionavel: false,
};
}
+4
View File
@@ -432,6 +432,10 @@ export const AUDIT_ACTIONS = [
"agenda.tipo_criado",
"agenda.tipo_alterado",
"agenda.tipo_desativado",
// Ligar de volta um tipo que alguém desligou é ato de gestão e tem verbo
// próprio: como `agenda.tipo_alterado { campos: ["is_active"] }` ele seria,
// na trilha, indistinguível de "mudaram a duração".
"agenda.tipo_reativado",
// A rodada que AVISOU alguém do próprio compromisso. Mensagem que saiu para o
// telefone de um cliente é efeito, e efeito audita — mas só a rodada que
// enviou: a que varreu e não achou ninguém a avisar não é mutação.
+32 -26
View File
@@ -38,11 +38,10 @@ function toE164Digits(raw: string): string {
/**
* Credencial do ambiente — o caminho de instalação de número único.
*
* `isConfigured()` continua olhando só o env de propósito: ele responde "dá para
* tentar?" de forma SÍNCRONA, e a resposta certa para uma instalação que gravou a
* credencial na sessão vem do banco. Quem sabe disso é o `send`, que é async.
* Devolver `false` aqui com sessão configurada faria o handler gravar `queued` sem
* motivo — por isso o `send` resolve de novo, com a sessão, antes de desistir.
* O env continua sendo LIDO (`resolveMetaCreds`, que o `send` chama como
* fallback depois da sessão), mas ele já não é quem decide se o canal está
* configurado: essa pergunta não tem resposta síncrona honesta — ver
* `isConfigured`.
*/
import { metaCredsFromEnv } from "../meta/credentials";
export { metaCredsFromEnv as getMetaCreds };
@@ -97,28 +96,28 @@ export const metaCloudAdapter: ChannelAdapter = {
},
/**
* DÍVIDA CONHECIDA, deixada de propósito — não é descuido.
* SEMPRE `true`, e isso não é preguiça: para este canal a pergunta não tem
* resposta síncrona honesta.
*
* A credencial deste canal também pode viver na SESSÃO (a tela de "Conectar
* canal oficial" grava `meta_token_encrypted` desde a 0118), e `isConfigured`
* é síncrono: não consulta o banco. Numa instalação que conectou pela tela e
* não escreveu `.env`, isto devolve `false`, e o handler (`_handler.ts:370`)
* grava `queued` com `queued_reason: meta_not_configured` sem nunca chamar
* `send` — mensagem parada no inbox, sem erro, com o canal conectado.
* A credencial vive na SESSÃO (a tela de "Conectar canal oficial" grava
* `meta_token_encrypted` desde a 0118), e `isConfigured` é síncrono — não
* consulta o banco. Olhar só o env respondia "não configurado" para toda
* instalação que conectou pela tela: o handler gravava `queued` com
* `queued_reason: meta_not_configured` sem NUNCA chamar `send`, e a mensagem
* ficava parada no inbox, sem erro, com o canal conectado e funcionando
* (issue #674). O canal intermediado pagou o mesmo defeito antes e resolveu
* assim — ver `adapters/zernio.ts`, que adotou este contrato primeiro.
*
* O canal intermediado JÁ passou por isso e resolveu devolvendo `true` e
* fazendo o `send` lançar (ver `adapters/zernio.ts`). O mesmo conserto cabe
* aqui, mas ele muda um contrato com dois testes explícitos
* (`tests/unit/channel-adapter-meta.test.ts`) cuja justificativa escrita é
* "mesmo contrato do outro canal" — justificativa que o fork já não sustenta.
*
* Trocar contrato testado exige uma mudança própria, com os testes revistos de
* propósito e não de passagem. Fica registrado aqui para quem for fazê-la.
* O custo de responder `true` é que `send` precisa ser quem desiste — e ele
* LANÇA `meta_not_configured` em vez de devolver `{externalId: null}`, para o
* handler gravar `queued` com o motivo em vez de um `sent` sem id, que diria
* "enviado" para algo que nunca saiu. O fallback de ambiente para instalações
* legadas segue vivo DENTRO do `send` (`resolveMetaCreds`): sessão primeiro,
* env depois.
*/
isConfigured(): boolean {
// Síncrono por contrato. Com credencial na sessão, quem confirma é o `send`
// (async) — ver o comentário acima.
return metaCredsFromEnv() !== null;
// Quem decide é `send()`, que pode consultar o banco. Ver o comentário.
return true;
},
/**
@@ -270,9 +269,16 @@ export const metaCloudAdapter: ChannelAdapter = {
organizationId: envelope.organizationId,
phoneNumberId: envelope.sessionRef,
});
// Mesmo contrato do outro canal: sem credencial é NOOP, não exceção. A UI mostra
// o banner de "canal não conectado"; transformar em erro mudaria comportamento.
if (!creds) return { externalId: null };
// LANÇA, não devolve null: com `isConfigured` sempre true, quem desiste é
// este ponto — e `{externalId: null}` faria o handler gravar `sent` sem id,
// dizendo "enviado" para algo que nunca saiu. O handler traduz o prefixo
// `meta_not_configured` para `queued` com o motivo: credencial ausente é
// canal ainda não conectado, não falha desta mensagem.
if (!creds) {
throw new Error(
"meta_not_configured: nenhuma credencial para esta sessão (nem na sessão, nem no ambiente).",
);
}
const corpo =
contactPayload(envelope) ??
@@ -43,6 +43,24 @@ export async function sendTemplateForSession(
throw new Error("template_incompleto: nome e idioma são obrigatórios em type=template");
}
// A credencial de AMBIENTE é o único caminho deste envio, e a guarda vem
// ANTES da consulta ao espelho de propósito: "canal não conectado" é desfecho
// da classe `queued` (recuperável), e a ordem dos desfechos é comportamento
// neste repo. Sem ela, uma instalação que conectou o número pela TELA
// (credencial cifrada no banco, `.env` sem chave) tentaria a Graph com
// `Bearer` vazio e viraria `failed` com um erro que não nomeia o motivo real
// — a mudança de elegibilidade da #674 transformaria uma fila recuperável em
// falha. Com ela, o desfecho é o mesmo de antes do #674: `queued` com
// `meta_not_configured`.
//
// Enviar template com a credencial da SESSÃO é um passo próprio (o adapter
// ainda não implementa `sendTemplate`); até lá, este caminho é só do env.
if (!process.env.META_PHONE_NUMBER_ID || !process.env.META_SYSTEM_USER_TOKEN) {
throw new Error(
"meta_not_configured: sem credencial de ambiente para enviar template (a conexão feita pela tela ainda não é usada por este caminho).",
);
}
const { data: linha, error } = await db
.from("meta_templates")
.select("name, language, status, contract_hash, components")
+41
View File
@@ -82,6 +82,17 @@ export const DICIONARIO: Traducoes = {
"Enviando resposta aprovada…": { es: "Enviando respuesta aprobada…" },
"Resposta aprovada enviada": { es: "Respuesta aprobada enviada" },
"Sugestão rejeitada": { es: "Sugerencia rechazada" },
"Nenhum agente publicado atende este canal. Publique uma versão do agente em IA › Agentes.": {
es: "Ningún agente publicado atiende este canal. Publica una versión del agente en IA › Agentes.",
},
"Não dá para sugerir nesta conversa: o contato pediu para não receber mensagens, foi anonimizado, ou o histórico não pôde ser lido.":
{
es: "No se puede sugerir en esta conversación: el contacto pidió no recibir mensajes, fue anonimizado, o no se pudo leer el historial.",
},
"Não foi possível gerar a sugestão. O motivo ficou registrado no servidor com o identificador abaixo.":
{
es: "No se pudo generar la sugerencia. El motivo quedó registrado en el servidor con el identificador de abajo.",
},
"Sugestão obsoleta: a conversa mudou": { es: "Sugerencia obsoleta: la conversación cambió" },
"Não foi possível concluir a sugestão ou o envio": {
es: "No se pudo completar la sugerencia o el envío",
@@ -3853,6 +3864,35 @@ export const DICIONARIO: Traducoes = {
es: "No pude iniciar la actualización. Intenta de nuevo en instantes.",
},
"Atualizando para a versão": { es: "Actualizando a la versión" },
// ── A espera antes de o servidor pegar o pedido, e o fim reconhecido na hora.
// Os dois estados que a tela ganhou quando parou de fingir que a conversa
// com o host é instantânea (ver o cabeçalho do `UpdatePanel`).
"Pedido enviado — esperando o servidor pegar": {
es: "Pedido enviado — esperando que el servidor lo tome",
},
"Anotei o pedido de atualizar para a versão": {
es: "Anoté el pedido de actualizar a la versión",
},
"O servidor confere se há algo a fazer de poucos em poucos minutos, então a atualização pode levar até cerca de cinco minutos para começar.": {
es: "El servidor revisa si hay algo que hacer cada pocos minutos, así que la actualización puede tardar hasta unos cinco minutos en empezar.",
},
"Esta tela ficar parada nesse tempo é normal": {
es: "Que esta pantalla quede quieta durante ese tiempo es normal",
},
" — ela se mexe sozinha assim que o servidor começar.": {
es: " — se mueve sola en cuanto el servidor empiece.",
},
"Esperando há": { es: "Esperando hace" },
"Pode fechar esta página: o pedido já está registrado e não se perde.": {
es: "Puedes cerrar esta página: el pedido ya está registrado y no se pierde.",
},
"Pronto — você está na versão": { es: "Listo — estás en la versión" },
"A atualização terminou e o sistema já está no ar na versão": {
es: "La actualización terminó y el sistema ya está en línea en la versión",
},
"O servidor confirma isso na próxima vez que falar comigo, daqui a alguns minutos — até lá, esta tela já sabe.": {
es: "El servidor lo confirma la próxima vez que hable conmigo, en unos minutos — hasta entonces, esta pantalla ya lo sabe.",
},
"O sistema sai do ar por alguns instantes e volta sozinho. Pode deixar esta página aberta.": {
es: "El sistema se apaga por unos instantes y vuelve solo. Puedes dejar esta página abierta.",
},
@@ -6017,6 +6057,7 @@ export const DICIONARIO: Traducoes = {
// de compilação, e é assim que o dicionário evita duas traduções da mesma
// frase divergirem.)
"Devolver acesso": { es: "Devolver el acceso" },
"Acesso devolvido.": { es: "Acceso devuelto." },
// ("Seu nome" já existe mais acima — a chave é o próprio texto.)
"Informe seu nome": { es: "Escribe tu nombre" },
"Você já tem uma conta com este e-mail": {
+32 -1
View File
@@ -38,6 +38,35 @@ export function isCommunityDsn(dsn: string | undefined): boolean {
/** Integração default do SDK que emite as sessões de release health do browser. */
export const INTEGRACAO_DE_SESSAO = "BrowserSession";
/**
* Integração default do SDK que instrumenta performance (pageload/navegação) e,
* como parte disso, registra `PerformanceObserver`s para Web Vitals (CLS/LCP/TTFB
* etc. — `browserTracingIntegration` → `@sentry/react` → uma cópia interna do
* `web-vitals`). Mesma classe de custo que `INTEGRACAO_DE_SESSAO`: no DSN da
* comunidade `tracesSampleRate` já é 0 (declarado logo abaixo), então nenhum
* trace desses observers É ENVIADO — mas os observers continuam INSTALADOS e
* RODANDO mesmo assim, porque a decisão de amostragem do SDK acontece depois da
* coleta, não antes de instalar o listener.
*
* Achado em produção (self-host, 2026-09-09): `TypeError: Cannot read
* properties of undefined (reading 'startTime')` no console do navegador, saindo
* de dentro do coletor de CLS/LCP desta integração — a lista de entries de um
* `PerformanceObserver` trouxe um item `undefined`, quase certamente por uma
* extensão do navegador que intercepta/corrompe a Performance API da página (o
* mesmo usuário via outros dois erros de console vindos de uma extensão sua,
* na mesma tela). O bug em si é upstream (`web-vitals`/Sentry SDK, não dá pra
* corrigir daqui) — mas rodar esse coletor sem NUNCA poder enviar nada é o
* exato "custo invisível" que este arquivo já rejeita para sessão. Tirar a
* integração pra quem está na comunidade elimina o crash pra essa população
* inteira, de graça, sem perder telemetria nenhuma (não havia o que perder).
*
* Quem aponta pro PRÓPRIO Sentry (`tracesSampleRate: 1`) mantém a integração —
* ali o trace tem para onde ir, e o crash upstream (se acontecer, sob a mesma
* combinação de extensão de navegador) é risco que a pessoa já assumiu ao
* habilitar tracing de verdade.
*/
export const INTEGRACAO_DE_TRACING = "BrowserTracing";
/**
* Quais integrações do browser valem para o DSN em uso.
*
@@ -67,5 +96,7 @@ export function integracoesDoCliente<T extends { name: string }>(
paraAComunidade: boolean,
): T[] {
if (!paraAComunidade) return [...padraoDoSdk];
return padraoDoSdk.filter((i) => i.name !== INTEGRACAO_DE_SESSAO);
return padraoDoSdk.filter(
(i) => i.name !== INTEGRACAO_DE_SESSAO && i.name !== INTEGRACAO_DE_TRACING,
);
}
+70 -1
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { canTransition, isRunStale, rollbackFoiSuperado, RUN_STALE_AFTER_MS } from "./update-run";
import {
canTransition,
isRunStale,
rollbackFoiSuperado,
sucessoJaInstalado,
RUN_STALE_AFTER_MS,
} from "./update-run";
describe("canTransition", () => {
it("aceita o desfecho reportado pelo agente", () => {
@@ -90,3 +96,66 @@ describe("rollbackFoiSuperado", () => {
expect(rollbackFoiSuperado("2026-09-05T15:35:02.000Z", "isso não é data", "1.2.0", RUN)).toBe(false);
});
});
/**
* A JANELA EM QUE A ATUALIZAÇÃO DEU CERTO E A TELA AINDA NÃO SABIA.
*
* `run_result` com `success` fecha o run e não escreve em
* `system_version.current_version` — quem escreve é o heartbeat do host, de 5 em
* 5 minutos. Nessa janela `update_available` (`latest !== current`) continuava
* verdadeiro, e a tela voltava do reinício oferecendo "Atualizar agora" para a
* versão que acabou de ser instalada.
*
* O desempate é o mesmo de `rollbackFoiSuperado`, na direção contrária: lá o
* host mais novo vence o run; aqui o run vence enquanto o host não falou.
*/
describe("sucessoJaInstalado", () => {
const FIM = "2026-09-11T14:00:00.000Z";
const RUN = { status: "success", to_version: "1.1.0" };
it("run bem-sucedido e host ainda calado: o run manda", () => {
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", FIM, RUN)).toBe(true);
});
it("host bateu DEPOIS do fim: quem manda volta a ser o host", () => {
// É o fim de validade desta função — e ele chega sozinho, em minutos.
expect(sucessoJaInstalado("2026-09-11T14:05:00.000Z", FIM, RUN)).toBe(false);
});
it("empate de segundo conta como host calado — o degrau que não reoferece", () => {
// As duas escritas vêm de relógios diferentes. Errar aqui para o lado de
// "já atualizou" custa alguns minutos de rótulo otimista; errar para o
// outro lado devolve o botão que manda instalar de novo o que já está lá.
expect(sucessoJaInstalado(FIM, FIM, RUN)).toBe(true);
});
it("host nunca reportou nada: o run é a única notícia que existe", () => {
expect(sucessoJaInstalado(null, FIM, RUN)).toBe(true);
expect(sucessoJaInstalado(undefined, FIM, RUN)).toBe(true);
expect(sucessoJaInstalado("isso não é data", FIM, RUN)).toBe(true);
});
it("só vale para SUCESSO — falha e rollback têm dono próprio nesta tela", () => {
for (const status of ["dispatched", "failed", "failed_rolled_back"]) {
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", FIM, { status, to_version: "1.1.0" })).toBe(
false,
);
}
});
it("sem `to_version` não há o que afirmar", () => {
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", FIM, { status: "success" })).toBe(false);
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", FIM, { status: "success", to_version: "" })).toBe(
false,
);
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", FIM, null)).toBe(false);
});
it("sem `finished_at` (run de agente antigo) não afirma nada", () => {
// Sem a data não dá para saber se o host já falou depois — e o degrau
// conservador é o comportamento de antes desta função existir.
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", null, RUN)).toBe(false);
expect(sucessoJaInstalado("2026-09-11T13:55:00.000Z", "isso não é data", RUN)).toBe(false);
});
});
+52
View File
@@ -92,3 +92,55 @@ export function rollbackFoiSuperado(
const descritasPeloRun = [run?.to_version, run?.from_version].filter(Boolean);
return !descritasPeloRun.includes(versaoReportadaPeloHost);
}
/**
* O run terminou BEM e o host ainda não teve chance de contar?
*
* ## O silêncio de até 5 minutos depois de dar certo
*
* `run_result` com `status: "success"` escreve só em `system_update_runs` — ele
* **não toca** `system_version.current_version`. Quem escreve essa coluna é o
* heartbeat do `agent.sh`, e ele roda de 5 em 5 minutos — o cabeçalho do
* próprio `agent.sh` diz "a cada 5 minutos", e a linha de cron que o instalador
* escreve está em `hostgator-setup-kit/test-validators.sh`.
*
* Então, na janela entre o fim da atualização e a batida seguinte,
* `current_version` ainda nomeia a versão ANTIGA — e `update_available`
* (`latest !== current`) continua verdadeiro. A tela volta do reinício
* oferecendo o botão "Atualizar agora" para a versão que **acabou de ser
* instalada**. Quem clicou faz tudo de novo, ou conclui que não funcionou.
*
* ## Por que assumir o `to_version` é seguro aqui
*
* `success` é o agente do host dizendo que o `update.sh` foi até o fim — a
* troca de imagem incluída. Diferente do caso de rollback (onde o host reporta
* a versão que QUEBROU e o run precisa contradizê-lo), aqui os dois concordam;
* o host só ainda não falou.
*
* E é auto-corrigível por construção: assim que a batida chega,
* `system_version.updated_at` passa a ser posterior ao `finished_at` e esta
* função devolve `false` — o host volta a mandar, sem exceção nenhuma. É o
* mesmo desempate temporal de `rollbackFoiSuperado`, na direção contrária.
*
* Sem `finished_at` (run antigo, agente velho) devolve `false`: sem a data não
* há como saber se o host já falou depois, e o degrau conservador é o de antes.
* Sem `versionUpdatedAt` devolve `true` — o host nunca reportou coisa alguma, e
* o run é a única notícia que existe.
*/
export function sucessoJaInstalado(
versionUpdatedAt: string | null | undefined,
runFinishedAt: string | null | undefined,
run?: { status?: string | null; to_version?: string | null } | null,
): boolean {
if (run?.status !== "success" || !run.to_version) return false;
if (!runFinishedAt) return false;
const terminou = Date.parse(runFinishedAt);
if (Number.isNaN(terminou)) return false;
if (!versionUpdatedAt) return true;
const gravado = Date.parse(versionUpdatedAt);
if (Number.isNaN(gravado)) return true;
// Empate conta como "o host ainda não falou": a escrita do `run_result` e a
// do heartbeat são de relógios diferentes, e na janela de um segundo o degrau
// seguro é o que NÃO volta a oferecer a versão já instalada.
return gravado <= terminou;
}
+19 -19
View File
@@ -39,7 +39,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^4.0.16",
"@ai-sdk/google": "^4.0.18",
"@ai-sdk/openai": "^4.0.56",
"@ai-sdk/openai": "^4.0.64",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@hello-pangea/dnd": "^18.0.1",
@@ -61,64 +61,64 @@
"@radix-ui/react-tooltip": "^1.2.16",
"@react-pdf/renderer": "^4.9.0",
"@sentry/nextjs": "^10",
"@supabase/ssr": "^0.12.5",
"@supabase/supabase-js": "^2.114.0",
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.116.0",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-query-devtools": "^5.102.8",
"@tanstack/react-virtual": "^3.14.10",
"@upstash/redis": "^1.38.3",
"@tanstack/react-virtual": "^3.14.11",
"@upstash/redis": "^1.38.4",
"@xyflow/react": "^12.11.6",
"ai": "^7.0.90",
"ai": "^7.0.96",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"fflate": "^0.8.3",
"gpt-tokenizer": "^4.0.0",
"import-in-the-middle": "^3.4.0",
"lucide-react": "^1.39.0",
"import-in-the-middle": "^3.5.0",
"lucide-react": "^1.43.0",
"next": "^16.3.4",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.3.289",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hook-form": "^7.87.0",
"react-hotkeys-hook": "^5.3.3",
"recharts": "^3.10.1",
"require-in-the-middle": "^8.0.1",
"resend": "^6.25.0",
"resend": "^6.26.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"web-push": "^3.6.7",
"zod": "^4.5.4"
"zod": "^4.6.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@playwright/test": "^1.62.1",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/jest": "^29.5.13",
"@types/node": "^22.19.0",
"@types/node": "^22.20.2",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vercel/config": "^0.7.0",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^9.12.0",
"eslint-config-next": "^16.2.10",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
"postcss": "^8.5.28",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.13",
"typescript": "^6.0.3",
"typescript-eslint": "^8.69.0",
"typescript-eslint": "^8.70.0",
"vite": "^8.2.2",
"vitest": "^4.1.11"
},
@@ -126,7 +126,7 @@
"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": {
"postcss": "^8.5.26",
"postcss": "^8.5.28",
"sharp": "^0.35.0",
"hono": "^4.12.34",
"js-yaml": "^4.3.1",
+955 -943
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -12,11 +12,12 @@
* 200 s, US$ 3,08 para "por onde eu começo a instalar?") e o "depois". Um
* teste automatizado não prova acionamento; só uma sessão real prova.
*
* node scripts/skills-embutidas/provar.mjs --cli claude --dir /caminho/do/clone \
* node scripts/skills-embutidas/provar.mjs --cli claude|codex|opencode|agy --dir /caminho/do/clone \
* --prompt "Comprei uma VPS e quero instalar o CRM. Por onde começo?" \
* --out /tmp/prova.jsonl [--max-turns 12] [--env CODEX_HOME=/tmp/codex-limpo] [--env GH_TOKEN=x]
*
* Cursor e Antigravity não têm modo sem interface: a prova neles é à mão.
* Antigravity roda por `agy --print` (o CLI que o app instala em ~/.local/bin). O Cursor
* tem o `agent` (curl https://cursor.com/install | bash), que exige `agent login`.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
@@ -39,6 +40,10 @@ const cmd = {
claude: ["claude", ["-p", opt.prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opt["max-turns"] || 12), "--permission-mode", "plan"]],
codex: ["codex", ["exec", "--json", "--ephemeral", "-s", "read-only", opt.prompt]],
opencode: ["opencode", ["run", "--format", "json", "--dir", opt.dir, opt.prompt]],
// --dangerously-skip-permissions: em modo sem interface ninguém clica "permitir", e o
// primeiro run_command (que uma doutrina global do usuário pode pedir) encerra a corrida
// sem resposta. Auto-aprovar é o substituto fiel do clique; o clone de prova é descartável.
agy: ["agy", ["--print", opt.prompt, "--output-format", "stream-json", "--mode", "plan", "--print-timeout", "10m", "--dangerously-skip-permissions"]],
}[opt.cli];
if (!cmd) { console.error("cli desconhecido"); process.exit(2); }
@@ -50,7 +55,7 @@ if (r.stderr) fs.writeFileSync(opt.out + ".err", r.stderr);
const linhas = (r.stdout || "").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
const tools = []; let final = ""; let turns = 0; let custo = null; let uso = null;
const ehSkill = (s) => /SKILL\.md|\.claude\/skills|\.agents\/skills|\.codex\/skills|\.opencode\/skill|\.cursor\/(rules|skills)|\.agent\/(skills|rules|workflows)/i.test(s || "");
const ehSkill = (s) => /SKILL\.md|\.claude\/skills|\.agents\/skills|\.codex\/skills|\.opencode\/skill|\.cursor\/(rules|skills)|\.agent\/(skills|rules|workflows)|deskcomm-[a-z-]+/i.test(s || "");
if (opt.cli === "claude") {
for (const m of linhas) {
@@ -72,6 +77,14 @@ if (opt.cli === "claude") {
}
if (m.type === "turn.completed") { turns++; uso = m.usage; }
}
} else if (opt.cli === "agy") {
for (const m of linhas) {
const su = m.step_update;
if (m.event === "step_update" && su && su.step_type === "tool" && su.state === "ACTIVE") {
tools.push({ nome: String(su.tool_name || "tool"), alvo: JSON.stringify(su.tool_info?.parameters || {}).slice(0, 160) });
}
if (m.event === "result" && m.result) { final = String(m.result.response || ""); turns = m.result.num_turns; uso = m.result.usage; }
}
} else if (opt.cli === "opencode") {
for (const m of linhas) {
const p = m.part || {};
+33 -7
View File
@@ -173,16 +173,42 @@ test.describe("a Agenda como o dono do produto a usa", () => {
).toHaveAttribute("aria-pressed", "true");
}
// A régua do agora só existe quando o instante cabe na faixa desenhada
// (07h–21h). Fora dela a ausência é CORRETA, e exigir presença faria a spec
// ficar vermelha de madrugada — que é o defeito que este repo já pagou nos
// A régua do agora só existe quando o instante cabe na faixa desenhada.
// Fora dela a ausência é CORRETA, e exigir presença faria a spec ficar
// vermelha de madrugada — que é o defeito que este repo já pagou nos
// invariantes de turno.
const hora = new Date().getHours();
//
// ⚠️ A FAIXA É CALCULADA COMO O COMPONENTE CALCULA, e não "7 a 21".
//
// `GradeDaAgenda.tsx` desenha quando
// `0 <= (hora - 7) * 60 + minuto <= (21 - 7 + 1) * 60`, ou seja até as
// **22:00:59** — o limite superior é `<= 900`, não `< 900`. A versão
// anterior desta spec comparava `hora >= 7 && hora <= 21`, e as duas réguas
// discordavam numa janela de UM MINUTO por dia: às 22:00 o componente ainda
// desenha e a spec exigia ausência.
//
// Um minuto em 1440 parece desprezível até acontecer. Aconteceu em
// 11/09/2026, em DOIS PRs de contribuidores diferentes, cujos `e2e`
// começaram às `22:00:12` e `22:00:24` — os dois ficaram vermelhos por um
// defeito que não era deles, num arquivo que eles não tocaram.
//
// A régua aqui passou a ser a MESMA conta do componente. Duplicar a regra
// continua sendo duplicação; o que muda é que agora ela duplica o que o
// componente faz, em vez de uma aproximação dele.
const agora = new Date();
const minutosDesdeOTopo = (agora.getHours() - 7) * 60 + agora.getMinutes();
const dentroDaFaixa = minutosDesdeOTopo >= 0 && minutosDesdeOTopo <= (21 - 7 + 1) * 60;
const regua = page.getByTestId("regua-do-agora");
if (hora >= 7 && hora <= 21) {
await expect(regua, "dentro da faixa 07h–21h e sem régua do agora").toBeVisible();
if (dentroDaFaixa) {
await expect(
regua,
`dentro da faixa desenhada (${agora.getHours()}h${String(agora.getMinutes()).padStart(2, "0")}) e sem régua do agora`,
).toBeVisible();
} else {
await expect(regua, "fora da faixa e a régua apareceu mesmo assim").toHaveCount(0);
await expect(
regua,
`fora da faixa (${agora.getHours()}h${String(agora.getMinutes()).padStart(2, "0")}) e a régua apareceu mesmo assim`,
).toHaveCount(0);
}
});
+42 -1
View File
@@ -238,7 +238,21 @@ test("o tipo NASCE com responsável — e quem escolhe 'Definir depois' recebe a
await page.screenshot({ path: "evidence/calendario/d6-tipo-com-responsavel.png", fullPage: true });
});
test("desativar tira o tipo da tela de marcar, sem apagar a história", async ({ page }) => {
test("desativar tira o tipo da tela de marcar, e reativar o traz de volta", async ({ page }) => {
/**
* ⚠️ ESTE CASO JÁ EXISTIA E CONFERIA O BOTÃO "Reativar" SÓ COM `toBeVisible`.
*
* Foi exatamente assim que ele sobreviveu morto. O botão mandava
* `PATCH /api/v1/agenda/tipos` com `{ id, is_active: true }`; o
* `alterarSchema` daquela rota é `criarSchema.partial()`, onde `is_active` não
* existe, e Zod descarta chave desconhecida em silêncio — o corpo chegava
* vazio e a resposta era 422 "Nenhum campo para alterar.". Nunca funcionou uma
* vez, desde que a tela nasceu.
*
* Ver que o controle está DESENHADO não é ver que ele ABRE. O caso agora
* clica, e cobra o efeito nos dois lugares: o rótulo "desativado" sai da lista
* e o tipo volta a ser oferecido em /app/agenda.
*/
const creds = lerCreds();
await entrar(page, creds);
await page.goto("/app/settings/tenant/agenda");
@@ -274,6 +288,33 @@ test("desativar tira o tipo da tela de marcar, sem apagar a história", async ({
page.getByRole("button", { name: new RegExp(`^${nome}`) }),
"tipo desativado continua oferecido para marcar",
).toHaveCount(0);
// ─── A VOLTA ────────────────────────────────────────────────────────────
//
// Sem esta metade, desativar é uma porta que só abre para um lado: o nome
// continua ocupado pelo tipo desligado (o slug é único), então quem errou o
// clique não consegue nem recriar com o mesmo nome.
await page.goto("/app/settings/tenant/agenda");
await expect(page.getByTestId("tipos-de-agendamento-config")).toBeVisible({ timeout: 20_000 });
await linha.getByRole("button", { name: "Reativar" }).click();
await expect(
linha.getByText("desativado"),
"cliquei em Reativar e o tipo continua marcado como desativado",
).toHaveCount(0, { timeout: 20_000 });
await expect(
linha.getByRole("button", { name: "Desativar" }),
"o tipo voltou mas a lista não oferece desligar de novo",
).toBeVisible();
// E volta a aparecer onde importa — o mesmo lugar de onde saiu.
await page.goto("/app/agenda");
await page.getByRole("button", { name: /novo agendamento/i }).click();
await expect(page.getByTestId("tipos-de-agendamento")).toBeVisible({ timeout: 15_000 });
await expect(
page.getByRole("button", { name: new RegExp(`^${nome}`) }),
"reativei o tipo e ele não voltou para a tela de marcar",
).toHaveCount(1);
});
test("ligo o aviso do compromisso pela tela, e ele fica ligado", async ({ page }) => {
+25 -4
View File
@@ -321,7 +321,22 @@ test("testa sem enviar, pausa preserva publicação e duas aprovações entregam
chatId: "15551234567@c.us",
text: "Maria, confirmei as informações e posso ajudar por aqui.",
});
await expect(panel(page).getByText("Resposta aprovada enviada", { exact: true })).toBeVisible();
// ⛔ NÃO espere "Resposta aprovada enviada": esse rótulo é INALCANÇÁVEL.
//
// `sugestaoParaMostrar` trata `sent` como SEM_NADA_A_OFERECER de propósito
// (`lib/agent-engine/agent/sugestao-de-resposta.ts`): o texto enviado já está
// na conversa logo acima, e repeti-lo numa caixa desabilitada diria duas
// vezes a mesma coisa. Foi decidido nesta branch, em 927f5aad — e o teste
// continuou cobrando o estado anterior, reprovando em SEIS rodadas de CI
// seguidas sempre nesta linha, com a mensagem já entregue ao receiver.
//
// Não é tempo: subir o teto para 15s não mudou nada, porque o elemento não
// existe em instante nenhum.
//
// O que a tela faz agora é o que se prova aqui: a sugestão SAI e o painel
// volta ao neutro — título genérico, e nada a aprovar.
await expect(panel(page).getByText("Assistência do agente", { exact: true })).toBeVisible();
await expect(panel(page).getByRole("button", { name: "Aprovar e enviar" })).toHaveCount(0);
await inbound(f, "Obrigada, pode continuar");
const second = await generate(page);
await panel(page)
@@ -355,9 +370,15 @@ test("testa sem enviar, pausa preserva publicação e duas aprovações entregam
const third = await generate(page);
expect(third).not.toBe(second);
await inbound(f, "O assunto mudou");
await expect(
panel(page).getByText("Sugestão obsoleta: a conversa mudou", { exact: true }),
).toBeVisible();
// ⛔ E "Sugestão obsoleta: a conversa mudou" é inalcançável pela MESMA razão
// do bloco acima: `stale` também está em SEM_NADA_A_OFERECER. Quando a
// conversa muda, a sugestão deixa de servir e SAI da tela — o painel volta
// ao neutro em vez de anunciar que ficou obsoleta.
//
// Os três rótulos de `statuses` cobertos por esse conjunto (`sent`, `stale`,
// `dismissed`) são hoje código inalcançável no componente. Está anotado; o
// teste não é o lugar de decidir se eles voltam a aparecer.
await expect(panel(page).getByText("Assistência do agente", { exact: true })).toBeVisible();
await expect(panel(page).getByRole("button", { name: "Aprovar e enviar" })).toHaveCount(0);
await expect(panel(page).getByText("Resposta aprovada. Acompanhe o envio aqui.", { exact: true })).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
+60 -16
View File
@@ -271,30 +271,68 @@ test("o dono vê a versão nova na sidebar e atualiza pela tela", async ({ page,
expect(atencao!.y).toBeLessThan(botao!.y);
await page.getByRole("button", { name: /atualizar agora/i }).click();
await expect(page.getByRole("heading", { name: /atualizando para a versão 1\.1\.0/i })).toBeVisible();
await expect(page.getByText(/Guardando uma cópia de segurança/)).toBeVisible();
await page.screenshot({ path: ".superpowers/evidence/task9-2-atualizando.png" });
// ── PONTA 1: o clique NÃO começa a atualização ──────────────────────────
//
// O `POST /update` só registra o pedido; quem executa é o `agent.sh`, que
// roda de 5 em 5 minutos no host. Este caso AFIRMAVA o defeito: esperava a
// lista de passos ("Guardando uma cópia de segurança") logo depois do clique
// — a tela dizia que o sistema estava trabalhando quando ele ainda nem tinha
// recebido a ordem, e ficava assim, imóvel, por até cinco minutos.
await expect(
page.getByRole("heading", { name: /pedido enviado/i }),
"logo após o clique a tela ainda afirma que está atualizando",
).toBeVisible();
await expect(page.getByText(/ficar parada nesse tempo é normal/i)).toBeVisible();
await expect(page.getByTestId("espera-decorrida")).toBeVisible();
await page.screenshot({ path: ".superpowers/evidence/task9-2a-pedido-enviado.png" });
// O agente do host detecta o pedido no próximo heartbeat...
const { data } = await heartbeat(request, { latest_version: "1.1.0" });
expect(data.update_requested).toBe(true);
expect(data.run_id).not.toBeNull();
// ...e aí sim a lista de passos aparece, porque aí sim há um passo.
await runProgress(request, data.run_id!, "backup");
// ⏱️ 20s, e não os 5s do padrão. ESTE é o único ponto do arquivo em que a tela
// descobre a mudança pelo POLL, sem recarga — e o poll é de 5 segundos
// (`useSystemVersion({ refetchInterval: 5_000 })`). Timeout de 5s contra ciclo
// de 5s é cara ou coroa: passou nas rodadas de CI de 16:25 e 16:54 e reprovou
// na de 17:21, sem ninguém tocar neste teste entre elas. As demais asserções
// do arquivo vêm depois de `page.reload()`, onde o dado já chega na carga.
await expect(
page.getByRole("heading", { name: /atualizando para a versão 1\.1\.0/i }),
).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(/Guardando uma cópia de segurança/)).toBeVisible({
timeout: 20_000,
});
await page.screenshot({ path: ".superpowers/evidence/task9-2b-atualizando.png" });
// ...executa (fora deste teste — é o `agent.sh`/`update.sh` reais, provados
// na task 8) e reporta o desfecho.
const runResult = await request.post("/api/v1/system/agent", {
headers: { Authorization: `Bearer ${SECRET}` },
data: { kind: "run_result", run_id: data.run_id, status: "success", log_tail: "ok" },
});
expect(runResult.status()).toBe(200);
await runResult(request, data.run_id!, "success", "ok");
// Depois do sucesso, o agente reinicia e o próximo heartbeat já anuncia a
// versão nova como a instalada — é o que a tela usa pra sair do estado
// "atualizando" e mostrar "você está em dia".
// ── PONTA 2: terminou, e o host ainda não teve chance de contar ─────────
//
// `run_result` fecha o run e NÃO escreve `current_version` — quem escreve é o
// heartbeat, até 5 minutos depois. Sem o conserto, esta recarga trazia de
// volta "Versão 1.1.0 disponível" e o botão "Atualizar agora", oferecendo a
// versão que acabou de ser instalada. Repare que NENHUM heartbeat foi enviado
// entre o `run_result` e esta linha: é exatamente a janela do defeito.
await page.reload();
await expect(
page.getByRole("heading", { name: /pronto — você está na versão 1\.1\.0/i }),
"a tela voltou oferecendo a versão que acabou de ser instalada",
).toBeVisible();
await expect(page.getByRole("button", { name: /atualizar agora/i })).toHaveCount(0);
await page.screenshot({ path: ".superpowers/evidence/task9-3a-acabou-de-atualizar.png" });
// E quando o host finalmente confirma, a janela se fecha sozinha: volta o
// texto normal de quem está em dia, sem ninguém limpar estado nenhum.
await heartbeat(request, { current_version: "1.1.0", latest_version: "1.1.0" });
await page.reload();
await expect(page.getByRole("heading", { name: /você está na versão 1\.1\.0/i })).toBeVisible();
await page.screenshot({ path: ".superpowers/evidence/task9-3-em-dia.png" });
await expect(page.getByRole("heading", { name: /^você está na versão 1\.1\.0/i })).toBeVisible();
await page.screenshot({ path: ".superpowers/evidence/task9-3b-em-dia.png" });
});
test("quando a atualização falha, a tela nomeia a versão certa, mostra o log e dá saída", async ({
@@ -309,7 +347,13 @@ test("quando a atualização falha, a tela nomeia a versão certa, mostra o log
await loginWithTotp(page, creds.users.dono!.email, creds.dono_totp!.secret);
await page.goto("/app/settings/atualizacao");
await page.getByRole("button", { name: /atualizar agora/i }).click();
await expect(page.getByRole("heading", { name: /atualizando para a versão 1\.1\.0/i })).toBeVisible();
// ⛔ NÃO é "Atualizando para a versão 1.1.0". O clique registra um PEDIDO; quem
// executa é o agente no host, que confere de poucos em poucos minutos — e a
// tela passou a dizer isso com todas as letras em vez de afirmar trabalho que
// ainda não começou. A versão de destino continua nomeada, que é o que este
// teste vigia.
await expect(page.getByRole("heading", { name: /pedido enviado/i })).toBeVisible();
await expect(page.getByText(/Anotei o pedido de atualizar para a versão/)).toContainText("1.1.0");
// ── Falha COM rollback ─────────────────────────────────────────────────────
const primeiro = await heartbeat(request, { latest_version: "1.1.0" });
@@ -360,7 +404,7 @@ test("quando a atualização falha, a tela nomeia a versão certa, mostra o log
await heartbeat(request, { current_version: "1.1.0", latest_version: "1.2.0" });
await page.reload();
await page.getByRole("button", { name: /atualizar agora/i }).click();
await expect(page.getByRole("heading", { name: /atualizando para a versão 1\.2\.0/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /pedido enviado/i })).toBeVisible();
const segundo = await heartbeat(request, { current_version: "1.1.0", latest_version: "1.2.0" });
expect(segundo.data.update_requested).toBe(true);
@@ -391,7 +435,7 @@ test("quando a atualização falha, a tela nomeia a versão certa, mostra o log
await page.getByRole("button", { name: /atualizar agora/i }).click();
// Espera a tela confirmar o pedido antes de bater o heartbeat: sem isso, o
// agente simulado corre com o POST do clique e não acha run nenhum.
await expect(page.getByRole("heading", { name: /atualizando para a versão 1\.2\.0/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /pedido enviado/i })).toBeVisible();
const terceiro = await heartbeat(request, { current_version: "1.1.0", latest_version: "1.2.0" });
await runResult(
request,
+384
View File
@@ -0,0 +1,384 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { createAdminClient } from "@/lib/supabase/admin";
/**
* REATIVAR TIPO DE AGENDAMENTO — e a travessia tela → rota que deixou o botão
* nascer morto.
*
* ─── O defeito, medido ──────────────────────────────────────────────────────
*
* O botão "Reativar" de Configurações › Agenda existe desde que a tela existe e
* **nunca funcionou uma vez**. Ele mandava `PATCH /api/v1/agenda/tipos` com
* `{ id, is_active: true }`; o `alterarSchema` daquela rota é
* `criarSchema.partial().extend({ id })`, e `is_active` não está entre os doze
* campos de `camposDoTipo`. Zod **descarta chave desconhecida em silêncio**,
* então `campos` chegava `{}` e a rota respondia 422 "Nenhum campo para
* alterar." — para um usuário que não pediu para alterar campo nenhum.
*
* O compilador teria acusado. Não acusou porque a chamada terminava em
* `as never`, o único cast do arquivo da tela, exatamente em cima da travessia.
*
* ─── Por que este arquivo tem DUAS partes ───────────────────────────────────
*
* A parte de COMPORTAMENTO prova que a rota nova faz o que diz: grava
* `is_active: true`, filtra o tenant pela sessão, audita com verbo próprio e
* recusa o que não existe. Ela não alcança o defeito original — a rota antiga
* também "funcionava", só que sobre um corpo vazio.
*
* Quem alcança o defeito é a TRAVESSIA. Ela mede as duas pontas no fonte: as
* chaves que a tela manda no corpo e os campos que o schema da rota aceita. No
* estado anterior a este commit ela reprova sozinha, nomeando `is_active`. E
* ela vale para o campo que ainda não existe: qualquer chave futura que a tela
* mande e a rota não conheça fica vermelha aqui, em vez de virar um 422 mudo na
* mão de quem clicou.
*
* Ancorado no AST e não em regex de propósito — a prosa deste repositório cita
* `is_active` e `apiClient.patch(` em comentário o tempo todo, e uma varredura
* de texto acusaria o arquivo por ele falar de si mesmo.
*
* ─── Medir ──────────────────────────────────────────────────────────────────
*
* npx vitest run tests/unit/agenda-reativar-tipo.test.ts
*
* Para ver a travessia morder, devolva `is_active: true` ao corpo do
* `apiClient.patch("/api/v1/agenda/tipos", …)` da tela. Para ver o
* comportamento morder, troque `is_active: true` por `false` na rota nova.
*/
vi.mock("@/lib/audit", () => ({
audit: vi.fn(async () => undefined),
isServiceRoleConfigured: vi.fn(() => true),
}));
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
// Este teste isola o handler; autoridade de suporte é exercitada na suíte própria.
vi.mock("@/lib/impersonate/support", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/impersonate/support")>()),
requireSupportWrite: vi.fn(async () => null),
authenticatedSessionId: vi.fn(async () => "f2200000-0000-4000-8000-000000000099"),
}));
const RAIZ = join(__dirname, "..", "..");
const ROTA_TIPOS = join(RAIZ, "app", "api", "v1", "agenda", "tipos", "route.ts");
const ROTA_REATIVAR = join(RAIZ, "app", "api", "v1", "agenda", "tipos", "reativar", "route.ts");
const TELA = join(RAIZ, "app", "app", "settings", "tenant", "agenda", "_client.tsx");
const ORG = "22222222-2222-4222-8222-222222222222";
const ANA = "11111111-1111-4111-8111-111111111111";
const TIPO = "33333333-3333-4333-8333-333333333333";
const OUTRA_ORG_TIPO = "44444444-4444-4444-8444-444444444444";
/** O que a rota gravou, em que tabela, e sob quais filtros. */
let tabelaAlvo: string | null = null;
let atualizacao: Record<string, unknown> | null = null;
let filtros: Record<string, unknown> = {};
/** A linha que o banco devolve — `null` simula tipo inexistente nesta organização. */
let linha: { id: string } | null = null;
function pedido(corpo?: unknown): NextRequest {
return new NextRequest("https://crm.exemplo/api/v1/agenda/tipos/reativar", {
method: "POST",
...(corpo === undefined ? {} : { body: JSON.stringify(corpo) }),
});
}
beforeEach(() => {
// `clearAllMocks` ANTES de configurar: os casos de recusa aserem que `audit`
// NÃO foi chamado, e sem isto eles herdam as chamadas dos casos anteriores e
// falham por vazamento de fixture, não por defeito da rota.
vi.clearAllMocks();
tabelaAlvo = null;
atualizacao = null;
filtros = {};
linha = { id: TIPO };
vi.mocked(requireRole).mockResolvedValue({
ok: true,
user: { id: ANA } as never,
org: { orgId: ORG } as never,
});
vi.mocked(createAdminClient).mockReturnValue({
from: (tabela: string) => ({
update: (valores: Record<string, unknown>) => {
tabelaAlvo = tabela;
atualizacao = valores;
const cadeia = {
eq: (coluna: string, valor: unknown) => {
filtros[coluna] = valor;
return cadeia;
},
select: () => cadeia,
maybeSingle: async () => ({ data: linha, error: null }),
};
return cadeia;
},
}),
} as never);
});
describe("POST /api/v1/agenda/tipos/reativar — comportamento", () => {
it("liga o tipo de volta, na tabela certa", async () => {
const { POST } = await import("@/app/api/v1/agenda/tipos/reativar/route");
const r = await POST(pedido({ id: TIPO }));
expect(r.status).toBe(200);
expect(tabelaAlvo).toBe("calendar_event_types");
expect(
atualizacao,
"a rota respondeu 200 sem gravar nada — que é exatamente o que o botão " +
"antigo fazia, só que com 422 em vez de 200",
).toEqual({ is_active: true });
});
it("o tenant vem da SESSÃO — sem isto a service role reativa tipo de outra casa", async () => {
const { POST } = await import("@/app/api/v1/agenda/tipos/reativar/route");
await POST(pedido({ id: OUTRA_ORG_TIPO }));
expect(filtros.organization_id).toBe(ORG);
expect(filtros.id).toBe(OUTRA_ORG_TIPO);
});
it("audita com verbo PRÓPRIO, e não como uma alteração de campo qualquer", async () => {
const { POST } = await import("@/app/api/v1/agenda/tipos/reativar/route");
await POST(pedido({ id: TIPO }));
expect(audit).toHaveBeenCalledTimes(1);
expect(vi.mocked(audit).mock.calls[0]?.[0]).toMatchObject({
action: "agenda.tipo_reativado",
organizationId: ORG,
resourceType: "calendar_event_types",
resourceId: TIPO,
actorUserId: ANA,
});
});
it("não diz que reativou o que não existe", async () => {
// 404 e não 200: dizer "reativei" sobre o que não há é a mesma família de
// mentira que o `DELETE` da rota irmã recusa do outro lado.
linha = null;
const { POST } = await import("@/app/api/v1/agenda/tipos/reativar/route");
const r = await POST(pedido({ id: TIPO }));
expect(r.status).toBe(404);
expect(audit).not.toHaveBeenCalled();
});
it("corpo sem `id` é recusado ANTES de qualquer escrita", async () => {
const { POST } = await import("@/app/api/v1/agenda/tipos/reativar/route");
const r = await POST(pedido({}));
expect(r.status).toBe(422);
expect(atualizacao, "a validação falhou e a rota gravou mesmo assim").toBeNull();
expect(audit).not.toHaveBeenCalled();
});
it("reativar exige `manager` — e a recusa de papel para antes da escrita", async () => {
vi.mocked(requireRole).mockResolvedValue({
ok: false,
response: new Response(null, { status: 403 }) as never,
} as never);
const { POST } = await import("@/app/api/v1/agenda/tipos/reativar/route");
const r = await POST(pedido({ id: TIPO }));
expect(r.status).toBe(403);
expect(atualizacao).toBeNull();
});
it("CONTROLE: o dublê só registra quando a rota escreve", () => {
// Sem esta asserção, um `update` que o dublê não interceptasse deixaria
// `atualizacao` sempre `null` — e os dois casos de recusa acima passariam
// por instrumento cego em vez de por comportamento.
expect(atualizacao).toBeNull();
expect(filtros).toEqual({});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// TRAVESSIA TELA → ROTA
// ─────────────────────────────────────────────────────────────────────────────
function ast(caminho: string): ts.SourceFile {
const fonte = readFileSync(caminho, "utf8");
return ts.createSourceFile(
caminho,
fonte,
ts.ScriptTarget.Latest,
true,
caminho.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
}
function percorre(no: ts.Node, visita: (n: ts.Node) => void): void {
visita(no);
no.forEachChild((filho) => percorre(filho, visita));
}
/** Os nomes declarados em `const <nome> = { … }` no arquivo dado. */
function chavesDaConstante(arquivo: ts.SourceFile, nome: string): string[] {
let achadas: string[] | null = null;
percorre(arquivo, (no) => {
if (
ts.isVariableDeclaration(no) &&
ts.isIdentifier(no.name) &&
no.name.text === nome &&
no.initializer &&
ts.isObjectLiteralExpression(no.initializer)
) {
achadas = no.initializer.properties.flatMap((p) =>
p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? [p.name.text] : [],
);
}
});
if (achadas === null) throw new Error(`não achei \`const ${nome} = { … }\` — a rota mudou de forma`);
return achadas;
}
/**
* Tira as cascas que não mudam o valor em runtime: `as T`, `<T>x`, `satisfies T`
* e parênteses.
*
* ⚠️ Sem isto a trava não morde o defeito que ela existe para pegar. O corpo
* infrator era `{ id, is_active: true } as never` — um `AsExpression`, não um
* `ObjectLiteralExpression` —, então a primeira versão desta varredura
* ATRAVESSAVA o corpo sem ver chave nenhuma e o caso passava verde. Medido: com
* a tela sabotada de volta ao estado antigo, 10 casos passaram e o principal
* foi um deles. O cast escondeu o campo do compilador E do guarda que existe
* para vigiar o compilador.
*/
function desembrulha(no: ts.Expression): ts.Expression {
let atual = no;
for (;;) {
if (ts.isAsExpression(atual) || ts.isSatisfiesExpression(atual) || ts.isTypeAssertionExpression(atual)) {
atual = atual.expression;
continue;
}
if (ts.isParenthesizedExpression(atual)) {
atual = atual.expression;
continue;
}
return atual;
}
}
/**
* As chaves de todo corpo que a tela manda em `apiClient.<metodo>("<caminho>", { … })`.
*
* Espalhamento condicional (`...(x ? { campo: y } : {})`) conta: a chave chega
* ao servidor nas mesmas condições que as outras.
*/
function chavesEnviadas(arquivo: ts.SourceFile, metodo: string, caminho: string): string[] {
const chaves = new Set<string>();
let achouChamada = false;
const colhe = (obj: ts.ObjectLiteralExpression): void => {
for (const prop of obj.properties) {
if (ts.isSpreadAssignment(prop)) {
percorre(prop.expression, (n) => {
if (ts.isObjectLiteralExpression(n)) colhe(n);
});
continue;
}
if (prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) {
chaves.add(prop.name.text);
}
}
};
percorre(arquivo, (no) => {
if (!ts.isCallExpression(no)) return;
const alvo = no.expression;
if (!ts.isPropertyAccessExpression(alvo)) return;
if (!ts.isIdentifier(alvo.expression) || alvo.expression.text !== "apiClient") return;
if (alvo.name.text !== metodo) return;
const primeiro = no.arguments[0];
if (!primeiro || !ts.isStringLiteral(primeiro) || primeiro.text !== caminho) return;
achouChamada = true;
const corpo = no.arguments[1];
if (!corpo) return;
const nu = desembrulha(corpo);
if (ts.isObjectLiteralExpression(nu)) colhe(nu);
});
if (!achouChamada) {
throw new Error(`não achei \`apiClient.${metodo}("${caminho}", …)\` na tela — a chamada mudou de forma`);
}
return [...chaves];
}
describe("travessia tela → rota: a tela não manda campo que a rota descarta", () => {
const rota = ast(ROTA_TIPOS);
const tela = ast(TELA);
const camposDoTipo = chavesDaConstante(rota, "camposDoTipo");
it("CONTROLE: as duas pontas foram realmente lidas", () => {
// Sem isto, um `camposDoTipo` que o leitor não achasse viraria lista vazia e
// TODA chave da tela apareceria como infratora — ou, na direção contrária,
// um `chavesEnviadas` vazio faria os casos abaixo passarem sobre nada.
expect(camposDoTipo.length, "camposDoTipo veio vazio — o leitor de AST cegou").toBeGreaterThan(5);
expect(camposDoTipo).toContain("duration_minutes");
expect(
camposDoTipo,
"`is_active` entrou em camposDoTipo — então o PATCH voltou a poder DESLIGAR " +
"um tipo, e a trilha perdeu a distinção entre alterar e desativar",
).not.toContain("is_active");
});
it("PATCH: toda chave enviada é aceita pelo `alterarSchema`", () => {
// `alterarSchema` é `criarSchema.partial().extend({ id })`.
const aceitas = new Set([...camposDoTipo, "id"]);
const enviadas = chavesEnviadas(tela, "patch", "/api/v1/agenda/tipos");
expect(enviadas.length).toBeGreaterThan(1);
expect(
enviadas.filter((c) => !aceitas.has(c)),
"Zod descarta chave desconhecida em SILÊNCIO: o campo some do corpo, o " +
"`update` chega vazio e quem clicou recebe 422 sem saber por quê. Foi " +
"assim que o botão Reativar passou a vida inteira sem funcionar.",
).toEqual([]);
});
it("POST: toda chave enviada na criação é aceita pelo `criarSchema`", () => {
const aceitas = new Set(camposDoTipo);
const enviadas = chavesEnviadas(tela, "post", "/api/v1/agenda/tipos");
expect(enviadas.length).toBeGreaterThan(1);
expect(enviadas.filter((c) => !aceitas.has(c))).toEqual([]);
});
it("o botão Reativar aponta para uma rota que EXISTE", () => {
// O defeito original não foi rota errada, foi campo descartado — mas o
// conserto trocou o endereço, e endereço sem arquivo é 404: o mesmo botão
// morto, com outra causa.
const enviadas = chavesEnviadas(tela, "post", "/api/v1/agenda/tipos/reativar");
expect(enviadas).toEqual(["id"]);
expect(existsSync(ROTA_REATIVAR), `sem arquivo em ${ROTA_REATIVAR}`).toBe(true);
expect(readFileSync(ROTA_REATIVAR, "utf8")).toMatch(/export async function POST/);
});
it("nenhuma chamada da tela apaga o compilador com `as never`", () => {
// Era o único cast do arquivo, e estava exatamente em cima desta travessia:
// sem ele o `pnpm typecheck` teria acusado `is_active` no dia em que o botão
// foi escrito. Um cast aqui desliga o tsc no lugar onde ele é a última
// defesa — a própria trava acima só alcança chave literal.
//
// Pelo AST, e não por regex: a primeira versão deste caso procurava o texto
// e reprovou o arquivo por causa do COMENTÁRIO que explica o cast, três
// linhas acima da chamada. Varredura de texto acusa o repositório por ele
// falar de si mesmo — é o mesmo motivo que ancora o resto deste arquivo.
const infratoras: string[] = [];
percorre(tela, (no) => {
if (!ts.isCallExpression(no)) return;
const alvo = no.expression;
if (!ts.isPropertyAccessExpression(alvo)) return;
if (!ts.isIdentifier(alvo.expression) || alvo.expression.text !== "apiClient") return;
for (const arg of no.arguments) {
percorre(arg, (n) => {
if (ts.isAsExpression(n) && n.type.kind === ts.SyntaxKind.NeverKeyword) {
infratoras.push(`apiClient.${alvo.name.text} — ${n.expression.getText().slice(0, 60)}`);
}
});
}
});
expect(infratoras).toEqual([]);
});
});
+130 -20
View File
@@ -8,7 +8,22 @@ import { getAdapter } from "@/lib/channels";
* chamada à Graph API — foi assim que estes testes vermelharam quando a resolução
* por sessão entrou, e o vermelho foi correto.
*/
const sessaoNoBanco: { token: string | null } = { token: null };
/**
* O estado do "banco" que a resolução por sessão enxerga.
*
* - `token` → instalação de uma organização só (quem conectou pela tela);
* - `porOrg` → busca filtrada por organização (#236): a chave é
* `organization_id|phone_number_id`, e cada tenant tem o SEU
* cifrado e o SEU token;
* - `erro` → falha de consulta (PGRST116 etc.);
* - `decifravel: false` → a decifra devolve null (GUC da chave ausente).
*/
const sessaoNoBanco: {
token: string | null;
porOrg: Record<string, { cifrado: string; token: string }> | null;
erro: { code?: string; message?: string } | null;
decifravel: boolean;
} = { token: null, porOrg: null, erro: null, decifravel: true };
/**
* Cadeia ENCADEÁVEL, não de um nível só.
@@ -19,25 +34,48 @@ const sessaoNoBanco: { token: string | null } = { token: null };
* que não casa com o código testa o mock. Aqui qualquer combinação de
* `.eq()/.is()` volta para o mesmo objeto e o terminal é `maybeSingle`.
*/
function cadeia(): Record<string, unknown> {
function cadeia(filtros: Record<string, unknown>): Record<string, unknown> {
const alvo: Record<string, unknown> = {
maybeSingle: async () => ({
data: sessaoNoBanco.token
? { meta_phone_number_id: "sessao-pn", meta_token_encrypted: "\\xdeadbeef" }
: null,
error: null,
}),
maybeSingle: async () => {
if (sessaoNoBanco.erro) return { data: null, error: sessaoNoBanco.erro };
const chave = `${filtros.organization_id ?? ""}|${filtros.meta_phone_number_id ?? ""}`;
const daOrg = sessaoNoBanco.porOrg?.[chave];
const cifrado = sessaoNoBanco.porOrg
? (daOrg?.cifrado ?? null)
: sessaoNoBanco.token
? "\\xdeadbeef"
: null;
return {
data: cifrado
? { meta_phone_number_id: "sessao-pn", meta_token_encrypted: cifrado }
: null,
error: null,
};
},
};
alvo.select = () => alvo;
alvo.eq = () => alvo;
alvo.eq = (col: string, val: unknown) => {
filtros[col] = val;
return alvo;
};
alvo.is = () => alvo;
return alvo;
}
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
from: () => cadeia(),
rpc: async () => ({ data: sessaoNoBanco.token, error: null }),
from: () => cadeia({}),
rpc: async (nome: string, args: { ciphertext?: string }) => {
if (nome !== "fn_decrypt_oauth" || !sessaoNoBanco.decifravel) {
return { data: null, error: null };
}
const cifrado = String(args?.ciphertext ?? "");
const daOrg = Object.values(sessaoNoBanco.porOrg ?? {}).find((s) => s.cifrado === cifrado);
return {
data: daOrg?.token ?? (sessaoNoBanco.porOrg ? null : sessaoNoBanco.token),
error: null,
};
},
}),
}));
@@ -69,6 +107,9 @@ afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
sessaoNoBanco.token = null;
sessaoNoBanco.porOrg = null;
sessaoNoBanco.erro = null;
sessaoNoBanco.decifravel = true;
});
describe("adapter meta_cloud — endereçamento", () => {
@@ -93,23 +134,30 @@ describe("adapter meta_cloud — endereçamento", () => {
});
describe("adapter meta_cloud — configuração", () => {
it("sem credencial NÃO está configurado", () => {
it("isConfigured é SEMPRE true — a credencial pode viver na sessão, e isto é síncrono", () => {
// O contrato anterior ("sem env → false") travava em `queued` toda
// instalação que conectou o número pela TELA: o pre-check respondia "não
// configurado" para um canal conectado e funcionando (issue #674). Quem
// decide é o `send`, que consulta o banco — o mesmo desenho do zernio.
vi.stubEnv("META_PHONE_NUMBER_ID", "");
vi.stubEnv("META_SYSTEM_USER_TOKEN", "");
expect(a().isConfigured()).toBe(false);
});
it("com credencial está configurado", () => {
expect(a().isConfigured()).toBe(true);
configurar();
expect(a().isConfigured()).toBe(true);
});
it("não configurado é NOOP no envio, nunca exceção", async () => {
// Mesmo contrato do outro canal: a UI mostra banner, o handler grava `queued`.
it("sem credencial NENHUMA o envio LANÇA meta_not_configured — e nada vai à rede", async () => {
// `{externalId: null}` faria o handler gravar `sent` sem id — "enviado"
// para algo que nunca saiu. O prefixo é o que o handler traduz para
// `queued` com motivo.
vi.stubEnv("META_PHONE_NUMBER_ID", "");
vi.stubEnv("META_SYSTEM_USER_TOKEN", "");
const r = await a().send({ organizationId: ORG, sessionRef: "x", to: "5531999", kind: "text", body: "oi" });
expect(r).toEqual({ externalId: null });
const spy = vi.fn();
vi.stubGlobal("fetch", spy);
await expect(
a().send({ organizationId: ORG, sessionRef: "x", to: "5531999", kind: "text", body: "oi" }),
).rejects.toThrow(/meta_not_configured/);
expect(spy).not.toHaveBeenCalled();
});
it("os códigos carregam o nome do provider — por isso vivem no adapter", () => {
@@ -290,3 +338,65 @@ describe("credencial por sessão — o que destrava multi-tenant", () => {
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer tok");
});
});
describe("elegibilidade é do `send` — os desfechos da #674", () => {
it("sessão válida SEM ambiente: o envio sai, com o token da sessão", async () => {
sessaoNoBanco.token = "token-da-sessao";
const spy = stubFetch({ messages: [{ id: "wamid.S" }] });
const r = await a().send({ organizationId: ORG, sessionRef: "sessao-pn", to: "5531", kind: "text", body: "oi" });
expect(r).toEqual({ externalId: "wamid.S" });
const [url, init] = spy.mock.calls[0]!;
expect(String(url)).toContain("/sessao-pn/messages");
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer token-da-sessao");
});
it("sessão AUSENTE e sem ambiente: lança meta_not_configured", async () => {
const spy = vi.fn();
vi.stubGlobal("fetch", spy);
await expect(
a().send({ organizationId: ORG, sessionRef: "sessao-pn", to: "5531", kind: "text", body: "oi" }),
).rejects.toThrow(/meta_not_configured/);
expect(spy).not.toHaveBeenCalled();
});
it("falha de CONSULTA fecha a ação com o código — não cai no env", async () => {
// O env está VÁLIDO de propósito: erro de resolução tem de fechar a ação e
// abrir a informação (#236), nunca virar caminho feliz de outra conta.
configurar();
sessaoNoBanco.erro = { code: "PGRST116", message: "duas linhas casaram" };
const spy = vi.fn();
vi.stubGlobal("fetch", spy);
await expect(
a().send({ organizationId: ORG, sessionRef: "sessao-pn", to: "5531", kind: "text", body: "oi" }),
).rejects.toThrow(/meta_creds_lookup_failed/);
expect(spy).not.toHaveBeenCalled();
});
it("decifragem que falha, sem env: meta_not_configured (não vira `sent` sem id)", async () => {
sessaoNoBanco.token = "cifrado-existe";
sessaoNoBanco.decifravel = false;
const spy = vi.fn();
vi.stubGlobal("fetch", spy);
await expect(
a().send({ organizationId: ORG, sessionRef: "sessao-pn", to: "5531", kind: "text", body: "oi" }),
).rejects.toThrow(/meta_not_configured/);
expect(spy).not.toHaveBeenCalled();
});
it("duas organizações: cada envio sai com o token do SEU tenant", async () => {
const OUTRA = "00000000-0000-4000-8000-0000000000bb";
sessaoNoBanco.porOrg = {
[`${ORG}|pn-a`]: { cifrado: "\\xaa", token: "tok-A" },
[`${OUTRA}|pn-b`]: { cifrado: "\\xbb", token: "tok-B" },
};
const spy = stubFetch({ messages: [{ id: "wamid.X" }] });
await a().send({ organizationId: ORG, sessionRef: "pn-a", to: "5531", kind: "text", body: "oi" });
await a().send({ organizationId: OUTRA, sessionRef: "pn-b", to: "5531", kind: "text", body: "oi" });
const auth = spy.mock.calls.map((c) => (c[1].headers as Record<string, string>).Authorization);
expect(auth).toEqual(["Bearer tok-A", "Bearer tok-B"]);
});
});
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Qual endereço o cron de fotos pede ao canal.
*
* `wa_identity` é GERADA com o telefone antes do lid (migration 0122). Num número
* BR cujo `wa_id` não tem o nono dígito — comum em linhas antigas — derivar o
* chatId dela produz `55AA9BBBBCCCC@c.us`, endereço que não existe no WhatsApp:
* o provider responde `profilePictureURL: null`, o cron carimba "sem foto" e o
* avatar nunca aparece. Medido numa instalação real: `check-exists` do WAHA
* devolvia `{"numberExists":true,"chatId":"55AABBBBCCCC@c.us"}` (12 dígitos)
* para um contato cujo `wa_identity` dizia 13, e o `@lid` do mesmo contato
* devolvia a foto na hora.
*
* `wa_lid` não deriva de telefone, por isso vem primeiro — a MESMA ordem de
* `resolveWahaChatId` (lib/waha/send.ts) e de `chatIdOf` (session-reconciler).
*/
const CONTATO = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const ORG = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const LID = "142704667287623";
/** O contato do lote — cada teste ajusta antes de chamar. */
let linhaDoContato: Record<string, unknown> = {};
/** Endereços que o cron pediu ao canal. */
const pedidos: string[] = [];
vi.mock("@/lib/env", () => ({
env: { INTERNAL_CRON_SECRET: "segredo-de-teste", INTERNAL_SECRET: "segredo-de-teste" },
}));
vi.mock("@/lib/channels", () => ({
DEFAULT_CHANNEL_PROVIDER: "waha",
getAdapter: () => ({
fetchProfilePictureUrl: async (input: { recipient: string }) => {
pedidos.push(input.recipient);
return "https://cdn.exemplo.invalid/foto.jpg";
},
}),
}));
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
from: (tabela: string) => ({
select: () => {
const dados =
tabela === "contacts"
? [linhaDoContato]
: { waha_session_name: "sessao-de-teste", provider: "waha" };
const proxy: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop) {
if (prop === "then") {
return (ok: (v: unknown) => unknown) =>
Promise.resolve({ data: dados, error: null }).then(ok);
}
if (prop === "maybeSingle") return async () => ({ data: dados, error: null });
return () => proxy;
},
},
);
return proxy;
},
update: () => {
const proxy: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop) {
if (prop === "then") {
return (ok: (v: unknown) => unknown) =>
Promise.resolve({ data: [{ id: CONTATO }], error: null }).then(ok);
}
if (prop === "select") {
return () => Promise.resolve({ data: [{ id: CONTATO }], error: null });
}
return () => proxy;
},
},
);
return proxy;
},
upsert: async () => ({ error: null }),
}),
storage: { from: () => ({ upload: async () => ({ error: null }) }) },
}),
}));
import { POST } from "@/app/api/v1/cron/contact-avatars/route";
beforeEach(() => {
pedidos.length = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
);
});
function chamar(): Promise<Response> {
return POST(
new Request("http://localhost/api/v1/cron/contact-avatars", {
method: "POST",
headers: { authorization: "Bearer segredo-de-teste" },
}) as never,
);
}
describe("cron de fotos: qual endereço vai ao canal", () => {
it("com wa_lid presente, pede pelo @lid — não pelo telefone do wa_identity", async () => {
linhaDoContato = {
id: CONTATO,
organization_id: ORG,
// O telefone tem o nono dígito; o wa_id real do contato não tem.
wa_identity: "phone:+5587999577575",
wa_lid: LID,
phone_number: "+5587999577575",
avatar_storage_path: null,
};
await chamar();
expect(pedidos).toEqual([`${LID}@lid`]);
// O endereço derivado do telefone é justamente o que não existe no WhatsApp.
expect(pedidos).not.toContain("5587999577575@c.us");
});
it("sem wa_lid, continua caindo no wa_identity — retaguarda preservada", async () => {
linhaDoContato = {
id: CONTATO,
organization_id: ORG,
wa_identity: "phone:+5511999990000",
wa_lid: null,
phone_number: "+5511999990000",
avatar_storage_path: null,
};
await chamar();
expect(pedidos).toEqual(["5511999990000@c.us"]);
});
it("wa_identity no formato lid: também resolve para @lid", async () => {
linhaDoContato = {
id: CONTATO,
organization_id: ORG,
wa_identity: `lid:${LID}`,
wa_lid: null,
phone_number: null,
avatar_storage_path: null,
};
await chamar();
expect(pedidos).toEqual([`${LID}@lid`]);
});
});
@@ -33,12 +33,19 @@
*
* ## 2. Nenhum desfecho pode dizer `sent` sem nada ter saído
*
* `sent` é o que a Central mostra como entregue ao canal. Com credencial
* ausente, `zernioAdapter.send` devolve `{ externalId: null }` sem tocar a rede
* (é o contrato de "canal não conectado", herdado do canal oficial) — e o
* handler, que só olha se houve exceção, grava `sent`. Num produto self-host
* ninguém está olhando: o dono da instalação lê "enviada" e conclui que o
* produto funciona.
* `sent` é o que a Central mostra como entregue ao canal. Histórico medido: com
* credencial ausente, `zernioAdapter.send` devolvia `{ externalId: null }` sem
* tocar a rede — o contrato de "canal não conectado" que o oficial também
* carregava — e o handler, que só olha se houve exceção, gravava `sent`. Num
* produto self-host ninguém está olhando: o dono da instalação lê "enviada" e
* conclui que o produto funciona.
*
* Os DOIS canais trocaram esse contrato por LANÇAR `*_not_configured`, e quem
* decide passou a ser o `send` (async), porque o pre-check síncrono não alcança
* o banco — onde a credencial de quem conectou pela tela mora (o intermediado
* primeiro; o oficial na #674). Este arquivo cobre os dois lados pelo canal
* oficial: o envio que SAI com a credencial da sessão e a fila com motivo
* nomeado quando não há credencial nenhuma.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -58,6 +65,24 @@ const THREAD = "6a76a2dc4b8fe115e5f6c300";
// O admin client é usado para assinar mídia e para resolver a credencial de
// sessão. Aqui a sessão NUNCA tem credencial gravada (é o estado de quem só
// configurou env), então o `select` devolve linha vazia.
/**
* O estado da credencial de sessão que a resolução encontra no "banco".
*
* - `token` → instalação de uma organização só (quem conectou pela tela e não
* escreveu `.env`);
* - `porOrg` → busca filtrada por organização (#236): a chave é
* `organization_id|phone_number_id`, e cada tenant tem o SEU cifrado e o SEU
* token — é o que prova que cada organização envia pelo token dela;
* - `erro` → falha de consulta (PGRST116 etc.);
* - `decifravel: false` → a decifra devolve null (GUC da chave ausente).
*/
const credencialDaSessao: {
token: string | null;
porOrg: Record<string, { cifrado: string; token: string }> | null;
erro: { code?: string; message?: string } | null;
decifravel: boolean;
} = { token: null, porOrg: null, erro: null, decifravel: true };
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
storage: {
@@ -73,14 +98,47 @@ vi.mock("@/lib/supabase/admin", () => ({
// migration 0165). Um stub em que `eq()` já entrega `maybeSingle` deixa de
// casar com o código real — e mock que não casa testa o mock.
from: () => {
const filtros: Record<string, unknown> = {};
const alvo: Record<string, unknown> = {
maybeSingle: async () => ({ data: null, error: null }),
maybeSingle: async () => {
if (credencialDaSessao.erro) return { data: null, error: credencialDaSessao.erro };
const chave = `${filtros.organization_id ?? ""}|${filtros.meta_phone_number_id ?? ""}`;
const daOrg = credencialDaSessao.porOrg?.[chave];
const cifrado = credencialDaSessao.porOrg
? (daOrg?.cifrado ?? null)
: credencialDaSessao.token
? "\\xdeadbeef"
: null;
return {
data: cifrado
? {
meta_phone_number_id: String(filtros.meta_phone_number_id ?? "pn"),
meta_token_encrypted: cifrado,
}
: null,
error: null,
};
},
};
alvo.select = () => alvo;
alvo.eq = () => alvo;
alvo.eq = (col: string, val: unknown) => {
filtros[col] = val;
return alvo;
};
alvo.is = () => alvo;
return alvo;
},
rpc: async (nome: string, args: { ciphertext?: string }) => {
if (nome !== "fn_decrypt_oauth" || !credencialDaSessao.decifravel) {
return { data: null, error: null };
}
const cifrado = String(args?.ciphertext ?? "");
const daOrg = Object.values(credencialDaSessao.porOrg ?? {}).find((s) => s.cifrado === cifrado);
return {
data: daOrg?.token ?? (credencialDaSessao.porOrg ? null : credencialDaSessao.token),
error: null,
};
},
}),
}));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => {}) }));
@@ -134,6 +192,8 @@ function projetar(linha: Row, select: string): Row {
interface Forma {
providerConversationId?: string | null;
provider?: string;
/** Canal excluído pela tela (migration 0106) — comporta o ramo `channel_archived`. */
archivedAt?: string | null;
}
function conversaCompleta(forma: Forma = {}): Row {
@@ -153,7 +213,7 @@ function conversaCompleta(forma: Forma = {}): Row {
meta_phone_number_id: provider === "meta_cloud" ? "1103328999528818" : null,
zernio_account_id: provider === "zernio" ? CONTA : null,
status: "WORKING",
archived_at: null,
archived_at: forma.archivedAt ?? null,
},
};
}
@@ -273,9 +333,22 @@ function respostaOk(messageId = "wamid.OK") {
}));
}
/** Resposta da Graph API (canal oficial): o id vem em `messages[0].id`. */
function respostaMeta(messageId = "wamid.M") {
return vi.fn(async (..._args: unknown[]) => ({
ok: true,
status: 200,
json: async () => ({ messages: [{ id: messageId }] }),
}));
}
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
credencialDaSessao.token = null;
credencialDaSessao.porOrg = null;
credencialDaSessao.erro = null;
credencialDaSessao.decifravel = true;
});
describe("o projetor do dublê é discriminante (guarda de vacuidade)", () => {
@@ -432,8 +505,10 @@ describe("nenhum desfecho diz `sent` sem nada ter saído", () => {
it("CONTROLE: o canal oficial, com env igualmente incompleto, fica `queued`", async () => {
// O par é o que dá sentido ao caso acima: mesma classe de má configuração,
// desfecho oposto. `metaCredsFromEnv()` exige as duas vars e `isConfigured()`
// deriva DELE, então o pre-check já barra e a linha fica em fila.
// desfecho oposto. Desde a #674 a decisão de elegibilidade é do `send` (o
// pre-check síncrono não alcança o banco): ele resolve a sessão, cai no env
// e, sem credencial nenhuma, LANÇA — o handler traduz o prefixo para
// `queued` com motivo. O desfecho observável é o mesmo de antes.
vi.stubEnv("META_PHONE_NUMBER_ID", "");
vi.stubEnv("META_SYSTEM_USER_TOKEN", "tok");
const fetchMock = vi.fn();
@@ -492,3 +567,103 @@ describe("nenhum desfecho diz `sent` sem nada ter saído", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
/**
* A promessa da TELA de conexão, do lado do handler (issue #674): quem conectou
* o número oficial pela Central de Conexões guarda a credencial cifrada no
* banco — e o envio tem de SAIR, sem `.env`. O pre-check síncrono respondia
* "não configurado" para essa instalação e a mensagem morria em fila, sem erro,
* sem nunca tentar.
*/
describe("canal oficial conectado pela TELA — a credencial da sessão manda (#674)", () => {
it("sessão válida SEM ambiente: a mensagem SAI, com o token da sessão", async () => {
credencialDaSessao.token = "tok-da-sessao";
const fetchMock = respostaMeta("wamid.M1");
vi.stubGlobal("fetch", fetchMock);
const { supabase } = makeSupabase(conversaCompleta({ provider: "meta_cloud" }));
const msg = await sendMessageHandler(supabase, ctx, texto());
expect(msg.status).toBe("sent");
expect(msg.external_id).toBe("wamid.M1");
const [url, init] = fetchMock.mock.calls[0]!;
expect(String(url)).toContain("/1103328999528818/messages");
expect((init as { headers: Record<string, string> }).headers.Authorization).toBe("Bearer tok-da-sessao");
});
it("sessão ausente e sem ambiente: `queued` com `meta_not_configured`, nada na rede", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { supabase } = makeSupabase(conversaCompleta({ provider: "meta_cloud" }));
const msg = await sendMessageHandler(supabase, ctx, texto());
expect(msg.status).toBe("queued");
expect((msg.metadata as Record<string, unknown>).queued_reason).toBe("meta_not_configured");
expect(fetchMock).not.toHaveBeenCalled();
});
it("falha de CONSULTA fecha a ação com o código — não engole em `sent`", async () => {
// Doutrina da #236: resolução que falha fecha a ação e abre a informação.
credencialDaSessao.erro = { code: "PGRST116", message: "duas linhas casaram" };
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { supabase } = makeSupabase(conversaCompleta({ provider: "meta_cloud" }));
const msg = await sendMessageHandler(supabase, ctx, texto());
expect(msg.status).toBe("failed");
expect(msg.error_code).toBe("meta_error");
expect(String(msg.error_message)).toMatch(/meta_creds_lookup_failed/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("decifragem que falha e sem env: `queued` — canal não conectado é recuperável", async () => {
credencialDaSessao.token = "cifrado-existe";
credencialDaSessao.decifravel = false;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { supabase } = makeSupabase(conversaCompleta({ provider: "meta_cloud" }));
const msg = await sendMessageHandler(supabase, ctx, texto());
expect(msg.status).toBe("queued");
expect((msg.metadata as Record<string, unknown>).queued_reason).toBe("meta_not_configured");
expect(fetchMock).not.toHaveBeenCalled();
});
it("sessão ARQUIVADA: `failed` com `channel_archived`, sem consultar credencial", async () => {
credencialDaSessao.token = "tok-que-nao-deve-ser-usado";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { supabase } = makeSupabase(
conversaCompleta({ provider: "meta_cloud", archivedAt: "2026-08-01T00:00:00.000Z" }),
);
const msg = await sendMessageHandler(supabase, ctx, texto());
expect(msg.status).toBe("failed");
expect(msg.error_code).toBe("channel_archived");
expect(fetchMock).not.toHaveBeenCalled();
});
it("duas organizações: cada envio sai com o token do SEU tenant", async () => {
const OUTRA = "99999999-9999-4999-8999-999999999999";
credencialDaSessao.porOrg = {
[`${ORG}|1103328999528818`]: { cifrado: "\\xaa", token: "tok-A" },
[`${OUTRA}|1103328999528818`]: { cifrado: "\\xbb", token: "tok-B" },
};
const fetchMock = respostaMeta("wamid.T");
vi.stubGlobal("fetch", fetchMock);
const { supabase: sbA } = makeSupabase(conversaCompleta({ provider: "meta_cloud" }));
await sendMessageHandler(sbA, ctx, texto());
const { supabase: sbB } = makeSupabase(conversaCompleta({ provider: "meta_cloud" }));
await sendMessageHandler(sbB, { ...ctx, organization_id: OUTRA }, texto());
const auth = fetchMock.mock.calls.map(
(c) => (c[1] as { headers: Record<string, string> }).headers.Authorization,
);
expect(auth).toEqual(["Bearer tok-A", "Bearer tok-B"]);
});
});
+9 -1
View File
@@ -75,8 +75,16 @@ describe("completude da navegação", () => {
);
expect(
semPorta,
// ⚠️ O ENDEREÇO AQUI É O `catalogo.ts`, e não o `registry.ts`.
//
// Esta mensagem mandava ao `registry.ts` — que é de onde este arquivo
// IMPORTA, mas onde não há nada a declarar: ele deriva `NAV_DESTINATIONS`
// de `NAV_CATALOG` e reexporta. Quem fosse reprovado pelo CI abriria um
// arquivo sem um único lugar para pôr a tela. É a mensagem de erro que a
// pessoa lê no pior momento; mandá-la ao arquivo errado é o defeito mais
// caro dos dois (o `CLAUDE.md` repetia o mesmo engano, já corrigido).
`Tela sem porta — existe mas não se chega nela pela navegação.\n` +
`Adicione ao registro (lib/navigation/registry.ts) declarando grupo, ou\n` +
`Declare em lib/navigation/catalogo.ts (NAV_CATALOG), com seu grupo, ou\n` +
`à NAV_ALLOWLIST deste arquivo COM a justificativa:\n ${semPorta.join("\n ")}`,
).toEqual([]);
});
@@ -0,0 +1,73 @@
/**
* O INTERRUPTOR TEM DE GRAVAR A CAMADA, NÃO O NOME DA TELA.
*
* Cada conferência tem dois identificadores: `nome` ("jailbreak_detect"), que é
* de tela, e `camada` ("jailbreak"), que é a chave de `org_guardrail_layers` e o
* único valor que `PUT /api/v1/ai/guardrail-layers` aceita — a rota valida
* contra o enum `CAMADAS_SEMANTICAS`.
*
* O interruptor mandava o `nome`, e ligar qualquer uma das duas camadas
* devolvia 422 em toda instalação. A posição do controle parecia certa porque a
* LEITURA já usava `camada`; só a escrita divergia.
*
* O typecheck não pegava: o call site fazia `layer as CamadaDeSeguranca["layer"]`,
* e um `as` desliga exatamente a checagem que reprovaria isto. O conserto tira o
* cast — mas um cast volta fácil num refactor, então a fiação fica medida aqui.
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import "@testing-library/jest-dom/vitest";
const gravar = vi.fn();
vi.mock("@/hooks/ai/useGuardrailLayers", () => ({
useGuardrailLayers: () => ({
data: {
camadas: [
{ layer: "promessa_semantica", escolha: false, padraoDoAmbiente: false, efetivo: false },
{ layer: "jailbreak", escolha: false, padraoDoAmbiente: false, efetivo: false },
],
podeEditar: true,
},
}),
useSetGuardrailLayer: () => ({ mutate: gravar, isPending: false }),
}));
import { PainelDeSeguranca } from "@/app/app/ai/agents/[id]/_components/PainelDeSeguranca";
import { CONFERENCIA_DE_ENTRADA, CONFERENCIAS_DE_SAIDA } from "@/lib/ai/guardrails/lista-de-conferencia";
function montar() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<PainelDeSeguranca />
</QueryClientProvider>,
);
}
/** As conferências que TÊM interruptor — as outras não se desligam. */
const COM_ESCOLHA = [...CONFERENCIAS_DE_SAIDA, CONFERENCIA_DE_ENTRADA].filter(
(c) => c.camada !== null,
);
describe("o interruptor de segurança grava a camada, não o nome de tela", () => {
it("o instrumento está vivo: existe conferência com escolha, e nome ≠ camada", () => {
// Controle positivo. Se a lista ficasse vazia (refactor, renomeação), o
// `it.each` abaixo passaria por vacuidade — zero caso é zero reprovação.
expect(COM_ESCOLHA.length).toBeGreaterThanOrEqual(2);
expect(COM_ESCOLHA.some((c) => c.nome !== c.camada)).toBe(true);
});
it.each(COM_ESCOLHA)("«$rotulo» manda layer=$camada", (c) => {
gravar.mockClear();
montar();
fireEvent.click(screen.getByTestId(`conferencia-${c.nome}-liga`));
expect(gravar).toHaveBeenCalledTimes(1);
expect(gravar).toHaveBeenCalledWith({ layer: c.camada, enabled: true });
// O nome de tela NÃO pode chegar à rota: é o valor que ela recusa com 422.
expect(gravar).not.toHaveBeenCalledWith(expect.objectContaining({ layer: c.nome }));
});
});
@@ -34,6 +34,7 @@ import { describe, expect, it } from "vitest";
import {
INTEGRACAO_DE_SESSAO,
INTEGRACAO_DE_TRACING,
integracoesDoCliente,
isCommunityDsn,
resolveSentryDsn,
@@ -43,6 +44,7 @@ const PADRAO_FALSO = [
{ name: "InboundFilters" },
{ name: "Breadcrumbs" },
{ name: INTEGRACAO_DE_SESSAO },
{ name: INTEGRACAO_DE_TRACING },
{ name: "GlobalHandlers" },
] as const;
@@ -52,6 +54,22 @@ describe("integracoesDoCliente", () => {
expect(saida).not.toContain(INTEGRACAO_DE_SESSAO);
});
/**
* `BrowserTracing` instrumenta Web Vitals (CLS/LCP/TTFB) via `PerformanceObserver`
* mesmo com `tracesSampleRate: 0` — a amostragem decide se o trace é ENVIADO, não
* se o observer é INSTALADO. Achado em produção (2026-09-09): um item `undefined`
* na lista de entries (uma extensão de navegador mexendo na Performance API da
* página) derrubava o coletor com `TypeError: Cannot read properties of
* undefined (reading 'startTime')`, direto no console de quem opera o self-host
* — sem nenhum trace chegando a existir para explicar o quê. Tirar a integração
* pra quem está no DSN da comunidade elimina o crash de graça: não havia telemetria
* nenhuma sendo enviada por ela ali.
*/
it("no DSN da comunidade o tracing/Web Vitals (fonte do crash de PerformanceObserver) NÃO vai", () => {
const saida = integracoesDoCliente(PADRAO_FALSO, true).map((i) => i.name);
expect(saida).not.toContain(INTEGRACAO_DE_TRACING);
});
it("no DSN da comunidade o RESTO continua — não é desligar telemetria, é escolher o quê", () => {
// Sem este caso, uma função que devolve `[]` passaria no de cima e mataria o
// stack trace junto com a sessão, que é o oposto do desenho.
@@ -117,6 +135,41 @@ describe("o nome da integração casa com o SDK instalado", () => {
).toBeTruthy();
expect(fonte).toContain(`name: "${INTEGRACAO_DE_SESSAO}"`);
});
it("o @sentry/browser instalado ainda chama a integração de tracing assim", () => {
const store = path.join(process.cwd(), "node_modules", ".pnpm");
const pastas = readdirSync(store).filter((d) => d.startsWith("@sentry+browser@"));
expect(
pastas.length,
"não achei o @sentry/browser no store do pnpm — ENSINE ESTE TESTE",
).toBeGreaterThan(0);
const fonte = pastas
.map((d) =>
path.join(
store,
d,
"node_modules/@sentry/browser/build/npm/cjs/prod/tracing/browserTracingIntegration.js",
),
)
.map((p) => {
try {
return readFileSync(p, "utf8");
} catch {
return "";
}
})
.find((c) => c.length > 0);
expect(
fonte,
"não achei o módulo de tracing do @sentry/browser — ENSINE ESTE TESTE",
).toBeTruthy();
// A constante fica no MESMO arquivo (não é import de outro módulo), então o
// literal sobrevive ao build — é o mesmo motivo pelo qual checar o arquivo
// isolado (em vez de rodar o SDK inteiro) já é prova suficiente do nome.
expect(fonte).toContain(`= "${INTEGRACAO_DE_TRACING}"`);
});
});
/**
+143
View File
@@ -0,0 +1,143 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
motivoDaFalha,
sugestaoParaMostrar,
} from "@/lib/agent-engine/agent/sugestao-de-resposta";
/**
* Os dois defeitos medidos em produção em 2026-09-12, na tela de atendimento.
*
* **1. A sugestão rejeitada não saía da tela.** O painel mostrava a mais recente
* sem olhar o estado. Rejeitar não fecha nada, e não há botão de fechar no
* componente — então o texto morto ficava ali. Quando a geração seguinte também
* falhava, a tela travava naquele estado, sem saída.
*
* **2. O erro não dizia nada, e o identificador não levava a lugar nenhum.** A
* rota fazia `} catch {`, sem nome: a causa era descartada, três situações
* diferentes viravam a mesma frase, e nada era registrado — mas a tela exibia o
* identificador da requisição, o que faz a mensagem parecer rastreável.
*
* Os dois casos cobrem o CAMINHO DO ERRO, que é o que paga: a sugestão que some
* e a causa que aparece. Cada regra tem também o seu controle no sentido oposto,
* porque uma função que devolvesse SEMPRE `undefined` (ou sempre o genérico)
* passaria por metade destes casos sem conservar nada.
*/
type Sugestao = { id: string; status: string };
const s = (status: string, id = "d1"): Sugestao => ({ id, status });
describe("qual sugestão o painel mostra", () => {
it("⛔ a REJEITADA sai da tela — é o defeito que travou a tela do dono", () => {
expect(sugestaoParaMostrar([s("dismissed")])).toBeUndefined();
});
it("⛔ a OBSOLETA e a JÁ ENVIADA também saem", () => {
// A enviada já aparece na conversa logo acima; repeti-la numa caixa
// desabilitada diz duas vezes a mesma coisa.
expect(sugestaoParaMostrar([s("stale")])).toBeUndefined();
expect(sugestaoParaMostrar([s("sent")])).toBeUndefined();
});
it("CONTROLE: a que espera revisão CONTINUA aparecendo", () => {
// Sem este caso, uma implementação que escondesse tudo passaria nos de cima
// e apagaria o recurso inteiro — o erro em espelho, e o mais fácil de fazer.
expect(sugestaoParaMostrar([s("pending")])?.status).toBe("pending");
});
it("CONTROLE: as intermediárias continuam aparecendo", () => {
for (const vivo of ["generating", "approved", "sending"])
expect(sugestaoParaMostrar([s(vivo)])?.status, vivo).toBe(vivo);
});
it("a que FALHOU continua aparecendo — ela é a única pista que sobra", () => {
// Deliberado: o texto dela diz o que conferir. Esconder deixaria quem
// atende sem nenhuma explicação para a sugestão que não veio.
expect(sugestaoParaMostrar([s("failed")])?.status).toBe("failed");
});
it("⛔ NÃO ressuscita uma sugestão antiga quando a atual é rejeitada", () => {
// A correção ingênua — procurar na lista a primeira que ainda sirva — faria
// um texto que o atendente acabou de rejeitar voltar sozinho para a tela,
// vindo de uma sugestão anterior. Olha só a mais recente, de propósito.
expect(sugestaoParaMostrar([s("dismissed", "novo"), s("pending", "velho")])).toBeUndefined();
});
it("sem sugestão nenhuma, painel neutro", () => {
expect(sugestaoParaMostrar([])).toBeUndefined();
expect(sugestaoParaMostrar(undefined)).toBeUndefined();
});
});
describe("o que a tela diz quando a geração falha", () => {
it("⛔ 'não há agente publicado' deixa de virar a frase genérica", () => {
const m = motivoDaFalha(new Error("reply_no_agent"));
expect(m.codigo).toBe("reply_no_agent");
expect(m.acionavel).toBe(true);
expect(m.texto).toMatch(/publicad/i);
});
it("⛔ 'contexto indisponível' diz as três causas reais", () => {
const m = motivoDaFalha(new Error("reply_context_unavailable"));
expect(m.codigo).toBe("reply_context_unavailable");
expect(m.acionavel).toBe(true);
});
it("causa desconhecida NÃO finge saber, e promete o registro", () => {
// A frase antiga mandava "conferir a publicação e a configuração do agente"
// mesmo quando o problema era o provedor de IA fora do ar. Mandar a pessoa
// mexer na configuração certa é pior que admitir que não se sabe.
const m = motivoDaFalha(new Error("timeout do provedor"));
expect(m.codigo).toBe("reply_unavailable");
expect(m.acionavel).toBe(false);
expect(m.texto).not.toMatch(/Confira a publicação/i);
});
it("não quebra com o que não é Error", () => {
for (const estranho of [undefined, null, "texto solto", 42])
expect(motivoDaFalha(estranho).codigo).toBe("reply_unavailable");
});
it("CONTROLE: as três frases são distintas — senão o conserto é só aparência", () => {
const textos = [
motivoDaFalha(new Error("reply_no_agent")).texto,
motivoDaFalha(new Error("reply_context_unavailable")).texto,
motivoDaFalha(new Error("outro")).texto,
];
expect(new Set(textos).size).toBe(3);
});
});
describe("a rota não volta a engolir a causa", () => {
const ROTA = path.join(
process.cwd(),
"app",
"api",
"v1",
"conversations",
"[id]",
"draft-reply",
"route.ts",
);
const fonte = fs.readFileSync(ROTA, "utf8");
it("⛔ nenhum `catch` sem nome — foi ele que descartou a causa", () => {
// A cerca é por forma porque o defeito é de forma: `} catch {` compila,
// passa no lint, passa no typecheck e passa na suíte. Só um leitor humano
// percebia — e por meses ninguém percebeu.
expect(/catch\s*\{/.test(fonte), "há um `catch {` sem nome nesta rota").toBe(false);
});
it("⛔ a causa é REGISTRADA — senão o identificador na tela não leva a nada", () => {
expect(fonte).toMatch(/logger\.error\(/);
});
it("CONTROLE: a cerca acima reprovaria mesmo — ela casa com a forma proibida", () => {
// Guarda de vacuidade: se a expressão parasse de casar com qualquer coisa,
// o caso de cima ficaria verde para sempre, inclusive com o defeito de volta.
expect(/catch\s*\{/.test("try { x() } catch { }")).toBe(true);
});
});
@@ -0,0 +1,206 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor, act } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TeamMember } from "@/hooks/team/useTeamMembers";
/**
* REVOGAR E DEVOLVER ACESSO PINTAM A LINHA NA HORA.
*
* ─── O defeito, achado pela tela ────────────────────────────────────────────
*
* Enquanto o revogado SUMIA da lista (a rota o filtrava fora), qualquer atraso
* de atualização parecia efeito: a linha desaparecia e pronto. Depois que ele
* passou a FICAR na lista e só o ESTADO mudar, só invalidar a query deixava a
* linha parada até alguém recarregar a página — quem clicava não via nada
* acontecer e clicava de novo.
*
* ─── Por que o rollback importa tanto quanto a pintura ──────────────────────
*
* Pintar sem desfazer é pior que não pintar: uma recusa do servidor deixaria a
* tela dizendo "ativo" para quem NÃO foi reativado. Os dois lados são cobrados
* aqui, nos dois hooks.
*
* ─── O que este arquivo cobre, e o que não ──────────────────────────────────
*
* Cobre o CACHE — que é o que a linha lê. Não cobre pixel: a prova de tela do
* fluxo de equipe é do Playwright, e está declarada no corpo do PR.
*
* ⚠️ O momento é o ponto. `apiClient.post` fica PENDENTE de propósito nos casos
* de pintura: assertar o cache depois que a promessa resolve não distingue
* "pintou na hora" de "recarregou no fim" — que é exatamente o defeito. Quem
* tirar o `onMutate` e deixar só o `invalidateQueries` passa numa asserção
* feita tarde demais.
*/
const post = vi.fn();
const toastSuccess = vi.fn();
const erroMostrado = vi.fn();
vi.mock("@/lib/api/client", () => ({ apiClient: { post: (...a: unknown[]) => post(...a) } }));
vi.mock("@/components/feedback/ApiErrorToast", () => ({
showApiError: (...a: unknown[]) => erroMostrado(...a),
}));
vi.mock("sonner", () => ({ toast: { success: (...a: unknown[]) => toastSuccess(...a) } }));
// O dicionário tem contexto próprio; aqui o texto degrada para ele mesmo, que é
// o contrato de `traduzir` quando não há entrada.
vi.mock("@/hooks/i18n/useT", () => ({ useT: () => (texto: string) => texto }));
import { useReactivateMember } from "@/hooks/team/useReactivateMember";
import { useRevokeMember } from "@/hooks/team/useRevokeMember";
const MEMBERS_KEY = ["team", "members"] as const;
const ANA = "11111111-1111-4111-8111-111111111111";
const BRUNO = "22222222-2222-4222-8222-222222222222";
function membro(userId: string, revokedAt: string | null): TeamMember {
return {
user_id: userId,
role: "agent",
invited_at: null,
accepted_at: "2026-09-01T12:00:00.000Z",
revoked_at: revokedAt,
created_at: "2026-09-01T12:00:00.000Z",
email: `${userId.slice(0, 4)}@exemplo.com.br`,
full_name: null,
last_sign_in_at: null,
};
}
let qc: QueryClient;
/** Uma promessa que EU decido quando (e como) termina. */
function pendente() {
let resolver!: (v: unknown) => void;
let rejeitar!: (e: unknown) => void;
const promessa = new Promise((res, rej) => {
resolver = res;
rejeitar = rej;
});
post.mockImplementation(() => promessa);
// A promessa é rejeitada de propósito em alguns casos; sem este `catch` o
// Node acusa rejeição não tratada e derruba a suíte por fora do teste.
promessa.catch(() => undefined);
return { resolver, rejeitar };
}
const linha = (userId: string) =>
qc.getQueryData<{ data: TeamMember[] }>(MEMBERS_KEY)?.data.find((m) => m.user_id === userId);
function envolve({ children }: { children: ReactNode }) {
return createElement(QueryClientProvider, { client: qc }, children);
}
beforeEach(() => {
vi.clearAllMocks();
qc = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
});
// Ana ATIVA, Bruno REVOGADO — os dois sentidos partem daqui.
qc.setQueryData(MEMBERS_KEY, {
data: [membro(ANA, null), membro(BRUNO, "2026-09-05T09:00:00.000Z")],
});
});
describe("CONTROLE do instrumento", () => {
it("o cache semeado parte do estado que os casos assumem", () => {
// Sem isto, um `setQueryData` com chave errada deixaria `linha()` sempre
// `undefined` e as asserções de estado passariam sobre nada.
expect(linha(ANA)?.revoked_at).toBeNull();
expect(linha(BRUNO)?.revoked_at).toBe("2026-09-05T09:00:00.000Z");
});
});
describe("useRevokeMember", () => {
it("pinta a linha ANTES de o servidor responder", async () => {
const { resolver } = pendente();
const { result } = renderHook(() => useRevokeMember(), { wrapper: envolve });
act(() => result.current.mutate(ANA));
await waitFor(() =>
expect(
linha(ANA)?.revoked_at,
"a linha só mudou depois da resposta — quem clicou ficou sem retorno, " +
"que é o defeito inteiro desta correção",
).not.toBeNull(),
);
// E o vizinho não foi arrastado junto.
expect(linha(BRUNO)?.revoked_at).toBe("2026-09-05T09:00:00.000Z");
await act(async () => {
resolver({ data: { user_id: ANA } });
});
});
it("DESFAZ quando o servidor recusa — senão a tela mente", async () => {
const { rejeitar } = pendente();
const { result } = renderHook(() => useRevokeMember(), { wrapper: envolve });
act(() => result.current.mutate(ANA));
await waitFor(() => expect(linha(ANA)?.revoked_at).not.toBeNull());
await act(async () => {
rejeitar(new Error("403"));
});
await waitFor(() =>
expect(
linha(ANA)?.revoked_at,
"o servidor recusou e a tela continuou dizendo que o acesso foi revogado",
).toBeNull(),
);
expect(erroMostrado).toHaveBeenCalledTimes(1);
});
});
describe("useReactivateMember", () => {
it("pinta a linha ANTES de o servidor responder", async () => {
const { resolver } = pendente();
const { result } = renderHook(() => useReactivateMember(), { wrapper: envolve });
act(() => result.current.mutate(BRUNO));
await waitFor(() => expect(linha(BRUNO)?.revoked_at).toBeNull());
expect(linha(ANA)?.revoked_at).toBeNull();
await act(async () => {
resolver({ data: { user_id: BRUNO } });
});
});
it("AVISA que deu certo — revogar avisava, devolver não", async () => {
// É a ação que se faz com receio de ter errado: sem confirmação, quem clicou
// não sabe se valeu.
const { resolver } = pendente();
const { result } = renderHook(() => useReactivateMember(), { wrapper: envolve });
act(() => result.current.mutate(BRUNO));
await act(async () => {
resolver({ data: { user_id: BRUNO } });
});
await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1));
expect(toastSuccess).toHaveBeenCalledWith("Acesso devolvido.");
});
it("recusa do servidor desfaz E não comemora", async () => {
const { rejeitar } = pendente();
const { result } = renderHook(() => useReactivateMember(), { wrapper: envolve });
act(() => result.current.mutate(BRUNO));
await waitFor(() => expect(linha(BRUNO)?.revoked_at).toBeNull());
await act(async () => {
rejeitar(new Error("403"));
});
await waitFor(() => expect(linha(BRUNO)?.revoked_at).toBe("2026-09-05T09:00:00.000Z"));
expect(
toastSuccess,
"avisou que o acesso foi devolvido depois de o servidor recusar",
).not.toHaveBeenCalled();
expect(erroMostrado).toHaveBeenCalledTimes(1);
});
});