mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge branch 'melgarafael:main' into develop
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+955
-943
File diff suppressed because it is too large
Load Diff
@@ -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 || {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}"`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user