Merge remote-tracking branch 'origin/main' into feat/marketplace-de-extensoes

This commit is contained in:
melgarafael
2026-09-18 20:31:45 -03:00
80 changed files with 4030 additions and 296 deletions
@@ -0,0 +1,10 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A mensagem que a automação manda tem número próprio no painel
---
O painel de atrito passa a separar o que o AGENTE escreveu do que a AUTOMAÇÃO enviou. Regra de automação, texto fixo do follow-up e lembrete de agenda aparecem num número novo — "Mensagens enviadas por automação" —, e a conversa nomeia essas mensagens como "Automação" em vez de "IA".
O número "Mensagens enviadas pelo agente" fica MENOR em quem usa automação, e isso não é regressão: aquelas mensagens nunca foram escritas pela IA, e até agora eram contadas como se fossem. Nenhum número antigo saiu do painel.
Crédito: @webtecnica.
@@ -1,8 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A catraca do espanhol passa a cobrar a chave que vem de tabela de outro módulo
---
O teste que garante que toda frase de tela tem espanhol resolvia a chave quando a tabela de rótulos era declarada no MESMO arquivo. Quando a tabela morava noutro módulo — o caso de `TRIGGER_LABELS` e `ACTION_LABELS` (rótulos do construtor de fluxo), `SEVERITY_LABEL` (inbox da IA) e `ROTULO_DO_PAPEL` (convite de equipe) —, a chamada passava batida e ninguém era avisado. Agora a catraca atravessa o `import` e cobra cada valor possível da tabela no dicionário, nas áreas de produto, `lib/` inclusive.
Medido na `main` de 18/08/2026: 103 chamadas resolvidas, 196 valores exigidos do dicionário e 9 valores faltando, em 3 arquivos. Esses 9 ficam numa lista de dívida congelada dentro do próprio teste: a lista só encolhe, e traduzir um deles deixa o teste vermelho pedindo a remoção da linha. O que o `t()` recebe de dado de runtime — identificador solto, `algo.campo` — segue fora do alcance de propósito: cobrar isso é o passo seguinte da mesma issue. Nada muda na tela de quem opera.
@@ -1,10 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A cerca de `organizations` resolve o tipo do cliente admin que mora em outro arquivo
---
O gate que garante que toda escrita em `organizations` passa pelo cliente admin reconhecia o cliente injetado por parâmetro só quando o TIPO estava escrito no próprio arquivo — ou num `type` local. Três formas que o `tsc` aceita ficavam vermelhas com a escrita certa: o alias importado de outro módulo (`import type { Admin } from "@/lib/waha/ingest"`, que já é exportado no repositório), o membro que chega por `extends` de uma interface, e o cliente que uma função passa para outra dentro do mesmo arquivo, sem anotação no receptor.
Agora o resolvedor atravessa o `import` até o módulo que declara o tipo — dois arquivos, o que usa e o que declara — e segue a herança até a base. A passagem entre funções passou a ser provada pela CHAMADA: o parâmetro sem anotação de uma função local não exportada é aceito quando todas as chamadas visíveis a ele entregam um cliente admin. Todas, não uma: uma chamada correta com outra entregando o cliente de sessão mantém o vermelho, e função exportada continua fora do alcance, porque pode ser chamada de um arquivo que a varredura não vê.
Nada muda para quem opera: nenhum arquivo do repositório muda de veredito (a cerca já estava verde) e o que autoriza continua sendo o tipo, nunca o nome. O que muda é o atrito de quem escreve certo.
@@ -1,27 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O follow-up espera a janela abrir sem desistir do contato
---
Quando um passo de mensagem caía fora do horário permitido de envio — a noite,
o domingo fechado, ou a faixa de horário que você escolheu —, o acompanhamento
já reagendava a mensagem corretamente para a próxima abertura. O problema era o
outro lado: o motor do fluxo não ficava sabendo do adiamento, continuava
perguntando "essa mensagem já saiu?" e, depois de cerca de onze horas
perguntando, desistia do contato. Na tela aparecia o aviso
**"Um fluxo de follow-up parou de tentar"**, e o motivo registrado dizia que a
mensagem nunca tinha sido concluída — o que era falso: ela estava só esperando
o horário que você mesmo configurou. Uma janela que fechasse no sábado à noite
e só reabrisse na segunda já passava desse limite.
Agora o passo diz que está esperando, e diz até quando. O motor guarda o
contato parado até a hora da abertura em vez de gastar tentativas, o dossiê do
acompanhamento mostra a linha
**"Segurou o envio até o horário permitido"** com a data, e a desistência
automática continua existindo para o que ela sempre serviu: um envio que de
fato travou, sem sinal de vida nenhum.
Nada muda para quem opera: nenhuma variável nova, nenhum ajuste, nenhum passo
na atualização. Contatos que já tinham sido dados como perdidos por esse motivo
não voltam sozinhos — o conserto vale dos próximos em diante.
-21
View File
@@ -1,21 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Quem baixa o projeto para usar ou estudar deixa de receber um erro que não é dele
---
Quem faz uma cópia do projeto (um "fork") para estudar, testar ou contribuir
recebia um erro vermelho na verificação automática logo na primeira vez que a
rodava — e o erro não tinha nada a ver com o que a pessoa tinha feito. Ele
existia porque o projeto confere se as imagens de instalação pertencem ao dono
certo, e numa cópia esse dono é outro por definição.
Agora a conferência entende quando está rodando dentro de uma cópia e não cobra
nada ali, dizendo por escrito que aquele caso não foi medido — em vez de dizer
que passou, que seria mentira, ou que falhou, que era o problema.
Contra o projeto original a conferência continua exatamente como era: se alguém
tentar trocar o dono das imagens num pedido de alteração, o erro aparece.
Para quem já opera um servidor, nada muda: isto acontece inteiramente na esteira
de verificação, antes de qualquer versão ser publicada.
-6
View File
@@ -1,6 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Erros de leitura do acervo respeitam o idioma da interface
---
Mensagens de falha ao ler arquivos do acervo agora usam chaves estáveis de interface, impedindo que detalhes técnicos façam a tradução cair silenciosamente em português. Crédito: @joaopaulomirandamatias.
@@ -1,23 +0,0 @@
---
impacto: nada_mudou
secao: alterado
titulo: O campo "Motivos de perda extras" sai de Configurações › Organização
---
Havia dois lugares para cadastrar motivo de perda, com nomes quase iguais. Um
funciona: **Etapas do funil**, o campo "Motivos de perda (separados por
vírgula)" — é ele que a janela de perder oferece e é ele que o banco aceita. O
outro, em **Organização**, prometia "adicionados ao set padrão" e não era lido
por ninguém: nem pela janela, nem pela validação que decide se o motivo passa.
O efeito era pior do que não ter o campo. Quem cadastrava ali não via os motivos
na hora de marcar um negócio como perdido, escrevia o motivo à mão em "Outro" e
recebia um erro genérico — o banco recusava um texto que a tela dizia ter
aceitado, e nada na interface ligava uma coisa à outra.
O campo saiu da aba Organização. Motivo de perda continua se cadastrando em
**Etapas do funil**, por funil, que é onde o relatório de perdas agrupa.
Nada some do banco: o que já estava gravado fica na linha, apenas sem tela. Se
você tinha motivos cadastrados só ali, eles nunca chegaram a valer — recadastre
no funil para passarem a aparecer na janela de perder.
@@ -1,11 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O sinal de presença deixa de poder encher o registro de auditoria
---
O sinal de presença do atendente, que saiu na 1.34.0, faz uma batida por aba a cada 60 segundos. Agora só a PRIMEIRA batida de cada pessoa deixa uma entrada no registro de auditoria — que é a que cria a linha e acorda o roteamento, o único efeito que outra pessoa sente. As batidas seguintes não registram nada, pela mesma régua que já vale para a rodada de cron que não fez nada: audita-se quando houve efeito, nunca se deixa de auditar por comodidade.
Sem isso, uma instalação com oito atendentes de plantão somaria cerca de 3.800 entradas de auditoria por turno de oito horas, sem que ninguém tivesse feito nada — e o registro de auditoria é onde se procura quem fez o quê quando algo dá errado.
Nada a fazer na instalação: o comportamento muda sozinho na atualização, e nenhuma entrada já gravada é tocada.
@@ -1,10 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A prova de sincronia do kit para de acusar chave inocente sob carga
---
A prova de sincronia do `hostgator-setup-kit/test-validators.sh` montava a lista de escrita num pipeline de três estágios e, quando a máquina estava saturada, às vezes recebia essa lista truncada — e então acusava chaves inocentes que o `install.sh` grava na linha seguinte, com conjunto de acusadas diferente a cada rodada sobre os mesmos arquivos. O piso anti-truncagem de 30 chaves não pegava a truncagem parcial: 30 é menos da metade das 67 chaves da régua real, então 67 caindo para 40 passava por baixo da guarda e saía como acusação.
Agora a mesma régua é contada duas vezes, por caminhos independentes — a lista do pipeline e uma contagem direta no `install.sh`, de um processo só, sem pipeline e logo sem leitura parcial — e a acusação só sai se as duas contas baterem. Divergindo, ou voltando o pipeline acima com status ≠ 0, o desfecho é inconclusivo e o teste diz isso com todas as letras, nunca "chave faltando". O piso fixo sai de cena: não sobra número escolhido à mão para envelhecer a cada chave nova.
Medido em 18/09/2026 na `main`: régua real com 67 chaves e piso de 30. Truncando a régua para 40 chaves — o corte que hoje passa por baixo do piso —, o código anterior acusou 24 chaves inocentes; cortes de 31 e 33 chaves acusaram 27 e 29, sobre os mesmos arquivos. Com o mesmo corte de 40, o código novo não acusa nenhuma: fecha inconclusivo com `40 chave(s) contra 67 na contagem direta`. Com a régua inteira, a suíte segue verde até "todos os validadores passaram". Nada muda para quem instala pelo kit — o teste fica mais difícil de ficar vermelho por engano e mais claro quando fica vermelho de verdade.
-20
View File
@@ -1,20 +0,0 @@
---
impacto: nada_mudou
secao: corrigido
titulo: A verificação automática deixou de dizer "cancelado" quando ela mesma demora
---
Quando a bateria de verificação automática levava mais tempo que o limite
configurado, o sistema marcava o resultado como "cancelado" — a mesma palavra
que aparece quando alguém cancela de propósito. Quem tinha enviado uma
contribuição lia "cancelaram o meu trabalho", e quem ia conferir saía procurando
uma pessoa que não existia.
Agora o limite serve só para matar o que travou de verdade, e quem avisa que a
bateria engordou é uma mensagem em português que diz o que aconteceu e o que
fazer — nomeando a parte que passou do previsto.
Para quem opera um servidor, nada muda: isto acontece inteiramente na esteira de
verificação do projeto, antes de qualquer versão ser publicada. O que muda é o
tempo até um conserto chegar até você, porque contribuições boas deixam de ficar
paradas por um diagnóstico errado.
@@ -0,0 +1,8 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: O canal oficial passa a registrar o próprio webhook na Meta
---
Conectar o canal oficial deixava metade do caminho para o operador: o CRM gravava a credencial validada e o canal ficava ENVIANDO e sem RECEBER até alguém abrir o painel da Meta, colar a URL de callback e marcar os campos — por número, e sem que a tela do CRM dissesse isso em lugar algum. Quem não sabia não via erro nenhum: as respostas do cliente simplesmente não chegavam e a janela de 24 horas nunca abria. Agora, ao conectar, a instalação inscreve o app na WABA e aponta o webhook daquele número para o endereço dela mesma (a inscrição primeiro: sem ela a Meta não entrega nada), guardando o desfecho na sessão — a tela mostra "webhook pendente" com o motivo que a Meta deu e um botão de tentar de novo, sem desconectar e reconectar o canal. Falhar nesse passo NÃO desfaz a conexão: o canal continua enviando, e o que falta é a entrega. O par número/WABA passa a ser conferido junto da credencial (número de uma conta com id de outra gravava uma sessão que envia e cujo webhook nunca chega), e o endereço público da instalação, que era calculado em três rotas com o mesmo código, passa a ter um dono só.
Crédito: @webtecnica.
+6 -2
View File
@@ -73,11 +73,13 @@ jobs:
# o teto volta de 25 para 15 (guarda de travamento) e o orçamento desce de 16
# para 12 — quem denuncia a suíte crescendo é o orçamento, por parte.
verify-parte:
runs-on: ubuntu-latest
# Guarda de travamento. O DETECTOR de crescimento é o passo `Orçamento de
# tempo` no fim de cada parte; a medição que justifica o número está em
# `TETOS`, em tests/unit/preambulo-do-ci-nao-come-o-relogio.test.ts.
timeout-minutes: 15
# Executor próprio para trabalho nosso quando EXECUTOR_PROPRIO=ligado; o
# porquê e a guarda contra fork: comentário do e2e-parte no e2e.yml.
runs-on: ${{ vars.EXECUTOR_PROPRIO == 'ligado' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && 'deskcomm-proprio' || 'ubuntu-latest' }}
concurrency:
group: ${{ github.workflow }}-verify-parte${{ matrix.parte }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
@@ -223,13 +225,15 @@ jobs:
# sendo o piso (`scripts/test-db.sh`) — quem guarda isso é
# tests/unit/baseline-no-piso-do-postgres.test.ts.
invariants-majors:
runs-on: ubuntu-latest
# Eram 20 min quando a perna tinha UMA passada; agora ela tem duas
# (`test:db` + `test:db:update`) e o teto acompanha. O 30 é DECLARADO, não
# medido — não há Docker onde esta matriz foi escrita —, e existe para o
# vermelho continuar falando de schema: mantido em 20, a perna morreria por
# relógio no meio da segunda passada.
timeout-minutes: 30
# Executor próprio para trabalho nosso quando EXECUTOR_PROPRIO=ligado; o
# porquê e a guarda contra fork: comentário do e2e-parte no e2e.yml.
runs-on: ${{ vars.EXECUTOR_PROPRIO == 'ligado' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && 'deskcomm-proprio' || 'ubuntu-latest' }}
concurrency:
group: ${{ github.workflow }}-invariants-majors-pg${{ matrix.pg }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+84 -7
View File
@@ -60,6 +60,58 @@ permissions:
# no agregador — razão medida e a tabela por evento no cabeçalho do ci.yml.
jobs:
# O PR ALCANÇA ALGO QUE O E2E MEDE? Responde em segundos, antes das partes.
#
# O e2e é o maior consumidor da fila: 56% dos minutos de runner medidos de 15 a
# 18/09/2026. Um PR só de documentação, teste de outra suíte ou workflow alheio
# pagava três partes de ~23 min para medir o mesmo produto — 25 dos 200 PRs
# mais recentes eram assim. A regra, e o porquê de cada caminho, estão no
# cabeçalho de scripts/pr-alcanca-o-e2e.sh.
#
# Fora de pull_request é SEMPRE `sim`: a `main` mede tudo. Falha ao listar,
# lista cortada (≥3000) ou resposta estranha também viram `sim`. E o agregador
# `e2e` só aceita parte pulada quando ESTE job terminou `success` com `nao`:
# job que falhou aqui reprova, em vez de passar por falta de medida.
#
# Roda nas máquinas do GitHub sempre: é de segundos, e não pode depender do
# executor próprio estar de pé para decidir nada.
e2e-alcance:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
e2e: ${{ steps.alcance.outputs.e2e }}
steps:
- uses: actions/checkout@v7
if: github.event_name == 'pull_request'
with:
sparse-checkout: scripts/pr-alcanca-o-e2e.sh
sparse-checkout-cone-mode: false
- name: O PR alcança o que o e2e mede?
id: alcance
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
set -uo pipefail
if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then
echo "e2e=sim" >> "$GITHUB_OUTPUT"; echo "evento ${GITHUB_EVENT_NAME}: roda"; exit 0
fi
if ! arquivos=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --jq '.[].filename'); then
echo "e2e=sim" >> "$GITHUB_OUTPUT"; echo "::warning::não consegui listar os arquivos do PR — roda"; exit 0
fi
n=$(printf '%s\n' "$arquivos" | grep -c . || true)
if [ "$n" -ge 3000 ]; then
resposta=sim
else
resposta=$(printf '%s\n' "$arquivos" | bash scripts/pr-alcanca-o-e2e.sh) || resposta=sim
fi
[ "$resposta" = "nao" ] || resposta=sim
echo "e2e=${resposta}" >> "$GITHUB_OUTPUT"
echo "${n} arquivo(s) no PR → e2e=${resposta}"
# AS DUAS PARTES RODAM EM PARALELO, e o motivo é medido, não estético.
#
# Elas rodavam como dois passos do MESMO job, então somavam contra um único
@@ -88,7 +140,15 @@ jobs:
# subi-lo seria trocar um vermelho honesto por um CI que demora mais a cada
# mês sem ninguém perceber.
e2e-parte:
runs-on: ubuntu-latest
needs: [e2e-alcance]
if: needs.e2e-alcance.outputs.e2e == 'sim'
# Executor próprio para trabalho NOSSO (push na main, PR de branch deste
# repo) quando a variável de repositório EXECUTOR_PROPRIO vale `ligado`;
# máquinas do GitHub para todo o resto, fork inclusive. A expressão decide
# só para quem não a edita — a guarda que vale contra fork mora na máquina
# (infra/executor-proprio/so-o-que-e-nosso.sh). Vigiado por
# tests/unit/executor-proprio-so-roda-o-que-e-nosso.test.ts.
runs-on: ${{ vars.EXECUTOR_PROPRIO == 'ligado' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && 'deskcomm-proprio' || 'ubuntu-latest' }}
# O teto FICA EM 30, e essa foi uma correção de rumo desta branch.
#
# Eu tinha subido para 45, com o argumento de que o teto acumula dois papéis
@@ -1186,7 +1246,7 @@ jobs:
# mesmo padrão do `imagens-ok` em `publish-image.yml`.
e2e:
if: always()
needs: [e2e-parte]
needs: [e2e-alcance, e2e-parte]
runs-on: ubuntu-latest
# Nenhuma permissão: este job só lê o resultado de `needs` e escreve o
# summary. Sem o bloco, o token herdaria o default do REPOSITÓRIO, que vive
@@ -1194,12 +1254,29 @@ jobs:
permissions: {}
steps:
- name: Falhar se qualquer parte não passou
env:
EVENTO: ${{ github.event_name }}
PORTAO: ${{ needs.e2e-alcance.result }}
ALCANCE: ${{ needs.e2e-alcance.outputs.e2e }}
PARTES: ${{ needs.e2e-parte.result }}
run: |
echo "e2e-parte: ${{ needs.e2e-parte.result }}"
# `success` é o único desfecho aceito. `skipped` também reprova, de
# propósito: parte pulada não mediu nada, e ler isso como aprovação é
# o modo de falha que o `imagens-ok` documenta.
[ "${{ needs.e2e-parte.result }}" = "success" ]
set -euo pipefail
echo "evento=${EVENTO} e2e-alcance=${PORTAO} e2e=${ALCANCE:-<vazio>}"
echo "e2e-parte: ${PARTES}"
# A pergunta tem de ter sido RESPONDIDA: job de alcance que falhou
# deixa as partes `skipped`, e isso não pode ler como aprovação.
[ "$PORTAO" = "success" ]
# `skipped` das partes só é aceito com `e2e=nao` em pull_request — PR
# que não alcança nada que o e2e mede (scripts/pr-alcanca-o-e2e.sh).
# Qualquer outra combinação exige `success`: parte pulada não mediu
# nada, e ler isso como aprovação é o modo de falha que o `imagens-ok`
# documenta.
if [ "$EVENTO" = "pull_request" ] && [ "$ALCANCE" = "nao" ]; then
[ "$PARTES" = "skipped" ]
echo "PR não alcança nada que o e2e mede — partes puladas (scripts/pr-alcanca-o-e2e.sh)" | tee -a "$GITHUB_STEP_SUMMARY"
else
[ "$PARTES" = "success" ]
fi
- uses: actions/checkout@v7
if: always()
+3 -1
View File
@@ -19,7 +19,9 @@ permissions:
jobs:
build-and-size:
runs-on: ubuntu-latest
# Executor próprio para trabalho nosso quando EXECUTOR_PROPRIO=ligado; o
# porquê e a guarda contra fork: comentário do e2e-parte no e2e.yml.
runs-on: ${{ vars.EXECUTOR_PROPRIO == 'ligado' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && 'deskcomm-proprio' || 'ubuntu-latest' }}
timeout-minutes: 15
concurrency:
group: ${{ github.workflow }}-build-and-size-${{ github.event.pull_request.number || github.ref }}
+10 -2
View File
@@ -295,7 +295,11 @@ jobs:
imagem-do-app-sobe:
needs: [a-tag-veio-da-main]
if: needs.a-tag-veio-da-main.outputs.imagem == 'sim'
runs-on: ubuntu-latest
# Executor próprio SÓ em PR de branch deste repo (EXECUTOR_PROPRIO=ligado).
# Na `main` e em tag esta imagem vira a que o parque instala: ela se constrói
# nas máquinas do GitHub, nunca numa máquina nossa. Guarda contra fork:
# comentário do e2e-parte no e2e.yml.
runs-on: ${{ vars.EXECUTOR_PROPRIO == 'ligado' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && 'deskcomm-proprio' || 'ubuntu-latest' }}
concurrency:
group: ${{ github.workflow }}-imagem-do-app-sobe-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
@@ -508,7 +512,11 @@ jobs:
imagens-de-fundo-sobem:
needs: [a-tag-veio-da-main]
if: needs.a-tag-veio-da-main.outputs.imagem == 'sim'
runs-on: ubuntu-latest
# Executor próprio SÓ em PR de branch deste repo (EXECUTOR_PROPRIO=ligado).
# Na `main` e em tag esta imagem vira a que o parque instala: ela se constrói
# nas máquinas do GitHub, nunca numa máquina nossa. Guarda contra fork:
# comentário do e2e-parte no e2e.yml.
runs-on: ${{ vars.EXECUTOR_PROPRIO == 'ligado' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && 'deskcomm-proprio' || 'ubuntu-latest' }}
concurrency:
group: ${{ github.workflow }}-imagens-de-fundo-sobem-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
+3 -1
View File
@@ -351,7 +351,9 @@ cabeçalho passava a mentir por todos eles.
- Arquivos de invariante de banco em `tests/invariants/` — RLS/isolamento cross-tenant, RBAC,
governança (G1–G6). Excluídos do `test:unit` de propósito; rodam via `pnpm test:db` **e no job
`invariants` do CI**. Quantos: `git ls-files 'tests/invariants/*.test.ts' | wc -l`.
- Specs Playwright em `tests/e2e/`, quase todas no CI (via `e2e.yml`, **obrigatório**). As que
- Specs Playwright em `tests/e2e/`, quase todas no CI (via `e2e.yml`, **obrigatório**), em todo PR que
alcança o que elas medem — PR só de documentação/teste de outra suíte pula as partes
(`scripts/pr-alcanca-o-e2e.sh`), e ali o `e2e` verde não prova tela. As que
ficam de fora estão declaradas em `FORA_DO_CI`, **com o motivo escrito ao lado**. Esta linha
já afirmou "menos uma" depois de deixarem de ser uma — por isso não conta mais. A issue #63,
que originou a discussão, está **fechada** e o título dela descreve um estado que já não vale.
+113 -1
View File
@@ -8,6 +8,117 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es
## [Não lançado]
## [1.35.1] — 2026-09-18
### Alterado
- **O campo "Motivos de perda extras" sai de Configurações › Organização** Havia dois lugares para cadastrar motivo de perda, com nomes quase iguais. Um
funciona: **Etapas do funil**, o campo "Motivos de perda (separados por
vírgula)" — é ele que a janela de perder oferece e é ele que o banco aceita. O
outro, em **Organização**, prometia "adicionados ao set padrão" e não era lido
por ninguém: nem pela janela, nem pela validação que decide se o motivo passa.
O efeito era pior do que não ter o campo. Quem cadastrava ali não via os motivos
na hora de marcar um negócio como perdido, escrevia o motivo à mão em "Outro" e
recebia um erro genérico — o banco recusava um texto que a tela dizia ter
aceitado, e nada na interface ligava uma coisa à outra.
O campo saiu da aba Organização. Motivo de perda continua se cadastrando em
**Etapas do funil**, por funil, que é onde o relatório de perdas agrupa.
Nada some do banco: o que já estava gravado fica na linha, apenas sem tela. Se
você tinha motivos cadastrados só ali, eles nunca chegaram a valer — recadastre
no funil para passarem a aparecer na janela de perder.
### Corrigido
- **A catraca do espanhol passa a cobrar a chave que vem de tabela de outro módulo** O teste que garante que toda frase de tela tem espanhol resolvia a chave quando a tabela de rótulos era declarada no MESMO arquivo. Quando a tabela morava noutro módulo — o caso de `TRIGGER_LABELS` e `ACTION_LABELS` (rótulos do construtor de fluxo), `SEVERITY_LABEL` (inbox da IA) e `ROTULO_DO_PAPEL` (convite de equipe) —, a chamada passava batida e ninguém era avisado. Agora a catraca atravessa o `import` e cobra cada valor possível da tabela no dicionário, nas áreas de produto, `lib/` inclusive.
Medido na `main` de 18/08/2026: 103 chamadas resolvidas, 196 valores exigidos do dicionário e 9 valores faltando, em 3 arquivos. Esses 9 ficam numa lista de dívida congelada dentro do próprio teste: a lista só encolhe, e traduzir um deles deixa o teste vermelho pedindo a remoção da linha. O que o `t()` recebe de dado de runtime — identificador solto, `algo.campo` — segue fora do alcance de propósito: cobrar isso é o passo seguinte da mesma issue. Nada muda na tela de quem opera.
- **A cerca de `organizations` resolve o tipo do cliente admin que mora em outro arquivo** O gate que garante que toda escrita em `organizations` passa pelo cliente admin reconhecia o cliente injetado por parâmetro só quando o TIPO estava escrito no próprio arquivo — ou num `type` local. Três formas que o `tsc` aceita ficavam vermelhas com a escrita certa: o alias importado de outro módulo (`import type { Admin } from "@/lib/waha/ingest"`, que já é exportado no repositório), o membro que chega por `extends` de uma interface, e o cliente que uma função passa para outra dentro do mesmo arquivo, sem anotação no receptor.
Agora o resolvedor atravessa o `import` até o módulo que declara o tipo — dois arquivos, o que usa e o que declara — e segue a herança até a base. A passagem entre funções passou a ser provada pela CHAMADA: o parâmetro sem anotação de uma função local não exportada é aceito quando todas as chamadas visíveis a ele entregam um cliente admin. Todas, não uma: uma chamada correta com outra entregando o cliente de sessão mantém o vermelho, e função exportada continua fora do alcance, porque pode ser chamada de um arquivo que a varredura não vê.
Nada muda para quem opera: nenhum arquivo do repositório muda de veredito (a cerca já estava verde) e o que autoriza continua sendo o tipo, nunca o nome. O que muda é o atrito de quem escreve certo.
- **O follow-up espera a janela abrir sem desistir do contato** Quando um passo de mensagem caía fora do horário permitido de envio — a noite,
o domingo fechado, ou a faixa de horário que você escolheu —, o acompanhamento
já reagendava a mensagem corretamente para a próxima abertura. O problema era o
outro lado: o motor do fluxo não ficava sabendo do adiamento, continuava
perguntando "essa mensagem já saiu?" e, depois de cerca de onze horas
perguntando, desistia do contato. Na tela aparecia o aviso
**"Um fluxo de follow-up parou de tentar"**, e o motivo registrado dizia que a
mensagem nunca tinha sido concluída — o que era falso: ela estava só esperando
o horário que você mesmo configurou. Uma janela que fechasse no sábado à noite
e só reabrisse na segunda já passava desse limite.
Agora o passo diz que está esperando, e diz até quando. O motor guarda o
contato parado até a hora da abertura em vez de gastar tentativas, o dossiê do
acompanhamento mostra a linha
**"Segurou o envio até o horário permitido"** com a data, e a desistência
automática continua existindo para o que ela sempre serviu: um envio que de
fato travou, sem sinal de vida nenhum.
Nada muda para quem opera: nenhuma variável nova, nenhum ajuste, nenhum passo
na atualização. Contatos que já tinham sido dados como perdidos por esse motivo
não voltam sozinhos — o conserto vale dos próximos em diante.
- **Quem baixa o projeto para usar ou estudar deixa de receber um erro que não é dele** Quem faz uma cópia do projeto (um "fork") para estudar, testar ou contribuir
recebia um erro vermelho na verificação automática logo na primeira vez que a
rodava — e o erro não tinha nada a ver com o que a pessoa tinha feito. Ele
existia porque o projeto confere se as imagens de instalação pertencem ao dono
certo, e numa cópia esse dono é outro por definição.
Agora a conferência entende quando está rodando dentro de uma cópia e não cobra
nada ali, dizendo por escrito que aquele caso não foi medido — em vez de dizer
que passou, que seria mentira, ou que falhou, que era o problema.
Contra o projeto original a conferência continua exatamente como era: se alguém
tentar trocar o dono das imagens num pedido de alteração, o erro aparece.
Para quem já opera um servidor, nada muda: isto acontece inteiramente na esteira
de verificação, antes de qualquer versão ser publicada.
- **O envio de vendas para o Google Ads volta a funcionar, e o botão só aparece quando a instalação está pronta** A versão 1.35.0 trouxe o envio de conversões para o Google Ads falando uma versão da API que o Google já tinha desativado (v17). Toda venda voltava recusada, sem nova tentativa, e a tela de Conversões mostrava a página de erro do Google no lugar do motivo. Agora o envio usa a v25, que o Google mantém até agosto de 2027. A versão fica num lugar só do código, e um teste impede que ela volte a ficar abaixo das que o Google ainda mantém. Quando o Google responder algo fora do formato de erro dele, a tela mostra um motivo legível, com a pista de que a versão pode ter saído do ar.
A 1.35.0 também dizia que, sem as credenciais do Google Ads no `.env`, o botão "Conectar com Google" não aparecia — e ele aparecia. Agora é verdade: sem `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_OAUTH_CLIENT_ID` e `GOOGLE_ADS_OAUTH_CLIENT_SECRET`, o cartão do Google Ads diz que o envio ainda não está disponível nesta instalação e lista, pelo nome, quais variáveis faltam. Quem já tem as três configuradas não precisa fazer nada.
- **A guarda de primeira mensagem da origem de site passa a filtrar a organização** A origem da página só é gravada quando aquela é a PRIMEIRA mensagem do contato. A consulta que responde isso sai pelo client administrativo — o que passa por cima do isolamento entre organizações que o banco aplica sozinho — e ela não filtrava a organização: filtrava o contato e a direção, e só. A resposta era sobre o contato no banco inteiro, não sobre o contato desta organização.
O filtro passou a vir do chamador, com a organização de quem recebeu o webhook — nunca do corpo da requisição. Medido no `tests/unit/origem-do-site.test.ts` em 18/09/2026: 25 testes verdes; removida apenas a linha do filtro, 2 ficam vermelhos, e o caso de duas organizações responde `false` porque a mensagem de entrada mais antiga daquele contato vinha de fora da fronteira. Nada muda na tela de quem opera: o `contact_id` é uuid e não colide entre organizações, então o resultado de hoje já era o certo. O que muda é a consulta deixar de depender disso.
- **Erros de leitura do acervo respeitam o idioma da interface** Mensagens de falha ao ler arquivos do acervo agora usam chaves estáveis de interface, impedindo que detalhes técnicos façam a tradução cair silenciosamente em português. Crédito: @joaopaulomirandamatias.
- **O gateway configurado em OPENROUTER_BASE_URL vale também para o agente** Quem aponta `OPENROUTER_BASE_URL` para um gateway compatível com a OpenRouter via os pontos do painel funcionarem, mas o agente publicado não: o botão "Sugerir resposta" e o turno do agente mandavam a chave para `openrouter.ai` e morriam com "Missing Authentication header". Agora seguem a variável o agente no app, o turno do agente no worker, a credencial da organização sem endereço preenchido no painel e a prova de crédito da instalação; sem ela, nada muda. A validação da chave na tela de Credenciais ainda consulta `openrouter.ai`. Crédito: @rogercampel.
- **O sinal de presença deixa de poder encher o registro de auditoria** O sinal de presença do atendente, que saiu na 1.34.0, faz uma batida por aba a cada 60 segundos. Agora só a PRIMEIRA batida de cada pessoa deixa uma entrada no registro de auditoria — que é a que cria a linha e acorda o roteamento, o único efeito que outra pessoa sente. As batidas seguintes não registram nada, pela mesma régua que já vale para a rodada de cron que não fez nada: audita-se quando houve efeito, nunca se deixa de auditar por comodidade.
Sem isso, uma instalação com oito atendentes de plantão somaria cerca de 3.800 entradas de auditoria por turno de oito horas, sem que ninguém tivesse feito nada — e o registro de auditoria é onde se procura quem fez o quê quando algo dá errado.
Nada a fazer na instalação: o comportamento muda sozinho na atualização, e nenhuma entrada já gravada é tocada.
- **A prova de sincronia do kit para de acusar chave inocente sob carga** A prova de sincronia do `hostgator-setup-kit/test-validators.sh` confere se toda chave prometida no `.env.hostgator.example` é gravada pelo `install.sh`. Sob máquina saturada, ela às vezes acusava uma ou duas chaves que o `install.sh` grava, e a cada rodada eram chaves diferentes, sobre os mesmos arquivos.
A causa era a checagem por chave. Cada chave era conferida por um `printf | grep -qx` próprio, ou seja, um processo por chave, e qualquer falha desse processo era lida como "chave ausente". Agora a pertença é respondida dentro do próprio shell, sem abrir processo, e não tem como falhar sozinha. A assinatura das rodadas registradas na issue #1153 confirma isso: cada uma acusou uma ou duas chaves espalhadas, enquanto uma lista truncada perde a cauda e, para deixar de fora aquelas chaves, teria de acusar de 14 a 54 ao mesmo tempo.
A lista também é contada duas vezes, desenho de @webtecnica no PR #1207: a lista do pipeline e uma contagem direta no `install.sh`, as duas por chave única. Se divergirem, o resultado é inconclusivo, nunca "chave faltando". Contar por chave única evita outro falso vermelho: um `envq` repetido em dois ramos de um `if` é uma chave só. O piso fixo de 30 chaves saiu. Nada muda para quem instala pelo kit.
- **A verificação automática deixou de dizer "cancelado" quando ela mesma demora** Quando a bateria de verificação automática levava mais tempo que o limite
configurado, o sistema marcava o resultado como "cancelado" — a mesma palavra
que aparece quando alguém cancela de propósito. Quem tinha enviado uma
contribuição lia "cancelaram o meu trabalho", e quem ia conferir saía procurando
uma pessoa que não existia.
Agora o limite serve só para matar o que travou de verdade, e quem avisa que a
bateria engordou é uma mensagem em português que diz o que aconteceu e o que
fazer — nomeando a parte que passou do previsto.
Para quem opera um servidor, nada muda: isto acontece inteiramente na esteira de
verificação do projeto, antes de qualquer versão ser publicada. O que muda é o
tempo até um conserto chegar até você, porque contribuições boas deixam de ficar
paradas por um diagnóstico errado.
## [1.35.0] — 2026-09-18
### Adicionado
@@ -5700,7 +5811,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.35.0...HEAD
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.35.1...HEAD
[1.35.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.35.0...v1.35.1
[1.35.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.34.0...v1.35.0
[1.34.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.33.0...v1.34.0
[1.33.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.32.1...v1.33.0
+20 -1
View File
@@ -428,7 +428,7 @@ Checks **obrigatórios** na branch protection da `main` (verificado na configura
- **`verify`** (`ci.yml`) — typecheck + lint + test:unit.
- **`invariants`** (`ci.yml`) — **job de fachada**: ele não roda suíte nenhuma; reprova quando a matriz `invariants-majors` não fecha em `success`. Quem roda é a matriz, uma perna por major do Postgres que o produto diz suportar, e cada perna faz duas passadas: `pnpm test:db` (baseline em modo install com `ON_ERROR_STOP=1` e update, mais os invariantes, incluindo o isolamento RLS entre 2 organizações) e `pnpm test:db:update` (atualização de um banco COM dados). Para saber quais majors hoje, pergunte ao arquivo em vez de a esta linha: `awk '/^ invariants-majors:/,/^ [a-z-]+:/' .github/workflows/ci.yml | grep -A6 'matrix:'`.
- **`build-and-size`** (`perf.yml`) — `pnpm build` em Node 22.
- **`e2e`** (`e2e.yml`) — sobe Supabase local, aplica o `baseline.sql` e roda **todas as specs Playwright menos as que `FORA_DO_CI` declara**. O número saiu daqui de propósito: ele apodreceu **cinco** vezes (a quinta em 2026-08-24, quando `inbox-quem-manda.spec.ts` entrou), e a condição que o PR #242 pôs para parar de recontar já tinha vencido na quarta. Quem precisa do número roda o comando abaixo — comando não envelhece. Quais ficam de fora, e por quê, é o que a própria variável diz — **não confie nesta linha, leia-a**:
- **`e2e`** (`e2e.yml`) — sobe Supabase local, aplica o `baseline.sql` e roda **todas as specs Playwright menos as que `FORA_DO_CI` declara** — **em PR que alcança algo que ele mede**. PR só de documentação, teste de outra suíte, fragmento ou workflow alheio pula as partes (regra em `scripts/pr-alcanca-o-e2e.sh`, na dúvida roda), e ali o `e2e` verde **não prova tela nenhuma**. O número saiu daqui de propósito: ele apodreceu **cinco** vezes (a quinta em 2026-08-24, quando `inbox-quem-manda.spec.ts` entrou), e a condição que o PR #242 pôs para parar de recontar já tinha vencido na quarta. Quem precisa do número roda o comando abaixo — comando não envelhece. Quais ficam de fora, e por quê, é o que a própria variável diz — **não confie nesta linha, leia-a**:
```bash
git show origin/main:.github/workflows/e2e.yml | \
@@ -465,6 +465,25 @@ seguiu dizendo "quatro". Uma triagem que leia qualquer uma dessas versões mede
que é o modo de falha nº 1 do procedimento de triagem. **Reconfira na fonte antes de confiar em
qualquer lista aqui**, com o comando acima.
**Onde os jobs rodam.** A conta tem o plano Pro: até **40** jobs simultâneos nas máquinas do GitHub
(medidos 39 em 18/09/2026, com 180 na fila). Os jobs pesados do trabalho **nosso** (push na `main`
e PR de branch deste repositório) podem ir para o **executor próprio** (`infra/executor-proprio/`)
quando a variável de repositório `EXECUTOR_PROPRIO` vale `ligado`; PR de fork roda sempre no GitHub,
e a publicação da `main` também. Duas regras que não se negociam:
- **A guarda contra fork mora na máquina, não no YAML.** Em PR de fork o GitHub roda o workflow do
fork, que pode reescrever `runs-on:`. Quem recusa é `infra/executor-proprio/so-o-que-e-nosso.sh`,
gravado na imagem como hook de entrada do runner. Mudar a expressão de `runs-on` não é mudar a
segurança — e afrouxar a guarda é.
- **Imagem que o parque instala nunca se constrói na máquina nossa.** `build-and-push` e
`promover-stable` ficam em `ubuntu-latest`; os jobs `*-sobe` só vão para a máquina em PR.
Vigiado por `tests/unit/executor-proprio-so-roda-o-que-e-nosso.test.ts`. Botão de emergência:
apagar a variável `EXECUTOR_PROPRIO` — os jobs novos voltam na hora para o GitHub. **A fila de merge
(merge queue) do GitHub não está disponível** neste repositório (conta pessoal; medido em 18/09/2026:
a regra é recusada com 422 e uma regra comum no mesmo formato é aceita) — a integração em lote da
triagem (`triagem/TRIAGEM.md` §3-quinquies) é o que cumpre esse papel.
Ao mexer em schema, RLS, RBAC, atribuição, escopo, roteamento, follow-up, webhooks ou automações: rode `pnpm test:db` **localmente** antes de abrir PR. É o único caminho que exercita o `baseline.sql` que o self-hoster realmente aplica.
---
@@ -58,10 +58,9 @@ export type UpdateAdPlatformConnectionResult =
const entradaSchema = z.object({
/**
* Só `meta_ads` hoje. `google_ads` fica FORA do enum de propósito: aceitar o
* cadastro de uma plataforma sem transporte deixaria o operador colar um token
* e esperar conversões que nunca sairiam — e o livro-razão diria
* `plataforma_sem_transporte` sem que ele tivesse como entender por quê.
* Só `meta_ads`. `google_ads` fica FORA do enum porque a credencial dele não
* é colada: chega pelo OAuth, e o cadastro de para onde reportar mora em
* `updateGoogleAdsConnection.ts`.
*/
platform: z.literal("meta_ads"),
dataset_id: z.string().trim().min(5).max(64).regex(/^\d+$/, "só dígitos"),
+121 -35
View File
@@ -31,11 +31,15 @@ import { ARCHIVED_AT, queryTolerantToMissingArchived } from "@/lib/channels/arch
import { CHANNEL_PROVIDER_META } from "@/lib/channels/capabilities";
import { appDaMeta, appDaMetaDoAmbiente } from "@/lib/channels/meta/app";
import { validateMetaCredentials } from "@/lib/channels/meta/validate-credentials";
import {
COLUNAS_DO_DESFECHO_DO_WEBHOOK,
registrarWebhookDaSessao,
} from "@/lib/channels/meta/webhook-da-sessao";
import { reactivateChannelSession } from "@/lib/channels/reactivate";
import { env } from "@/lib/env";
import { createAdminClient } from "@/lib/supabase/admin";
import { metadataInicialDoCanal } from "@/lib/ai/elegibilidade/pre-go-live";
import { encryptWebhookSecret } from "@/lib/webhooks/secrets";
import { basePublicaDaInstalacao } from "@/lib/webhooks/url-publica";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -47,21 +51,31 @@ const conectarSchema = z.object({
token: z.string().min(20),
});
interface DesfechoGravado {
meta_webhook_override_uri: string | null;
meta_webhook_override_erro: string | null;
meta_webhook_override_em: string | null;
}
/**
* Base pública desta instalação — é o que o operador cola no dashboard da Meta.
* O desfecho do registro do webhook desta sessão, lido em consulta PRÓPRIA.
*
* `env.*` e NÃO `process.env.NEXT_PUBLIC_APP_URL` direto: variáveis
* `NEXT_PUBLIC_` são substituídas no BUILD, e a imagem genérica do self-host é
* construída com `https://placeholder.invalid` (Dockerfile). Lendo direto do
* `process.env`, a tela mostrava essa URL — e quem a colasse no dashboard
* apontaria o webhook para o nada, sem erro em lugar nenhum.
* Separado do select principal de propósito: as três colunas chegam na migration
* 0311, e num banco sem ela o select inteiro voltaria 42703 — a tela perderia o
* canal (conectado, número, URL) por causa de um EXTRA. Aqui a ausência só significa
* "estado do registro indisponível".
*/
function publicBase(req: NextRequest): string {
const configurada = env.NEXT_PUBLIC_APP_URL;
const usavel = configurada && !configurada.includes("placeholder.invalid") ? configurada : null;
return (
usavel ?? req.headers.get("origin") ?? `${req.nextUrl.protocol}//${req.nextUrl.host}`
);
async function lerDesfechoDoWebhook(
admin: ReturnType<typeof createAdminClient>,
channelSessionId: string,
): Promise<DesfechoGravado | null> {
const { data, error } = await admin
.from("channel_sessions")
.select(COLUNAS_DO_DESFECHO_DO_WEBHOOK)
.eq("id", channelSessionId)
.maybeSingle();
if (error) return null;
return data as DesfechoGravado | null;
}
/**
@@ -124,7 +138,8 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
() => consultar().maybeSingle(),
);
const base = publicBase(req);
const base = basePublicaDaInstalacao(req);
const desfecho = data?.id ? await lerDesfechoDoWebhook(admin, data.id) : null;
return ok({
connected: Boolean(data),
channel_session_id: data?.id ?? null,
@@ -148,6 +163,21 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
fields: ["messages", "message_template_status_update"],
}
: null,
/**
* E o que a instalação já fez SOZINHA (fatia F1): o webhook deste número está
* registrado na Meta ou ainda não? `registrado: false` com `erro` é estado
* esperado e não falha da conexão — o canal ENVIA normalmente; o que depende
* disto é a ENTREGA. A tela mostra o motivo e oferece tentar de novo.
*/
webhookRegistro: data
? {
registrado:
Boolean(desfecho?.meta_webhook_override_uri) && !desfecho?.meta_webhook_override_erro,
url: desfecho?.meta_webhook_override_uri ?? null,
erro: desfecho?.meta_webhook_override_erro ?? null,
em: desfecho?.meta_webhook_override_em ?? null,
}
: null,
});
}
@@ -172,7 +202,15 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
// VALIDA ANTES DE GRAVAR — a rota não sabe com quem fala; ela pergunta se a
// credencial presta e o canal responde.
const validacao = await validateMetaCredentials({ phoneNumberId: phone_number_id, token });
//
// `wabaId` junto desde a fatia F1: a checagem do número sozinha aceita o par
// trocado (número de uma conta, id de outra), e o registro do webhook logo abaixo
// apontaria o override de um número que esta instalação não controla.
const validacao = await validateMetaCredentials({
phoneNumberId: phone_number_id,
token,
wabaId: waba_id,
});
if (!validacao.ok) {
return fail("invalid_request", validacao.motivo, 422, { requestId });
}
@@ -202,10 +240,14 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
.eq("provider", CHANNEL_PROVIDER_META)
.maybeSingle();
const { data: existenteRaw } = await queryTolerantToMissingArchived(
() => buscarExistente(`id, ${ARCHIVED_AT}`),
() => buscarExistente("id"),
() => buscarExistente(`id, ${ARCHIVED_AT}, webhook_path_token`),
() => buscarExistente("id, webhook_path_token"),
);
const existente = existenteRaw as { id: string; archived_at?: string | null } | null;
const existente = existenteRaw as {
id: string;
archived_at?: string | null;
webhook_path_token?: string | null;
} | null;
const linha = {
organization_id: orgId,
@@ -231,26 +273,44 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
// devolver a linha à vida, ou o canal fica "conectado" na tela e excluído para
// todo o resto do sistema. Para o canal que já estava ativo é um no-op — e a
// auditoria de volta sai de lá, junto da ressurreição, não daqui.
const { error } = existente
? await reactivateChannelSession(
admin,
{
organizationId: orgId,
channelSessionId: existente.id,
archivedAt: existente.archived_at ?? null,
},
linha,
{
userId: userId,
requestId,
metadata: { provider: CHANNEL_PROVIDER_META, phone_number: linha.phone_number },
},
)
: await admin.from("channel_sessions").insert({
let idDaSessao: string | null = existente?.id ?? null;
let webhookPathToken: string | null = existente?.webhook_path_token ?? null;
let error: { message?: string | null } | null = null;
if (existente) {
({ error } = await reactivateChannelSession(
admin,
{
organizationId: orgId,
channelSessionId: existente.id,
archivedAt: existente.archived_at ?? null,
},
linha,
{
userId: userId,
requestId,
metadata: { provider: CHANNEL_PROVIDER_META, phone_number: linha.phone_number },
},
));
} else {
// `select("id, webhook_path_token")` porque o registro do webhook logo abaixo
// precisa dos DOIS: o id para gravar o desfecho na mesma linha, e o token porque
// é ele que compõe a URL que a Meta vai chamar. O INSERT não os devolve sozinho,
// e reler a linha por (org, provider) seria uma segunda ida ao banco pelo dado
// que este INSERT acabou de criar.
const inserida = await admin
.from("channel_sessions")
.insert({
...linha,
webhook_secret_encrypted: cifrado,
metadata: metadataInicialDoCanal(),
});
})
.select("id, webhook_path_token")
.maybeSingle();
error = inserida.error;
idDaSessao = inserida.data?.id ?? null;
webhookPathToken = inserida.data?.webhook_path_token ?? null;
}
if (error) {
return fail("internal_error", error.message ?? "channel_session_write_failed", 500, {
@@ -258,9 +318,35 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
});
}
// ─── O webhook DESTE número, registrado pela própria instalação (fatia F1) ──
// DEPOIS de gravar, nunca antes: o GET de verificação da Meta chega no instante
// em que o override é registrado e procura a sessão pelo `webhook_path_token` —
// registrar antes de a linha existir devolveria 404 e a Meta marcaria o webhook
// como inválido, que é pior que não registrar.
//
// E o desfecho volta na RESPOSTA, não só no log: quem colou as credenciais precisa
// saber que o canal envia mas ainda não entrega, com o motivo em mãos.
const webhook =
idDaSessao && webhookPathToken
? await registrarWebhookDaSessao({
admin,
channelSessionId: idDaSessao,
phoneNumberId: phone_number_id,
wabaId: waba_id,
tokenCifrado: cifrado,
webhookPathToken,
base: basePublicaDaInstalacao(req),
requestId,
})
: null;
return ok({
connected: true,
displayName: linha.display_name,
phoneNumber: linha.phone_number,
/** `registrado: false` NÃO desfaz a conexão — o canal envia; falta a entrega. */
webhookRegistro: webhook
? { registrado: webhook.registrado, url: webhook.url, erro: webhook.erro, em: webhook.em }
: null,
});
}
@@ -0,0 +1,100 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* POST /api/v1/channels/official/webhook — "tentar de novo" o registro do webhook.
*
* Existe porque o registro automático (fatia F1, issue #850) pode falhar por motivo
* passageiro — token recém-gerado que ainda não propagou no lado da Meta, permissão
* que o operador estava ajustando no painel, rede. Sem esta rota, o único caminho de
* volta seria desconectar e reconectar o canal: derrubar o que funciona para consertar
* o que não funciona.
*
* ─── Ela NÃO pede a credencial de novo ───────────────────────────────────────
* O token já está cifrado na sessão; pedir de novo faria o operador procurar um token
* que ele talvez nem tenha mais à mão para repetir uma chamada que é do nosso lado. O
* que ela exige é a sessão VIVA (não arquivada) e um admin — a mesma guarda da tela.
*
* O desfecho é o da tentativa de AGORA, e é gravado nas colunas da 0311: a tela
* recarrega o GET e vê o estado novo, não o da tentativa anterior.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest, NextResponse } from "next/server";
import { fail, ok } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { ARCHIVED_AT, queryTolerantToMissingArchived } from "@/lib/channels/archived";
import { CHANNEL_PROVIDER_META } from "@/lib/channels/capabilities";
import { registrarWebhookDaSessao } from "@/lib/channels/meta/webhook-da-sessao";
import { createAdminClient } from "@/lib/supabase/admin";
import { basePublicaDaInstalacao } from "@/lib/webhooks/url-publica";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const COLUNAS =
"id, meta_phone_number_id, meta_waba_id, meta_token_encrypted, webhook_path_token";
export async function POST(req: NextRequest): Promise<NextResponse> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const authz = await requireRole("admin", { requestId, resource: "channels_official_webhook" });
if (!authz.ok) return authz.response;
const orgId = authz.org.orgId;
const admin = createAdminClient();
const consultar = () =>
admin
.from("channel_sessions")
.select(COLUNAS)
.eq("organization_id", orgId)
.eq("provider", CHANNEL_PROVIDER_META);
// Só a sessão VIVA: reaplicar o webhook de um canal arquivado apontaria a Meta para
// uma URL cujo `webhook_path_token` já foi rotacionado — ou seja, registraria uma
// entrega que ninguém atende. Canal arquivado se reconecta, não se "conserta".
const { data } = await queryTolerantToMissingArchived(
() => consultar().is(ARCHIVED_AT, null).maybeSingle(),
() => consultar().maybeSingle(),
);
const sessao = data as {
id: string;
meta_phone_number_id: string | null;
meta_waba_id: string | null;
meta_token_encrypted: string | null;
webhook_path_token: string | null;
} | null;
if (!sessao || !sessao.meta_phone_number_id || !sessao.meta_waba_id || !sessao.meta_token_encrypted) {
return fail("invalid_request", "no_meta_channel", 422, { requestId });
}
if (!sessao.webhook_path_token) {
// Sessão sem token de caminho é sessão que a migration 0099 (ou o gerador do
// INSERT) não alcançou: sem ele não há URL a registrar. Dizer isso é melhor que
// mandar a Meta chamar `/api/v1/webhooks/meta/null`.
return fail("invalid_request", "channel_without_webhook_path", 422, { requestId });
}
const desfecho = await registrarWebhookDaSessao({
admin,
channelSessionId: sessao.id,
phoneNumberId: sessao.meta_phone_number_id,
wabaId: sessao.meta_waba_id,
tokenCifrado: sessao.meta_token_encrypted,
webhookPathToken: sessao.webhook_path_token,
base: basePublicaDaInstalacao(req),
requestId,
});
// 200 mesmo quando a Meta recusou: a TENTATIVA foi feita e o desfecho é um estado
// (com motivo), não um erro de requisição. A tela pinta o aviso com `erro` — um 4xx
// aqui faria o cliente tratar como "deu erro" e esconder o motivo que a Meta deu.
return ok({
registrado: desfecho.registrado,
url: desfecho.url,
erro: desfecho.erro,
em: desfecho.em,
callbackUrl: `${basePublicaDaInstalacao(req)}/api/v1/webhooks/meta/${sessao.webhook_path_token}`,
});
}
+7 -19
View File
@@ -29,9 +29,9 @@ import {
savePartnerSession,
validatePartnerCredentials,
} from "@/lib/channels/connect";
import { env } from "@/lib/env";
import { createAdminClient } from "@/lib/supabase/admin";
import { encryptWebhookSecret } from "@/lib/webhooks/secrets";
import { basePublicaDaInstalacao } from "@/lib/webhooks/url-publica";
import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
@@ -45,26 +45,14 @@ const conectarSchema = z.object({
/**
* Endereço público desta instalação — é o que o operador cola no provedor.
*
* `env.*` e NÃO `process.env.NEXT_PUBLIC_APP_URL` direto: variáveis
* `NEXT_PUBLIC_` são substituídas no BUILD, e a imagem genérica do self-host é
* construída com `https://placeholder.invalid` (Dockerfile). Lendo direto do
* `process.env`, a tela mostrava essa URL — e quem a colasse no provedor
* apontaria o webhook para o nada, sem nenhum erro em lugar nenhum. `env.*`
* parseia em runtime, então a imagem serve qualquer domínio.
*
* O host da requisição é o fallback: numa instalação que esqueceu a variável,
* o endereço por onde a tela está sendo servida é a melhor pista que existe —
* e melhor que um placeholder que não resolve.
* A base sai de `lib/webhooks/url-publica` desde a fatia F1 da #850: a mesma
* pergunta ("qual é o endereço público desta instalação?") era respondida em três
* rotas, e a resposta divergente entre elas é um webhook que a tela promete num
* endereço e o produto atende em outro. O porquê do `env.*` no lugar do
* `process.env.NEXT_PUBLIC_APP_URL` direto está documentado lá.
*/
function urlDoWebhook(req: NextRequest, token: string): string {
const configurada = env.NEXT_PUBLIC_APP_URL;
const usavel = configurada && !configurada.includes("placeholder.invalid") ? configurada : null;
const base = (
usavel ??
req.headers.get("origin") ??
`${req.nextUrl.protocol}//${req.nextUrl.host}`
).replace(/\/+$/, "");
return `${base}/api/v1/webhooks/channel/${token}`;
return `${basePublicaDaInstalacao(req)}/api/v1/webhooks/channel/${token}`;
}
export async function GET(req: NextRequest): Promise<NextResponse> {
+41 -4
View File
@@ -114,9 +114,9 @@ async function removerEcoDoProprioEnvio(
.in("external_id", candidatos)
// ⚠️ SEGUNDA CAMADA, SEM COBERTURA POSSÍVEL — escrito porque medi: trocar
// este `neq` por um que nunca casa deixa a suíte VERDE. O filtro de
// `sent_via` acima já exclui a linha deste envio (que nasce `user`/`ai`,
// nunca `external_device`), então nenhum teste alcança esta cláusula.
// Fica porque o desfecho que ela impede é o pior que esta função poderia
// `sent_via` acima já exclui a linha deste envio (que nasce `user`, `ai`
// ou `automation`, nunca `external_device`), então nenhum teste alcança
// esta cláusula. Fica porque o desfecho que ela impede é o pior que esta função poderia
// produzir: apagar a própria mensagem que acabou de ser entregue. Quem
// mexer no filtro de cima não vai ser avisado por teste nenhum.
.neq("id", minhaLinhaId);
@@ -130,6 +130,43 @@ async function removerEcoDoProprioEnvio(
}
}
/**
* De quem é esta linha, no vocabulário de `messages.sent_via`.
*
* A pergunta era UMA só (`!== "user"`), e por isso a automação se apresentava
* como IA: tudo que não era pessoa saía `'ai'`, inclusive um template fixo de
* regra — sem IA nenhuma no caminho. A decisão do mantenedor na #652 é
* categoria própria para "nem pessoa nem IA", e `'automation'` é o valor que o
* CHECK de `messages.sent_via` já aceitava e que o balão sabe nomear.
*
* `api_token` (integração com token de servidor) segue `'ai'` de propósito: a
* #866 decide o valor daquele caminho, e trocar aqui sem aquele PR misturaria
* duas decisões numa linha.
*
* Quem lê estes valores: o filtro de eco da ingestão do canal (a lista anda
* junto), o resgate da fila (`session-reconciler.ts`), a métrica de atrito e o
* rótulo do balão (`components/inbox/MessageBubble.tsx`).
*/
export function origemDaMensagem(actor: Actor): "user" | "ai" | "automation" {
if (actor.type === "user") return "user";
// A regra dispara, mas nem sempre ESCREVE. A ação "Mensagem escrita pela IA"
// manda texto de um agente publicado com este mesmo ator, e a decisão da #652
// é por AUTORIA: ali a linha é da IA. Decidir só pelo tipo do ator carimbaria
// "Automação" no balão e tiraria a mensagem de `envios_por_ia`.
if (actor.type === "webhook_source") {
// Os dois retornos são LITERAIS de propósito: `rotulo-de-origem-tem-emissor`
// lê o corpo desta função e conta como emissor cada literal devolvido, para
// saber quais rótulos o motor de fato produz. Escrito como ternário, o gate
// deixa de enxergar `automation` e acusa a tela de prometer uma distinção
// que ninguém grava — foi o que aconteceu na primeira versão deste conserto.
// (E o comentário não pode conter a forma que o extrator procura: a segunda
// versão trazia um exemplo literal aqui, e o gate o leu como emissor real.)
if (actor.textoEscritoPelaIA) return "ai";
return "automation";
}
return "ai";
}
const MSG_COLS =
"id, organization_id, conversation_id, channel_session_id, contact_id, external_id, type, direction, status, ack, error_code, error_message, body, media_url, media_mime, media_size_bytes, media_storage_path, sent_via, sent_by_user_id, sent_at, delivered_at, read_at, metadata, edited_at, revoked_at, reply_to_message_id, created_at";
@@ -551,7 +588,7 @@ export async function sendMessageHandler(
media_mime: input.media_mime ?? null,
media_storage_path: input.media_storage_path ?? null,
media_size_bytes: input.media_size_bytes ?? null,
sent_via: ctx.actor.type !== "user" ? ("ai" as const) : ("user" as const),
sent_via: origemDaMensagem(ctx.actor),
sent_by_user_id: ctx.actor.type === "user" ? ctx.actor.id : null,
sent_at: now,
metadata: {
+1
View File
@@ -79,6 +79,7 @@ const VAZIO: Omit<AtritoRaw, "escopo"> = {
vetos: 0,
execucoes_medidas: 0,
envios_por_ia: 0,
envios_por_automacao: 0,
envios_humano_no_sistema: 0,
envios_humano_fora: 0,
demandas_sem_proximo_passo: 0,
@@ -35,9 +35,15 @@ const ERRO_EM_PORTUGUES: Record<string, string> = {
export function FormularioDeConversoesGoogle({
estado,
idioma,
configurado,
falta,
}: {
estado: EstadoDaConexaoGoogle;
idioma: Idioma;
/** A instalação tem as três variáveis do Google Ads? Ver `config.ts`. */
configurado: boolean;
/** O que falta, PELO NOME — para a tela dizer em vez de só esconder o botão. */
falta: string[];
}) {
const t = (texto: string) => traduzir(texto, idioma);
const router = useRouter();
@@ -70,6 +76,37 @@ export function FormularioDeConversoesGoogle({
});
}
/*
* Sem as credenciais da INSTALAÇÃO, o botão não existe — mesmo quando a
* organização já conectou antes: sem elas o envio recusa toda venda
* (`conversions.ts`), e mostrar o formulário diria que está tudo de pé. O
* molde é o cartão da Agenda (`CartaoDaConexaoGoogle`): não é "você não
* pode", é "esta instalação ainda não tem", e quem lê pode repassar o que
* falta a quem instalou.
*/
if (!configurado) {
return (
<Card className="p-6" data-testid="google-ads-nao-configurado">
<div className="flex flex-col gap-2">
<h3 className="font-medium">{t("Google Ads")}</h3>
<p className="text-sm text-muted-foreground">
{t("Enviar vendas para o Google Ads ainda não está disponível nesta instalação — não é nada que você tenha feito. Quem instalou o sistema precisa configurar")}
{falta.length > 0 ? (
<>
{" "}
<span data-testid="google-ads-o-que-falta" className="font-mono text-xs">
{falta.join(` ${t("e")} `)}
</span>
</>
) : (
` ${t("as credenciais")}`
)}
</p>
</div>
</Card>
);
}
if (!estado.temRefreshToken) {
return (
<Card className="p-6">
+10 -1
View File
@@ -46,6 +46,10 @@ import {
TAMANHO_MAXIMO_DO_CODIGO,
} from "@/lib/leads/origem-do-site";
import { formatCentsBRL } from "@/lib/money";
import {
faltaParaConectarOGoogleAds,
googleAdsEstaConfigurado,
} from "@/lib/plataformas-de-anuncio/google/config";
import { lerEstadoDaConexaoGoogle } from "@/lib/plataformas-de-anuncio/google/estado-da-conexao";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -142,7 +146,12 @@ export default async function ConversoesPage({
)}
<FormularioDeConversoes estado={estado} idioma={idioma} />
<FormularioDeConversoesGoogle estado={estadoGoogle} idioma={idioma} />
<FormularioDeConversoesGoogle
estado={estadoGoogle}
idioma={idioma}
configurado={googleAdsEstaConfigurado()}
falta={faltaParaConectarOGoogleAds()}
/>
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between">
+48 -5
View File
@@ -11,6 +11,7 @@ import { Label } from "@/components/ui/label";
import {
useConnectOfficialChannel,
useOfficialChannel,
useRegistrarWebhookOficial,
} from "@/hooks/channels/useOfficialChannel";
import { copyToClipboard } from "@/lib/clipboard";
import { useT } from "@/hooks/i18n/useT";
@@ -64,6 +65,7 @@ export function CanalOficialClient() {
const t = useT();
const { data, isPending } = useOfficialChannel();
const conectar = useConnectOfficialChannel();
const registrarWebhook = useRegistrarWebhookOficial();
const [form, setForm] = useState({ phone_number_id: "", waba_id: "", token: "" });
const estado = data?.data;
@@ -107,14 +109,55 @@ export function CanalOficialClient() {
{estado?.webhook ? (
<Card className="flex flex-col gap-3 p-4">
<div>
<h2 className="font-medium">{t("Cole isto no painel da Meta")}</h2>
<h2 className="font-medium">
{estado.webhookRegistro?.registrado
? t("Webhook registrado pela instalação")
: t("Cole isto no painel da Meta")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("Em")} <strong>WhatsApp → {t("Configuração")}</strong>
{t(", na seção de Webhook. Sem esse passo o canal envia, mas")}{" "}
<strong>{t("não recebe")}</strong>
{t(" — as respostas do cliente não chegam e a janela de 24 horas nunca abre.")}
{estado.webhookRegistro?.registrado ? (
t(
"O CRM apontou o webhook deste número para cá — não é preciso colar nada no painel da Meta. Os valores abaixo ficam para conferência.",
)
) : (
<>
{t("Em")} <strong>WhatsApp → {t("Configuração")}</strong>
{t(", na seção de Webhook. Sem esse passo o canal envia, mas")}{" "}
<strong>{t("não recebe")}</strong>
{t(" — as respostas do cliente não chegam e a janela de 24 horas nunca abre.")}
</>
)}
</p>
</div>
{/*
O aviso só aparece quando o CRM TENTOU registrar e não conseguiu. Nulo
(banco sem a migration 0311) cai no passo manual acima, que continua
verdadeiro — a tela nunca diz "registrado" sem ter registrado.
*/}
{estado.webhookRegistro && !estado.webhookRegistro.registrado ? (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
<Badge
variant="outline"
className="border-amber-500/60 font-normal text-amber-700 dark:text-amber-400"
>
{t("Webhook pendente")}
</Badge>
<span className="text-sm">
{estado.webhookRegistro.erro ?? t("o registro ainda não foi feito")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => registrarWebhook.mutate()}
disabled={registrarWebhook.isPending}
>
{registrarWebhook.isPending ? t("Tentando…") : t("Tentar de novo")}
</Button>
</div>
) : null}
<ParaColar rotulo={t("URL de callback")} valor={estado.webhook.callbackUrl} />
<ParaColar
rotulo={t("Token de verificação")}
+7 -2
View File
@@ -64,9 +64,14 @@ describe("MessageBubble — rótulo de origem", () => {
expect(screen.getByText("Celular")).toBeInTheDocument();
});
it("automação não inventa rótulo — ninguém grava esse valor", () => {
it("automação tem rótulo próprio — o motor passou a gravar esse valor (#652)", () => {
// Até a #652 ninguém carimbava `'automation'` — tudo que não era pessoa saía
// `'ai'` —, e este caso prendia o rótulo AUSENTE: a tela não podia oferecer
// uma distinção que o motor não fazia. Com o carimbo em `origemDaMensagem`,
// o rótulo ganhou emissor e o caso inverte de lado. O par continua vigiado
// nas duas direções por tests/unit/rotulo-de-origem-tem-emissor.test.ts.
render(<MessageBubble message={msg({ sent_via: "automation" })} />);
expect(screen.queryByText("Automação")).not.toBeInTheDocument();
expect(screen.getByText("Automação")).toBeInTheDocument();
});
it("digitada no CRM por QUEM ESTÁ LENDO mostra 'Você'", () => {
+7 -10
View File
@@ -80,19 +80,16 @@ export function MessageBubble({
// sem nome: o dono lia a conversa como se tudo tivesse sido digitado no CRM.
// Os rótulos passam por t() no render (ver dicionario.ts para o espanhol).
//
// NÃO HÁ RAMO PARA `'automation'`. O CHECK do banco aceita o valor e o union
// de `Message` o declara, mas nenhuma linha de app/, lib/ ou workers/ o
// grava: as ações de automação chamam `sendMessageHandler` com
// `actor.type === "webhook_source"`, e `_handler.ts` carimba `'ai'` em tudo
// que não é `"user"`. Um ramo aqui seria controle decorativo — a tela
// prometendo uma distinção que o motor não faz. Carimbar `'automation'` na
// origem é decisão de produto com efeito colateral medido (o dedup de eco da
// ingestão de canal filtra `sent_via in ('ai','user')`, e o valor novo
// duplicaria a mensagem na conversa), então fica para uma issue própria.
// Vigiado nas duas direções por tests/unit/rotulo-de-origem-tem-emissor.
// `'automation'` é a categoria de quem não é pessoa nem IA: regra de
// automação, texto fixo do follow-up e lembrete de agenda (#652, decidida pelo
// mantenedor em 16/09). Enquanto ninguém gravava o valor, um ramo aqui seria
// controle decorativo — a tela oferecendo uma distinção que o motor não fazia.
// O carimbo vive em `origemDaMensagem` (`app/api/v1/messages/_handler.ts`) e o
// par é vigiado nas duas direções por tests/unit/rotulo-de-origem-tem-emissor.
const senderLabel = (() => {
if (!isOutbound) return null;
if (message.sent_via === "ai") return "IA";
if (message.sent_via === "automation") return "Automação";
if (message.sent_via === "external_device") return "Celular";
if (message.sent_via === "user" || message.sent_via === "crm") {
// "Você" exige as DUAS pontas: saber quem lê e saber quem enviou. Falta
+6 -4
View File
@@ -147,10 +147,12 @@ plataforma** (`meta_ads`, o vocabulário que a 0164 já criou para a atribuiçã
importa o transporte. Provado por construção: plantar `graph.facebook.com` em
`lib/conversoes/` reprova o `pnpm lint:channels`.
E o invariante 4 vale igual neste eixo. `google_ads` está **declarado sem transporte** no
registro — não ausente. A lacuna é anterior: sem extrator de `gclid` não há clique capturado
para reportar. Declarada, ela vira `skipped: 'plataforma_sem_transporte'` no livro-razão, que
a tela mostra; omitida, viraria `undefined` e o chamador a trataria como bug.
E o invariante 4 vale igual neste eixo. Uma plataforma do vocabulário que ainda não tem
transporte fica **declarada no registro com `null`** — não ausente. Declarada, ela vira
`skipped: 'plataforma_sem_transporte'` no livro-razão, que a tela mostra; omitida, viraria
`undefined` e o chamador a trataria como bug. Hoje nenhuma está nesse estado — `meta_ads` e
`google_ads` têm transporte (o do Google desde as migrations 0306/0307); para conferir sem
confiar nesta linha: `grep -n "TRANSPORTES" -A4 lib/plataformas-de-anuncio/registry.ts`.
---
+42
View File
@@ -27,6 +27,20 @@ export interface OfficialChannelState {
configurarEm?: string | null;
fields: string[];
} | null;
/**
* O que a instalação já fez SOZINHA com o webhook deste número (fatia F1 da #850):
* a Meta foi apontada para o endereço desta sessão, ou ainda não.
*
* `registrado: false` NÃO é canal quebrado: ele envia normalmente; o que depende
* disto é a ENTREGA. Nulo = banco sem a migration 0311 (a tela volta ao passo
* manual, que é o estado anterior — e continua verdadeiro).
*/
webhookRegistro: {
registrado: boolean;
url: string | null;
erro: string | null;
em: string | null;
} | null;
}
export interface ConnectInput {
@@ -35,6 +49,14 @@ export interface ConnectInput {
token: string;
}
export interface RegistroDoWebhook {
registrado: boolean;
url: string | null;
erro: string | null;
em: string;
callbackUrl: string;
}
export function useOfficialChannel() {
return useQuery({
queryKey: ["official-channel"],
@@ -57,3 +79,23 @@ export function useConnectOfficialChannel() {
},
});
}
/**
* "Tentar de novo" o registro do webhook — sem pedir a credencial outra vez.
*
* A rota responde 200 mesmo quando a Meta recusa (o motivo vem em `erro`), e é de
* propósito: aqui o que interessa é o MOTIVO na tela. Por isso não há toast de erro
* genérico no sucesso — a invalidação recarrega o estado e o aviso âmbar com o motivo
* fica onde o operador pode lê-lo, em vez de desaparecer em três segundos.
*/
export function useRegistrarWebhookOficial() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
apiClient.post<{ data: RegistroDoWebhook }>("/api/v1/channels/official/webhook", {}),
onError: showApiError,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["official-channel"] });
},
});
}
+20 -8
View File
@@ -475,20 +475,30 @@ else
# contagem direta no install.sh, de um processo só — sem pipeline, logo sem
# leitura parcial. Batendo, a régua está inteira e as acusações abaixo têm
# chão; divergindo, ou voltando o pipeline acima com status ≠ 0, o desfecho é
# INCONCLUSIVO — nunca "chave faltando". Para ver quantas há hoje:
# grep -cE '^[[:space:]]*envq [A-Z_0-9]+' hostgator-setup-kit/install.sh
# INCONCLUSIVO — nunca "chave faltando". A contagem direta é por CHAVE ÚNICA,
# como a lista: `envq DOMAIN` escrito em dois ramos de um `if` é uma chave só,
# e contar linhas acusaria régua truncada para sempre com a régua inteira.
#
# A contagem dupla fecha a truncagem, mas não era ela a causa das acusações
# soltas da issue #1153: as rodadas registradas acusaram uma ou duas chaves
# espalhadas, e uma régua truncada perde a CAUDA — para deixar de fora aquelas
# chaves teria de acusar de 14 a 54 ao mesmo tempo. O que produz uma acusação
# solta é a checagem POR CHAVE, que era um `printf | grep -qx` por chave: um
# processo por chave, que pode falhar sozinho sob carga, lido pelo `&&` como
# "ausente" para QUALQUER status ≠ 0. `na_regua` responde a pertença sem abrir
# processo nenhum. Qual falha do sistema devolvia o status ≠ 0 não foi medido
# (carga ~17: 0 em 6000); o conserto não depende de saber.
na_regua() { case $'\n'"$GRAVA"$'\n' in *$'\n'"$1"$'\n'*) return 0 ;; esac; return 1; }
n_grava="$(printf '%s\n' "$GRAVA" | grep -c . || true)"
n_real="$(grep -cE '^[[:space:]]*envq [A-Z_0-9]+' install.sh || true)"
n_real="$(awk 'match($0, /^[[:space:]]*envq [A-Z_0-9]+/) { k = substr($0, RSTART, RLENGTH); sub(/^[[:space:]]*envq /, "", k); u[k] = 1 } END { n = 0; for (k in u) n++; print n }' install.sh)" || regua_quebrou=1
if [ "${regua_quebrou:-0}" -ne 0 ] || [ "${n_grava:-0}" -ne "${n_real:-0}" ]; then
printf ' ✗ a lista de escrita voltou com %s chave(s) contra %s na contagem direta — a régua está truncada, não o install.sh\n' "${n_grava:-0}" "${n_real:-0}"
printf ' (cenário INCONCLUSIVO: sem régua inteira, toda acusação abaixo seria falsa)\n'
fail=1
GRAVA=""
novas=""
else
novas=""
for k in $(grep -oE '^[A-Z_0-9]+=' "$EXEMPLO" | tr -d '=' | sort -u); do
printf '%s\n' "$GRAVA" | grep -qx "$k" && continue
na_regua "$k" && continue
case " $DIVIDA " in *" $k "*) continue ;; esac
novas="$novas $k"
done
@@ -499,10 +509,11 @@ else
else
printf ' ✓ nenhuma chave nova fora da lista de escrita\n'
fi
fi
# Só com a régua inteira: no ramo inconclusivo, conferir a dívida contra uma
# régua que não temos imprimiria um ✓ calculado sobre nada.
estagnada=""
for k in $DIVIDA; do
printf '%s\n' "$GRAVA" | grep -qx "$k" && estagnada="$estagnada $k"
na_regua "$k" && estagnada="$estagnada $k"
done
if [ -n "$estagnada" ]; then
printf ' ✗ já é gravada pelo install.sh — tire da lista DÍVIDA deste teste:%s\n' "$estagnada"
@@ -510,6 +521,7 @@ else
else
printf ' ✓ dívida ainda condiz (%s chaves conhecidas, só pode encolher)\n' "$(printf '%s' "$DIVIDA" | wc -w | tr -d ' ')"
fi
fi
fi
echo "resposta afirmativa (resposta_sim)"
+54
View File
@@ -0,0 +1,54 @@
# Imagem de UMA vaga do executor próprio. Cada job roda num contêiner novo desta
# imagem (runtime sysbox-runc) e o contêiner é destruído ao fim do job: nada que
# um job escreve sobrevive para o seguinte.
#
# O que está aqui é o que os jobs pesados pressupõem do `ubuntu-latest` do GitHub
# (medido nos workflows em 18/09/2026): docker + buildx + compose (e2e sobe o
# Supabase local, invariants sobe pgvector, imagens constroem), psql/pg_dump 17
# (o baseline usa GRANT MAINTAIN, pg17+), jq, gh, git, zstd (actions/cache) e
# sudo sem senha (`playwright install --with-deps` chama apt-get).
FROM ubuntu:24.04
ARG RUNNER_VERSION=2.337.0
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git jq unzip zip zstd xz-utils sudo tzdata \
python3 openssl procps iptables build-essential lsb-release \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu noble stable" > /etc/apt/sources.list.d/docker.list \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/keyrings/pgdg.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] https://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli.gpg \
&& echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/githubcli.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
postgresql-client-17 gh \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash runner \
&& usermod -aG docker runner \
&& echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner \
&& chmod 0440 /etc/sudoers.d/runner
WORKDIR /home/runner/actions-runner
RUN curl -fsSL -o /tmp/runner.tgz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" \
&& tar xzf /tmp/runner.tgz \
&& rm /tmp/runner.tgz \
&& ./bin/installdependencies.sh \
&& chown -R runner:runner /home/runner
COPY so-o-que-e-nosso.sh entrypoint.sh /opt/deskcomm/
RUN chmod 0755 /opt/deskcomm/*.sh \
# A guarda é lida pelo runner a partir do `.env` do diretório dele (método
# documentado). Arquivo do root: o job só roda DEPOIS da guarda, e o contêiner
# morre ao fim do job, então nada o reescreve para o próximo.
&& echo "ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/deskcomm/so-o-que-e-nosso.sh" > /home/runner/actions-runner/.env \
&& chown root:root /home/runner/actions-runner/.env \
&& chmod 0644 /home/runner/actions-runner/.env
ENTRYPOINT ["/opt/deskcomm/entrypoint.sh"]
+50
View File
@@ -0,0 +1,50 @@
# Executor próprio do CI
Uma máquina nossa que roda os jobs pesados do trabalho **nosso** — push na `main` e PR de
branch deste repositório — para a fila do GitHub ficar para quem contribui de fora.
Não muda nada até a variável de repositório `EXECUTOR_PROPRIO` valer `ligado`
(Settings → Secrets and variables → Actions → Variables). Apagar a variável é o botão de
emergência: os jobs novos voltam na hora para as máquinas do GitHub.
## Peças
| arquivo | o que é |
|---|---|
| `instalar.sh` | instala Docker + Sysbox numa Ubuntu 24.04 amd64 dedicada, pede o token, liga as vagas e a atualização semanal |
| `vaga.sh` | uma vaga: pede ao GitHub um runner JIT (um job, uso único), roda um contêiner limpo com ele, repete |
| `Dockerfile` | a imagem da vaga — o que os jobs pressupõem do `ubuntu-latest` |
| `entrypoint.sh` | sobe o Docker de dentro da vaga e entrega ao runner |
| `so-o-que-e-nosso.sh` | a guarda de entrada: recusa todo job que não seja nosso, antes do primeiro passo |
## Por que a guarda mora aqui
O repositório é público, e num `pull_request` de fork o GitHub roda o workflow **do fork** — que
pode reescrever `runs-on:` para mirar esta máquina. A expressão de `runs-on` dos nossos workflows
só decide para quem não a edita. A guarda é gravada na imagem e registrada como
`ACTIONS_RUNNER_HOOK_JOB_STARTED`; script de entrada que falha faz o job não rodar.
Aceita: `push`, `workflow_dispatch`, `schedule`, `merge_group`, e `pull_request` cuja branch mora
neste repositório. Recusa o resto, inclusive `pull_request_target` e payload ilegível.
## Por que uma vaga por contêiner (Sysbox)
O `e2e` sobe o Supabase em portas fixas (54321/54322) e o teste de imagem usa `:3000` e nomes fixos
de contêiner: dois jobs no mesmo Docker colidem. Cada vaga tem o próprio Docker e a própria rede,
sem `--privileged`, e o contêiner é descartado ao fim do job.
## Tamanho
Uma vaga = 4 núcleos e 14 GB, como o `ubuntu-latest`. Medido em 15–18/09/2026: o trabalho nosso
ocupava ~5 jobs em média e mais de 10 em 17% do tempo (sob o teto de 20 da época, então a demanda
real é maior). 8 vagas cobrem os picos.
## Operação
```bash
journalctl -u deskcomm-vaga@1 -f # log de uma vaga
sudo bash /opt/deskcomm-executor/instalar.sh atualizar # reconstrói a imagem agora
systemctl list-timers deskcomm-executor-atualizar.timer # próxima atualização automática
```
Vigiado por `tests/unit/executor-proprio-so-roda-o-que-e-nosso.test.ts`.
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Sobe o Docker de DENTRO da vaga (o sysbox isola um daemon por contêiner, sem
# --privileged) e entrega o controle ao runner do GitHub para exatamente UM job.
# A configuração JIT chega por variável e sai do ambiente antes do runner
# começar: o job herda o ambiente do processo do runner.
set -euo pipefail
cfg="${JIT_CONFIG:-}"
unset JIT_CONFIG
[ -n "$cfg" ] || { echo "entrypoint: JIT_CONFIG vazio — nada a fazer" >&2; exit 1; }
dockerd >/var/log/dockerd.log 2>&1 &
for _ in $(seq 1 60); do
docker info >/dev/null 2>&1 && break
sleep 1
done
docker info >/dev/null 2>&1 || { echo "entrypoint: dockerd não subiu em 60s" >&2; tail -50 /var/log/dockerd.log >&2; exit 1; }
cd /home/runner/actions-runner
exec runuser -u runner -- ./run.sh --jitconfig "$cfg"
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Instala o executor próprio do DeskcommCRM numa máquina Ubuntu 24.04 amd64
# DEDICADA a isso. Rode como root, de dentro de um clone do repositório:
#
# sudo bash infra/executor-proprio/instalar.sh # instala ou reinstala
# sudo bash infra/executor-proprio/instalar.sh atualizar # reconstrói a imagem
#
# O que ele faz, e nada além disso:
# 1. instala Docker e Sysbox (Docker isolado dentro de cada vaga);
# 2. pede o token do GitHub e o número de vagas, e grava em /etc/deskcomm-executor;
# 3. constrói a imagem da vaga com a versão mais nova do runner do GitHub;
# 4. liga N serviços `deskcomm-vaga@N` e um timer semanal que reconstrói a imagem.
#
# Não liga nada no GitHub. Os jobs só vêm para cá quando a variável de
# repositório EXECUTOR_PROPRIO valer `ligado` (tutorial: pasta de decisões, doc 39).
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
CONF=/etc/deskcomm-executor
DESTINO=/opt/deskcomm-executor
SYSBOX_VERSAO=0.7.1
falha() { echo "ERRO: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || falha "rode com sudo"
[ "$(uname -m)" = x86_64 ] || falha "a máquina precisa ser amd64 (x86_64); esta é $(uname -m)"
# shellcheck source=/dev/null
. /etc/os-release
[ "${VERSION_ID:-}" = "24.04" ] || echo "AVISO: testado em Ubuntu 24.04; esta é ${PRETTY_NAME:-desconhecida}" >&2
construir_imagem() {
local versao
versao=$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/^v//')
[ -n "$versao" ] && [ "$versao" != null ] || falha "não consegui ler a versão do runner no GitHub"
echo "==> construindo a imagem da vaga (runner ${versao})"
docker build --pull --build-arg "RUNNER_VERSION=${versao}" -t deskcomm-executor:atual "$DIR"
# Imagens antigas desta mesma tag viram <none>; limpa sem tocar em nada em uso.
docker image prune -f >/dev/null
}
if [ "${1:-}" = atualizar ]; then
construir_imagem
echo "==> imagem atualizada. As vagas usam a nova a partir do próximo job."
exit 0
fi
echo "==> pacotes base"
apt-get update -q
apt-get install -y -q ca-certificates curl jq git
if ! command -v docker >/dev/null; then
echo "==> instalando Docker"
curl -fsSL https://get.docker.com | sh
fi
if ! command -v sysbox-runc >/dev/null; then
echo "==> instalando Sysbox ${SYSBOX_VERSAO}"
# O instalador do sysbox reinicia o Docker e exige que não haja contêiner rodando.
deb="/tmp/sysbox-ce_${SYSBOX_VERSAO}.linux_amd64.deb"
curl -fsSL -o "$deb" "https://github.com/nestybox/sysbox/releases/download/v${SYSBOX_VERSAO}/sysbox-ce_${SYSBOX_VERSAO}.linux_amd64.deb"
apt-get install -y -q "$deb"
rm -f "$deb"
fi
systemctl is-active --quiet sysbox || falha "o serviço sysbox não está ativo (systemctl status sysbox)"
mkdir -p "$CONF"
chmod 0700 "$CONF"
if [ ! -s "$CONF/token" ]; then
echo
echo "Cole o token do GitHub (fine-grained, só o repositório DeskcommCRM,"
echo "permissão 'Administration: Read and write'). Ele não aparece na tela:"
read -r -s token
echo
[ -n "$token" ] || falha "token vazio"
umask 077
printf '%s' "$token" > "$CONF/token"
fi
# Confere o token AGORA, em vez de descobrir pelo log de uma vaga parada.
codigo=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $(cat "$CONF/token")" \
"https://api.github.com/repos/melgarafael/DeskcommCRM/actions/runners")
[ "$codigo" = 200 ] || falha "o token não lê os runners do repositório (HTTP ${codigo}). Confira a permissão Administration."
echo "melgarafael/DeskcommCRM" > "$CONF/repo"
nucleos=$(nproc)
memoria_gb=$(awk '/MemTotal/ {printf "%d", $2/1024/1024}' /proc/meminfo)
sugestao=$(( nucleos / 4 ))
[ $(( memoria_gb / 14 )) -lt "$sugestao" ] && sugestao=$(( memoria_gb / 14 ))
[ "$sugestao" -ge 1 ] || falha "máquina pequena demais: ${nucleos} núcleos e ${memoria_gb} GB (uma vaga pede 4 núcleos e 14 GB)"
vagas_atual=$(cat "$CONF/vagas" 2>/dev/null || echo "$sugestao")
echo
echo "Esta máquina tem ${nucleos} núcleos e ${memoria_gb} GB: cabem ${sugestao} vaga(s) de 4 núcleos / 14 GB."
read -r -p "Quantas vagas ligar? [${vagas_atual}] " vagas
vagas="${vagas:-$vagas_atual}"
[[ "$vagas" =~ ^[0-9]+$ ]] && [ "$vagas" -ge 1 ] || falha "número de vagas inválido"
echo "$vagas" > "$CONF/vagas"
echo 4 > "$CONF/cpus-por-vaga"
echo 14g > "$CONF/memoria-por-vaga"
construir_imagem
echo "==> serviços"
install -d "$DESTINO"
install -m 0755 "$DIR/vaga.sh" "$DESTINO/vaga.sh"
install -m 0755 "$DIR/instalar.sh" "$DESTINO/instalar.sh"
cp "$DIR/Dockerfile" "$DIR/entrypoint.sh" "$DIR/so-o-que-e-nosso.sh" "$DESTINO/"
cat > /etc/systemd/system/deskcomm-vaga@.service <<EOF
[Unit]
Description=DeskcommCRM — vaga %i do executor próprio
After=docker.service sysbox.service network-online.target
Requires=docker.service sysbox.service
[Service]
ExecStart=${DESTINO}/vaga.sh %i
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/deskcomm-executor-atualizar.service <<EOF
[Unit]
Description=DeskcommCRM — reconstrói a imagem do executor (runner novo + pacotes)
[Service]
Type=oneshot
ExecStart=${DESTINO}/instalar.sh atualizar
EOF
cat > /etc/systemd/system/deskcomm-executor-atualizar.timer <<EOF
[Unit]
Description=DeskcommCRM — atualização semanal do executor
[Timer]
OnCalendar=Sun 04:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
# Desliga vagas acima do número novo (reinstalação com menos vagas).
for unidade in $(systemctl list-units --all --plain --no-legend 'deskcomm-vaga@*' | awk '{print $1}'); do
n="${unidade#deskcomm-vaga@}"; n="${n%.service}"
[ "$n" -le "$vagas" ] || systemctl disable --now "$unidade"
done
for n in $(seq 1 "$vagas"); do
systemctl enable --now "deskcomm-vaga@${n}"
done
systemctl enable --now deskcomm-executor-atualizar.timer
echo
echo "==> pronto: ${vagas} vaga(s) ligada(s)."
echo "Confira em https://github.com/melgarafael/DeskcommCRM/settings/actions/runners"
echo "(cada vaga aparece como deskcomm-vaga-N-<hora>, status Idle)."
echo "Logs de uma vaga: journalctl -u deskcomm-vaga@1 -f"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Guarda de entrada do executor próprio: roda ANTES de todo job, e job que ela
# recusa não executa nenhum passo (exit != 0 → "the job will not run and will be
# marked as failed", docs do GitHub sobre ACTIONS_RUNNER_HOOK_JOB_STARTED).
#
# Por que a guarda mora AQUI e não no `runs-on` dos workflows: o repositório é
# público, e num `pull_request` de fork o GitHub roda o workflow da BRANCH DO
# FORK. Quem abre o PR pode reescrever `runs-on:` para mirar esta máquina — a
# expressão dos nossos YAML só decide para quem não a edita. Este arquivo é
# gravado na imagem do executor, fora do alcance de qualquer PR, e é lido pelo
# processo do runner antes do checkout.
#
# Aceita só o que já exige permissão de escrita no repositório:
# - push, workflow_dispatch, schedule, merge_group;
# - pull_request cuja branch mora NESTE repositório (não num fork).
# Recusa todo o resto — inclusive pull_request_target e qualquer evento novo que
# o GitHub venha a criar. Na dúvida (payload ilegível, campo ausente), recusa.
set -uo pipefail
REPO_ESPERADO="${DESKCOMM_REPO_ESPERADO:-melgarafael/DeskcommCRM}"
recusa() {
echo "::error::executor próprio recusou este job: $1. Ele só roda trabalho de branch deste repositório; PR de fork roda nas máquinas do GitHub." >&2
exit 1
}
[ "${GITHUB_REPOSITORY:-}" = "$REPO_ESPERADO" ] \
|| recusa "repositório '${GITHUB_REPOSITORY:-<vazio>}' não é ${REPO_ESPERADO}"
case "${GITHUB_EVENT_NAME:-}" in
push | workflow_dispatch | schedule | merge_group)
echo "executor próprio: evento ${GITHUB_EVENT_NAME} aceito"
exit 0
;;
pull_request)
[ -r "${GITHUB_EVENT_PATH:-}" ] || recusa "payload do evento ilegível"
origem=$(jq -r '.pull_request.head.repo.full_name // ""' "$GITHUB_EVENT_PATH" 2>/dev/null) \
|| recusa "payload do evento não é JSON"
[ "$origem" = "$REPO_ESPERADO" ] || recusa "a branch do PR mora em '${origem:-<desconhecido>}'"
echo "executor próprio: PR de branch deste repositório aceito"
exit 0
;;
*)
recusa "evento '${GITHUB_EVENT_NAME:-<vazio>}' fora da lista"
;;
esac
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Uma VAGA do executor próprio: pede ao GitHub uma credencial de uso único
# (runner JIT, que executa no máximo um job e some), sobe um contêiner limpo com
# ela, espera o job terminar e repete. Roda como serviço systemd
# `deskcomm-vaga@N` (N = número da vaga), instalado por instalar.sh.
#
# Uma vaga = um job por vez. As vagas não colidem entre si mesmo com portas
# fixas (o e2e sobe o Supabase em 54321/54322, o teste de imagem usa :3000)
# porque cada contêiner tem o próprio Docker e a própria rede.
set -uo pipefail
VAGA="${1:?uso: vaga.sh <número da vaga>}"
CONF=/etc/deskcomm-executor
REPO="$(cat "$CONF/repo" 2>/dev/null || echo melgarafael/DeskcommCRM)"
CPUS="$(cat "$CONF/cpus-por-vaga" 2>/dev/null || echo 4)"
MEMORIA="$(cat "$CONF/memoria-por-vaga" 2>/dev/null || echo 14g)"
IMAGEM=deskcomm-executor:atual
ROTULO=deskcomm-proprio
while true; do
token="$(cat "$CONF/token" 2>/dev/null)" || token=""
if [ -z "$token" ]; then
echo "vaga ${VAGA}: sem token em ${CONF}/token — esperando" >&2
sleep 60
continue
fi
nome="deskcomm-vaga-${VAGA}-$(date +%s)"
resposta=$(curl -fsS -X POST \
-H "Authorization: Bearer ${token}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${REPO}/actions/runners/generate-jitconfig" \
-d "{\"name\":\"${nome}\",\"runner_group_id\":1,\"labels\":[\"${ROTULO}\"],\"work_folder\":\"_work\"}") \
|| { echo "vaga ${VAGA}: o GitHub recusou a credencial (token vencido ou sem permissão Administration?) — nova tentativa em 60s" >&2; sleep 60; continue; }
cfg=$(printf '%s' "$resposta" | jq -r '.encoded_jit_config // empty')
[ -n "$cfg" ] || { echo "vaga ${VAGA}: resposta sem encoded_jit_config — nova tentativa em 60s" >&2; sleep 60; continue; }
# Contêiner que sobrou de rodada anterior (queda de energia, kill) faria o
# `--name` fixo falhar na hora — e cada volta queimaria uma credencial nova.
docker rm -f "deskcomm-vaga-${VAGA}" >/dev/null 2>&1 || true
echo "vaga ${VAGA}: ${nome} pronto, esperando job"
docker run --rm --runtime=sysbox-runc \
--name "deskcomm-vaga-${VAGA}" \
--cpus "$CPUS" --memory "$MEMORIA" \
-e JIT_CONFIG="$cfg" \
"$IMAGEM"
echo "vaga ${VAGA}: ${nome} terminou (código $?)"
sleep 2
done
@@ -45,3 +45,128 @@ describe("redrive pré-go-live", () => {
expect(query).toHaveBeenLastCalledWith(expect.stringContaining("m.organization_id = $2"), ["message-test", "org-test"]);
});
});
/**
* O RESGATE ALCANÇA A MENSAGEM QUE A AUTOMAÇÃO DEIXOU NA FILA (#652).
*
* O carimbo novo só é honesto se o resgate de `queued` souber dele. O registro
* da automação conta com este resgate quando diz que o envio ficou adiado
* (`lib/automation/desfecho-do-envio.ts`): uma linha `'automation'` presa com o
* canal fora ficaria `queued` para sempre enquanto a Atividade da regra diz que
* a mensagem ainda vai sair.
*
* O banco falso abaixo APLICA o filtro de `sent_via` que está na consulta —
* devolver a linha de qualquer jeito faria o teste passar nos dois mundos, e é
* justamente o vocabulário da consulta que está sob teste.
*/
function vocabularioDeSentVia(sql: string): string[] | null {
const igual = /sent_via\s*=\s*'([a-z_]+)'/i.exec(sql);
if (igual) return [igual[1]!];
const conjunto = /sent_via\s+in\s*\(([^)]*)\)/i.exec(sql);
if (conjunto) return [...conjunto[1]!.matchAll(/'([a-z_]+)'/g)].map((m) => m[1]!);
return null;
}
interface LinhaPresa {
id: string;
organization_id: string;
conversation_id: string;
body: string;
sent_via: string;
waha_session_name: string;
wa_identity: string | null;
wa_lid: string | null;
phone_number: string | null;
is_group: boolean;
group_chat_id: string | null;
}
function bancoComFila(fila: LinhaPresa[]) {
const consultas: string[] = [];
const query = vi.fn(async (sql: string) => {
consultas.push(sql);
if (/count\(\*\)/i.test(sql)) return { rows: [{ n: "0" }] };
if (/select\s+m\.id/i.test(sql)) {
const vocabulario = vocabularioDeSentVia(sql);
if (vocabulario === null) return { rows: [] };
return { rows: fila.filter((l) => vocabulario.includes(l.sent_via)).map((l) => ({ ...l })) };
}
if (/select\s+s\.metadata/i.test(sql)) {
return { rows: fila.length === 0 ? [] : [{ metadata: {}, phone_number: "+5531999998888" }] };
}
return { rows: [] };
});
return { query, consultas };
}
describe("resgate da fila — a mensagem da AUTOMAÇÃO é alcançada (#652)", () => {
it("⭐ linha `sent_via='automation'` presa em queued é reenviada pelo watchdog", async () => {
const send = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(JSON.stringify({ id: { id: "3EB0ABCDEF" } }), { status: 200 }));
const { query } = bancoComFila([
{
id: "m-automacao",
organization_id: "org-1",
conversation_id: "conversa-1",
body: "Olá! Vi que você preencheu o formulário.",
sent_via: "automation",
waha_session_name: "default",
wa_identity: null,
wa_lid: null,
phone_number: "+5531999998888",
is_group: false,
group_chat_id: null,
},
]);
const reenviadas = await redriveQueued({ query } as unknown as pg.Pool, {
wahaBaseUrl: "http://127.0.0.1:9999",
wahaApiKey: "test-key",
intervalMs: 1,
redriveMinAgeMs: 0,
redriveBatchSize: 10,
redriveSpacingMs: 0,
}, createLogger());
expect(
reenviadas,
"a mensagem da automação ficou presa em `queued`: o resgate só olha sent_via='ai'",
).toBe(1);
expect(send).toHaveBeenCalledTimes(1);
});
it("CONTROLE: a linha de dispositivo externo não é reenviada — quem a mandou não foi o CRM", async () => {
// Sem esta direção, um resgate que ignorasse o filtro passaria — e reenviar
// uma mensagem do celular é mandar de novo o que a pessoa já escreveu.
const send = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 }));
const { query } = bancoComFila([
{
id: "m-celular",
organization_id: "org-1",
conversation_id: "conversa-1",
body: "oi, tudo bem?",
sent_via: "external_device",
waha_session_name: "default",
wa_identity: null,
wa_lid: null,
phone_number: "+5531999998888",
is_group: false,
group_chat_id: null,
},
]);
expect(
await redriveQueued({ query } as unknown as pg.Pool, {
wahaBaseUrl: "http://127.0.0.1:9999",
wahaApiKey: "test-key",
intervalMs: 1,
redriveMinAgeMs: 0,
redriveBatchSize: 10,
redriveSpacingMs: 0,
}, createLogger()),
).toBe(0);
expect(send).not.toHaveBeenCalled();
});
});
@@ -13,7 +13,7 @@
* e a sessão parada — o envio exige WORKING, então inbound e auto-resposta
* morrem até alguém clicar Reconectar. FAILED não entra: pode ser banimento,
* e religar sozinho piora; SCAN_QR_CODE também não — tem gente no celular.
* 3. REDRIVE: mensagens `sent_via='ai'` presas em `queued` cuja sessão está
* 3. REDRIVE: mensagens `sent_via in ('ai','automation')` presas em `queued` cuja sessão está
* WORKING são reenviadas pelo WAHA (com espaçamento anti-rajada) e marcadas
* `sent`. Só linhas ainda `queued` entram, então nada é reenviado duas vezes
* pelo mesmo caminho — e, desde 14/09/2026, o eco da mensagem reenviada não
@@ -310,7 +310,7 @@ export async function redriveQueued(
join channel_sessions s on s.id = m.channel_session_id and s.organization_id = m.organization_id
join conversations v on v.id = m.conversation_id and v.organization_id = m.organization_id
join contacts c on c.id = m.contact_id and c.organization_id = m.organization_id
where m.sent_via = 'ai' and m.status = 'queued'
where m.sent_via in ('ai', 'automation') and m.status = 'queued'
and s.status = 'WORKING'
and c.is_blocked = false
-- ─── Só as sessões que ESTE resgate consegue alcançar ───────────────
@@ -339,7 +339,7 @@ export async function redriveQueued(
`select count(*)::text as n
from messages m
join channel_sessions s on s.id = m.channel_session_id
where m.sent_via = 'ai' and m.status = 'queued'
where m.sent_via in ('ai', 'automation') and m.status = 'queued'
and s.status = 'WORKING'
and s.waha_session_name is null
and m.created_at < now() - make_interval(secs => $1 / 1000.0)`,
+1 -1
View File
@@ -41,7 +41,7 @@ const GOOGLE_ENDPOINT = 'https://generativelanguage.googleapis.com';
* com ela sem dependência nova — e os ids dela já vêm no formato
* `familia/modelo`, o mesmo dos nossos, sem tradução no meio.
*/
export const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1';
export const OPENROUTER_ENDPOINT = process.env.OPENROUTER_BASE_URL?.trim() || 'https://openrouter.ai/api/v1';
/**
* Cabeçalhos OPCIONAIS de atribuição da OpenRouter.
+1 -1
View File
@@ -19,7 +19,7 @@ import { env } from "@/lib/env";
/** Endpoint da OpenRouter. Compatível com a API da OpenAI, então o provider
* `@ai-sdk/openai` fala com ela sem dependência nova. */
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
export const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL?.trim() || "https://openrouter.ai/api/v1";
export type ModelId =
| "anthropic/claude-sonnet-5"
+14 -1
View File
@@ -55,7 +55,20 @@ export type Actor =
* a valer.
*/
| { type: "api_token"; id: string; role?: string }
| { type: "webhook_source"; id: string };
/**
* REGRA DE AUTOMAÇÃO disparando um envio — o ator é a regra, não uma pessoa.
*
* `textoEscritoPelaIA` existe porque a AUTORIA e o DISPARO são coisas
* diferentes, e a decisão da #652 classifica `messages.sent_via` por autoria.
* Quase toda ação de regra manda texto fixo (template, follow-up, lembrete):
* ninguém escreveu, e a linha é `'automation'`. A ação "Mensagem escrita pela
* IA" é a exceção: quem escreve é um agente publicado, e a linha é `'ai'` —
* mesmo tendo sido disparada por regra.
*
* Sem este campo, o carimbo se decide só pelo tipo do ator, e a mensagem que a
* IA escreveu aparece no balão como "Automação" e some de `envios_por_ia`.
*/
| { type: "webhook_source"; id: string; textoEscritoPelaIA?: true };
export interface HandlerCtx {
agentOperation?: AgentOperationContext;
+2 -1
View File
@@ -155,7 +155,8 @@ async function execute(ctx: ActionCtx, config: Record<string, unknown>): Promise
organization_id: ctx.organizationId,
serviceBoundary: boundary,
proactiveContext: { organizationId: ctx.organizationId, contactId: contact.id },
actor: { type: "webhook_source", id: ctx.ruleId },
// Disparada por regra, ESCRITA pela IA: o carimbo segue a autoria (#652).
actor: { type: "webhook_source", id: ctx.ruleId, textoEscritoPelaIA: true },
requestId: `rule:${ctx.ruleId}`,
},
{ conversation_id: conversationId, type: "text", body: texto } as Parameters<
+3 -3
View File
@@ -48,9 +48,9 @@ const SAIU = new Set(["sent", "delivered", "read"]);
* Traduz o estado REAL da mensagem no desfecho que a automação registra.
*
* `postponed` para `queued` e não `failed`: a mensagem ainda pode sair (o
* watchdog do agent-engine resgata `sent_via='ai'` em `queued` quando a sessão
* volta a WORKING). Dizer que falhou faria quem lê desistir de uma mensagem que
* está a caminho — o oposto do defeito, e igualmente mentiroso.
* watchdog do agent-engine resgata `sent_via in ('ai','automation')` em `queued`
* quando a sessão volta a WORKING). Dizer que falhou faria quem lê desistir de
* uma mensagem que está a caminho — o oposto do defeito, e igualmente mentiroso.
*/
export function desfechoDoEnvio(
tipo: string,
+75
View File
@@ -19,6 +19,12 @@ export type ValidacaoCredencial =
export async function validateMetaCredentials(input: {
phoneNumberId: string;
token: string;
/**
* Quando informado, a validação também pergunta se o número PERTENCE a esta WABA
* (issue #850, fatia F1). Opcional para não quebrar quem só quer saber se a
* credencial responde.
*/
wabaId?: string;
graphVersion?: string;
}): Promise<ValidacaoCredencial> {
const version = input.graphVersion ?? graphVersion();
@@ -44,6 +50,23 @@ export async function validateMetaCredentials(input: {
};
}
// ─── O número pertence à WABA informada? ────────────────────────────────
// A checagem acima só pergunta se o NÚMERO responde à credencial. Conectar com
// o par trocado (número de uma conta e id de outra) grava uma sessão que ENVIA
// mas cujo webhook nunca chega: a Meta entrega na WABA à qual o número de fato
// pertence, e nenhuma rota desta instalação atende lá. É a pergunta que o
// override da fatia F1 torna obrigatória — ele aponta o webhook de um número
// pelo id, e o id não carrega a WABA. Quem responde é a Meta, não o operador.
if (input.wabaId) {
const pertence = await numeroPertenceAWaba({
wabaId: input.wabaId,
phoneNumberId: input.phoneNumberId,
token: input.token,
version,
});
if (!pertence.ok) return { ok: false, motivo: pertence.motivo };
}
return {
ok: true,
displayPhoneNumber: body.display_phone_number ?? null,
@@ -56,3 +79,55 @@ export async function validateMetaCredentials(input: {
return { ok: false, motivo: `rede indisponível: ${err instanceof Error ? err.message : "erro"}` };
}
}
/**
* Este número está na lista de números DESTA WABA?
*
* `GET /{waba_id}/phone_numbers` é a única resposta direta que a Meta dá à pergunta
* — o id do número não carrega a conta à qual pertence. Lista vazia é tratada como
* "não pertence" com motivo próprio: credencial sem permissão na WABA devolve 200
* com `data: []`, e dizer "não pertence" seria apontar o dedo para o operador
* quando o problema é a credencial (mesma distinção de motivo da validação acima).
*/
async function numeroPertenceAWaba(input: {
wabaId: string;
phoneNumberId: string;
token: string;
version: string;
}): Promise<{ ok: true } | { ok: false; motivo: string }> {
try {
const res = await fetch(
`https://graph.facebook.com/${input.version}/${input.wabaId}/phone_numbers?fields=id&limit=200`,
{ headers: { Authorization: `Bearer ${input.token}` } },
);
const body = (await res.json().catch(() => ({}))) as {
data?: Array<{ id?: string }>;
error?: { message?: string; error_data?: { details?: string } };
};
if (!res.ok || body.error) {
return {
ok: false,
motivo:
body.error?.error_data?.details ?? body.error?.message ?? `a Meta respondeu http_${res.status} ao listar os números da WABA`,
};
}
const ids = (body.data ?? []).map((n) => n.id).filter((id): id is string => Boolean(id));
if (ids.length === 0) {
return {
ok: false,
motivo: `a WABA ${input.wabaId} não devolveu nenhum número — confira se a credencial tem acesso a esta conta no painel da Meta`,
};
}
if (!ids.includes(input.phoneNumberId)) {
return {
ok: false,
motivo: `o número ${input.phoneNumberId} não pertence à WABA ${input.wabaId} — confira no painel da Meta a qual conta o número está ligado`,
};
}
return { ok: true };
} catch (err) {
return { ok: false, motivo: `rede indisponível: ${err instanceof Error ? err.message : "erro"}` };
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Registrar o webhook de uma sessão de canal oficial — o caso de uso, não a chamada.
*
* Fica separado de `webhook-override.ts` (que sabe FALAR com a Meta) pela mesma
* divisão do resto do canal: aqui mora o que o CRM faz com a sessão — decifrar a
* credencial, descobrir o verify token EM VIGOR (banco, com o `.env` de piso),
* montar a URL e GRAVAR o desfecho; lá mora o POST na Graph API.
*
* ─── O desfecho é gravado, e a falha não desfaz a conexão ────────────────────
* A conexão já funcionava sem este passo: o operador colava a URL à mão no painel
* da Meta. Quem não colava seguia com um canal que envia e não recebe — o defeito
* silencioso que a fatia F1 (#850) fecha. Por isso o desfecho vira COLUNA
* (`meta_webhook_override_uri|erro|em`, migration 0311): a tela mostra "conectado,
* webhook pendente: <motivo>" com botão de tentar de novo, em vez de dizer
* "conectado" e deixar a descoberta para a primeira mensagem que nunca chega.
*
* ─── Banco sem a migration 0311 não mente nem quebra ────────────────────────
* Aplicar migration é passo SEPARADO do deploy neste projeto. Se a coluna não
* existir, a gravação do desfecho falha com o nome dela na mensagem e é tratada
* como ausência de schema (log de aviso), não como erro de produto: o desfecho
* ainda volta para a resposta da rota, que é onde o operador o vê.
*/
import { appDaMeta } from "@/lib/channels/meta/app";
import {
registrarWebhookDoNumero,
urlDeCallbackDaSessao,
} from "@/lib/channels/meta/webhook-override";
import { logger } from "@/lib/logger";
import type { createAdminClient } from "@/lib/supabase/admin";
import { decryptWebhookSecret } from "@/lib/webhooks/secrets";
/** As três colunas do desfecho, na ordem em que a migration 0311 as cria. */
export const COLUNAS_DO_DESFECHO_DO_WEBHOOK =
"meta_webhook_override_uri, meta_webhook_override_erro, meta_webhook_override_em";
export interface DesfechoDoWebhookDaSessao {
/** A Meta está entregando no NOSSO endereço? */
registrado: boolean;
/** A URL que ficou registrada (null quando não registrou). */
url: string | null;
/** O motivo, para a tela — null quando deu certo. */
erro: string | null;
/** Quando foi esta tentativa (ISO). */
em: string;
}
/** O erro é "a migration 0311 não rodou neste banco" — e não um erro de verdade. */
function ehColunaDoDesfechoAusente(mensagem: string | null | undefined): boolean {
return (mensagem ?? "").includes("meta_webhook_override");
}
export async function registrarWebhookDaSessao(input: {
admin: ReturnType<typeof createAdminClient>;
channelSessionId: string;
phoneNumberId: string;
wabaId: string;
tokenCifrado: string;
webhookPathToken: string;
base: string;
requestId?: string;
}): Promise<DesfechoDoWebhookDaSessao> {
const em = new Date().toISOString();
const gravar = async (desfecho: DesfechoDoWebhookDaSessao): Promise<void> => {
const { error } = await input.admin
.from("channel_sessions")
.update({
meta_webhook_override_uri: desfecho.url,
meta_webhook_override_erro: desfecho.erro,
meta_webhook_override_em: desfecho.em,
})
.eq("id", input.channelSessionId);
if (!error) return;
if (ehColunaDoDesfechoAusente(error.message)) {
logger.warn(
"migration 0311 não aplicada: o desfecho do webhook não foi gravado (a rota devolve o estado na resposta)",
{ requestId: input.requestId, channelSessionId: input.channelSessionId },
);
return;
}
logger.error("falha ao gravar o desfecho do webhook da sessão", {
requestId: input.requestId,
channelSessionId: input.channelSessionId,
erro: error.message,
});
};
const token = await decryptWebhookSecret(input.admin, input.tokenCifrado);
if (!token) {
// Cifra indisponível ou ciphertext ilegível: sem token não há como falar com a
// Meta em nome desta sessão. Dizer "credencial ilegível" é diferente de "a Meta
// recusou" — o operador troca a credencial no primeiro caso e o app no segundo.
const desfecho: DesfechoDoWebhookDaSessao = {
registrado: false,
url: null,
erro: "a credencial do canal não pôde ser decifrada nesta instalação (GUC de cifra)",
em,
};
await gravar(desfecho);
return desfecho;
}
const app = await appDaMeta();
if (!app.verifyToken) {
// O verify token é o que a Meta repete no handshake: sem ele o override até
// registraria, e o GET de verificação responderia 403 — webhook registrado e
// nunca aceito, que é pior que webhook não registrado.
const desfecho: DesfechoDoWebhookDaSessao = {
registrado: false,
url: null,
erro: "a instalação não tem o verify token (tela de canais oficiais ou META_WEBHOOK_VERIFY_TOKEN)",
em,
};
await gravar(desfecho);
return desfecho;
}
const callbackUrl = urlDeCallbackDaSessao(input.base, input.webhookPathToken);
const resultado = await registrarWebhookDoNumero({
phoneNumberId: input.phoneNumberId,
wabaId: input.wabaId,
token,
callbackUrl,
verifyToken: app.verifyToken,
});
const desfecho: DesfechoDoWebhookDaSessao = resultado.ok
? { registrado: true, url: resultado.url, erro: null, em }
: {
registrado: false,
url: null,
erro:
resultado.etapa === "inscricao_na_waba"
? `não consegui inscrever o app na WABA (${resultado.motivo})`
: `a Meta recusou o webhook do número (${resultado.motivo})`,
em,
};
await gravar(desfecho);
return desfecho;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* O webhook do NÚMERO, registrado no app da Meta (issue #850, fatia F1).
*
* Mora aqui pela mesma razão que `validate-credentials.ts`: a catraca
* (`scripts/lint-channels.ts`) proíbe chamada à Graph API fora de `lib/channels/`,
* e a rota não deve saber com quem fala. A rota pede "registre o webhook deste
* número"; quem sabe como é este módulo.
*
* ─── Por que POR NÚMERO, e não por WABA ─────────────────────────────────────
* A prioridade da Meta é: override do número → override da WABA → URL do app.
* O canal no CRM é o NÚMERO (`channel_sessions.meta_phone_number_id` é a chave da
* conexão), e duas organizações da mesma instalação podem ter números da MESMA
* WABA — override por WABA mandaria a entrega de uma para a URL da outra.
*
* ─── Por que isto é consequência, e não pré-requisito, da conexão ───────────
* A conexão já funcionava sem este passo (o operador colava a URL à mão no painel
* da Meta). Aqui o CRM passa a fazer sozinho o que dependia de cópia manual: o
* produto é self-host para quem NÃO programa. Se o registro falhar, a conexão
* CONTINUA — o desfecho volta para a tela com o motivo e um botão de tentar de
* novo, porque desfazer um canal que já envia mensagem por causa do webhook seria
* trocar um problema por dois.
*
* ─── O que NÃO cabe aqui, de propósito ──────────────────────────────────────
* `message_template_status_update` NÃO aceita override na Meta — ele continua indo
* para a URL do app. É limite da plataforma, não escolha deste módulo (issue #850).
*/
import { graphVersion } from "@/lib/graph-version";
/** Qual das duas chamadas da Meta falhou. A tela mostra isto junto do motivo. */
export type EtapaDoWebhook = "inscricao_na_waba" | "configuracao_do_numero";
export type DesfechoDoWebhook =
| { ok: true; url: string | null }
| { ok: false; etapa: EtapaDoWebhook; motivo: string };
interface RespostaDaGraph {
ok: boolean;
status: number;
body: unknown;
}
/**
* O motivo que o operador lê. A Meta devolve o detalhe útil em
* `error_data.details` (a mensagem genérica costuma ser "Invalid parameter"): sem
* ele a tela diz "não deu", que não é diagnóstico.
*/
function motivoDoErro(res: RespostaDaGraph): string {
const b = res.body as { error?: { message?: string; error_data?: { details?: string } } } | null;
return (
b?.error?.error_data?.details ??
b?.error?.message ??
(res.status === 0 ? "rede indisponível" : `http_${res.status}`)
);
}
async function postNaGraph(url: string, token: string, corpo: unknown): Promise<RespostaDaGraph> {
try {
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(corpo),
});
const body = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, body };
} catch (err) {
// Rede caída não é credencial ruim: o motivo precisa dizer isso, senão o
// operador troca um token que estava certo (mesma lição de `validate-credentials`).
return {
ok: false,
status: 0,
body: { error: { message: `rede indisponível: ${err instanceof Error ? err.message : "erro"}` } },
};
}
}
/** A URL que ESTA instalação publica para o webhook desta sessão de canal. */
export function urlDeCallbackDaSessao(base: string, webhookPathToken: string): string {
return `${base.replace(/\/+$/, "")}/api/v1/webhooks/meta/${webhookPathToken}`;
}
/**
* Inscreve o app na WABA e aponta o webhook DESTE número para `callbackUrl`.
*
* As duas chamadas são sequenciais e a primeira é pré-requisito da segunda: sem a
* inscrição na WABA a Meta não entrega nada, e o override sozinho daria a impressão
* de canal pronto. O desfecho diz qual etapa falhou.
*/
export async function registrarWebhookDoNumero(input: {
phoneNumberId: string;
wabaId: string;
token: string;
callbackUrl: string;
verifyToken: string;
}): Promise<DesfechoDoWebhook> {
const versao = graphVersion();
const inscricao = await postNaGraph(
`https://graph.facebook.com/${versao}/${input.wabaId}/subscribed_apps`,
input.token,
{},
);
if (!inscricao.ok) {
return { ok: false, etapa: "inscricao_na_waba", motivo: motivoDoErro(inscricao) };
}
const configuracao = await postNaGraph(
`https://graph.facebook.com/${versao}/${input.phoneNumberId}`,
input.token,
{
webhook_configuration: {
override_callback_uri: input.callbackUrl,
verify_token: input.verifyToken,
},
},
);
if (!configuracao.ok) {
return { ok: false, etapa: "configuracao_do_numero", motivo: motivoDoErro(configuracao) };
}
return { ok: true, url: input.callbackUrl };
}
/**
* Devolve o número à URL do app (override vazio).
*
* Usado quando o canal é arquivado — a exclusão rotaciona o `webhook_path_token`, e
* sem isto a Meta seguiria entregando numa URL morta (404 para sempre, sem erro do
* nosso lado).
*/
export async function desfazerWebhookDoNumero(input: {
phoneNumberId: string;
token: string;
}): Promise<DesfechoDoWebhook> {
const resposta = await postNaGraph(
`https://graph.facebook.com/${graphVersion()}/${input.phoneNumberId}`,
input.token,
{ webhook_configuration: { override_callback_uri: "" } },
);
if (!resposta.ok) {
return { ok: false, etapa: "configuracao_do_numero", motivo: motivoDoErro(resposta) };
}
return { ok: true, url: null };
}
+8 -1
View File
@@ -350,7 +350,14 @@ async function guardarOrigemDaPagina(admin: Admin, entrada: EntradaDeMensagem):
// A consulta vem ANTES de qualquer escrita, e DENTRO do try: falha de
// leitura não pode virar estampa. O `estampar` só roda depois de a
// primeira mensagem estar confirmada.
if (!(await ehAPrimeiraMensagemDoContato(admin, entrada.contactId, entrada.messageId))) {
if (
!(await ehAPrimeiraMensagemDoContato(
admin,
entrada.organizationId,
entrada.contactId,
entrada.messageId,
))
) {
logger.info("pos-entrada: código de origem fora da primeira mensagem (ignorado)", {
contactId: entrada.contactId,
messageId: entrada.messageId,
+3 -2
View File
@@ -128,8 +128,9 @@ async function handle(row: EventRow): Promise<HandlerResult> {
const transporte = transporteDe(plataforma);
if (!transporte) {
// `google_ads` cai aqui, e é o desfecho CERTO — não um bug. A lacuna é
// anterior: sem extrator de `gclid` não há clique capturado para reportar.
// Plataforma do vocabulário declarada sem transporte no registro — o
// desfecho CERTO, não um bug (invariante 4 da restrição de canal). Hoje
// nenhuma cai aqui: `meta_ads` e `google_ads` têm transporte.
await registra("skipped", "plataforma_sem_transporte");
return ok("skipped", "plataforma_sem_transporte");
}
+9
View File
@@ -3002,6 +3002,9 @@ export type Database = {
meta_phone_number_id: string | null
meta_token_encrypted: string | null
meta_waba_id: string | null
meta_webhook_override_em: string | null
meta_webhook_override_erro: string | null
meta_webhook_override_uri: string | null
metadata: Json
organization_id: string
phone_number: string | null
@@ -3035,6 +3038,9 @@ export type Database = {
meta_phone_number_id?: string | null
meta_token_encrypted?: string | null
meta_waba_id?: string | null
meta_webhook_override_em?: string | null
meta_webhook_override_erro?: string | null
meta_webhook_override_uri?: string | null
metadata?: Json
organization_id: string
phone_number?: string | null
@@ -3068,6 +3074,9 @@ export type Database = {
meta_phone_number_id?: string | null
meta_token_encrypted?: string | null
meta_waba_id?: string | null
meta_webhook_override_em?: string | null
meta_webhook_override_erro?: string | null
meta_webhook_override_uri?: string | null
metadata?: Json
organization_id?: string
phone_number?: string | null
+10
View File
@@ -6642,6 +6642,7 @@ export const DICIONARIO: Traducoes = {
"Insistência do agente (média de retornos)": { es: "Insistencia del agente (promedio de retornos)" },
"Insistência no pior caso": { es: "Insistencia en el peor caso" },
"Intervenções humanas por demanda": { es: "Intervenciones humanas por demanda" },
"Mensagens enviadas por automação": { es: "Mensajes enviados por automatización" },
"Mensagens enviadas pelo agente": { es: "Mensajes enviados por el agente" },
"Negócios ganhos": { es: "Negocios ganados" },
"O cliente que mais recebeu retornos. A média esconde o exagero pontual.": { es: "El cliente que más retornos recibió. El promedio esconde el exceso puntual." },
@@ -6649,6 +6650,7 @@ export const DICIONARIO: Traducoes = {
"Passagens para humano": { es: "Pases a humano" },
"Perguntas que a pessoa teve de repetir": { es: "Preguntas que la persona tuvo que repetir" },
"Quanto o sistema precisou ser contido de si mesmo antes de falar.": { es: "Cuánto el sistema necesitó ser contenido de sí mismo antes de hablar." },
"Regra de automação, texto fixo do follow-up e lembrete de agenda: saiu sozinho e ninguém escreveu. Não entra no número do agente — é por isso que ele cai onde há automação.": { es: "Regla de automatización, texto fijo del seguimiento y recordatorio de agenda: salió solo y nadie lo escribió. No entra en el número del agente — por eso baja donde hay automatización." },
"Respostas dadas pelo agente": { es: "Respuestas dadas por el agente" },
"Respostas humanas fora do sistema": { es: "Respuestas humanas fuera del sistema" },
"Turnos até o desfecho (mediana)": { es: "Turnos hasta el desenlace (mediana)" },
@@ -7958,6 +7960,8 @@ export const DICIONARIO: Traducoes = {
"Enviar vendas para o Google Ads": { es: "Enviar ventas a Google Ads" },
"Google Ads autorizado. Agora informe a conta e a ação de conversão abaixo.":
{ es: "Google Ads autorizado. Ahora indica la cuenta y la acción de conversión abajo." },
"Enviar vendas para o Google Ads ainda não está disponível nesta instalação — não é nada que você tenha feito. Quem instalou o sistema precisa configurar":
{ es: "Enviar ventas a Google Ads todavía no está disponible en esta instalación — no es nada que hayas hecho. Quien instaló el sistema necesita configurar" },
// Convidado do compromisso (agenda)
"E-mail do convidado": { es: "Correo del invitado" },
@@ -9315,6 +9319,12 @@ export const DICIONARIO: Traducoes = {
"o compromisso": { es: "la cita" },
"Entendi": { es: "Entendido" },
"e-mail do convidado inválido": { es: "correo del invitado no válido" },
"Webhook registrado pela instalação": { es: "Webhook registrado por la instalación" },
"O CRM apontou o webhook deste número para cá — não é preciso colar nada no painel da Meta. Os valores abaixo ficam para conferência.": {
es: "El CRM apuntó el webhook de este número hacia acá — no hace falta pegar nada en el panel de Meta. Los valores de abajo quedan para verificación.",
},
"Webhook pendente": { es: "Webhook pendiente" },
"o registro ainda não foi feito": { es: "el registro todavía no se hizo" },
// ─── issue #924 — a origem de quem chega pelo site ───
// app/app/settings/conversoes/page.tsx (a explicação do link) e
+16
View File
@@ -233,15 +233,31 @@ export async function estamparOrigemDaPagina(
* Na dúvida não se grava origem. O custo de uma origem faltando é um relatório
* mais pobre; o de uma origem inventada é um número errado que ninguém vai
* auditar depois — e este módulo trata o texto do cliente como não confiável.
*
* ─── O filtro de organização NÃO é dispensável aqui ─────────────────────────
*
* A consulta roda no client de ADMIN (service role), que passa por cima da RLS:
* sem filtro explícito ela lê as mensagens de TODAS as organizações. O
* `contact_id` de hoje é uuid e não colide entre tenants — e isso não é motivo
* para dispensar o filtro. A alternativa é reavaliar, a cada leitura deste
* arquivo, se a premissa de unicidade ainda vale; o filtro custa um `eq` e
* torna a resposta sobre "este contato" uma resposta sobre "este contato desta
* organização", que é a única pergunta que o domínio sabe fazer.
*
* A organização vem por PARÂMETRO, tirada do segredo do webhook que abriu a
* conversa (`entrada.organizationId`), nunca do corpo da requisição — quem
* escreve a mensagem escolhe o texto, não o tenant.
*/
export async function ehAPrimeiraMensagemDoContato(
admin: Admin,
organizationId: string,
contactId: string,
messageId: string | null,
): Promise<boolean> {
const { data, count, error } = await admin
.from("messages")
.select("id", { count: "exact" })
.eq("organization_id", organizationId)
.eq("contact_id", contactId)
.eq("direction", "inbound")
.order("sent_at", { ascending: true })
+27 -4
View File
@@ -47,6 +47,12 @@ export interface AtritoRaw {
vetos: number;
execucoes_medidas: number;
envios_por_ia: number;
/**
* Envios de REGRA (automação, follow-up fixo, lembrete de agenda): saíram
* sozinhos, mas ninguém os escreveu. É o número que a #652 acrescentou ao
* painel para a queda do "por IA" não ser lida como o agente encolhendo.
*/
envios_por_automacao: number;
envios_humano_no_sistema: number;
envios_humano_fora: number;
demandas_sem_proximo_passo: number;
@@ -108,10 +114,15 @@ export function razao(numerador: number, denominador: number): number | null {
}
/**
* Quanto das respostas saiu do agente, sobre TODAS as saídas (IA + humano no
* sistema + humano fora dele). Incluir o `external_device` no denominador é o
* que impede a automação de parecer alta numa org onde o time responde pelo
* celular: ali a IA não absorveu, ela apenas não foi usada.
* Quanto das respostas saiu do agente, sobre as saídas que TÊM dono entre o
* agente e uma pessoa (IA + humano no sistema + humano fora). Incluir o
* `external_device` no denominador é o que impede a automação de parecer alta
* numa org onde o time responde pelo celular: ali a IA não absorveu, ela apenas
* não foi usada.
*
* As linhas de AUTOMAÇÃO (#652) ficam de fora das duas pontas: nem o agente nem
* uma pessoa as escreveu, e misturá-las aqui faria o número do agente subir por
* mensagem que ele não escreveu. Elas aparecem no número próprio, no painel.
*/
export function taxaDeAutomacao(e: AtritoRaw["empresa"]): number | null {
return razao(e.envios_por_ia, e.envios_por_ia + e.envios_humano_no_sistema + e.envios_humano_fora);
@@ -345,6 +356,18 @@ export function montarPares(
unidade: "media",
nota: t("Quanto o sistema precisou ser contido de si mesmo antes de falar."),
},
// O número da #652. Ele existe porque o contrário dele mente: quando o
// carimbo da automação saiu de `'ai'`, o "por IA" CAIU para quem usa
// regra — sem este número, a queda apareceria como o agente encolhendo.
{
chave: "envios_por_automacao",
rotulo: t("Mensagens enviadas por automação"),
valor: empresa.envios_por_automacao,
unidade: "contagem",
nota: t(
"Regra de automação, texto fixo do follow-up e lembrete de agenda: saiu sozinho e ninguém escreveu. Não entra no número do agente — é por isso que ele cai onde há automação.",
),
},
],
},
];
@@ -40,6 +40,7 @@
import { logger } from "@/lib/logger";
import { configuracaoDoGoogleAds } from "./config";
import { renovarToken } from "./token";
import { VERSAO_DA_API_DO_GOOGLE_ADS } from "./versao-da-api";
import type {
ConversaoOffline,
CredencialDeConversao,
@@ -47,12 +48,6 @@ import type {
TransporteDeConversao,
} from "../types";
/**
* Fixada no código, como a versão da Meta. A API do Google Ads descontinua
* versões antigas por cronograma público; bump é manutenção esperada, não bug.
*/
const VERSAO_DA_API = "v17";
const ENDERECO_BASE = "https://googleads.googleapis.com";
const TEMPO_LIMITE_MS = 10_000;
@@ -93,6 +88,27 @@ function lerErro(bruto: unknown): ErroDoGoogle {
};
}
/**
* O corpo do erro, lido como o Google o manda — ou um motivo que alguém
* consegue ler quando ele NÃO vem em JSON.
*
* Quando a versão da API foi desativada (ou o endereço está errado), o Google
* responde 404 com uma página HTML inteira. Copiar esse HTML para `detalhe`
* punha 400 caracteres de marcação na tela de Conversões, e a pista que
* importava — "a versão pode ter saído do ar" — não aparecia em lugar nenhum.
*/
function lerCorpoDeErro(status: number, texto: string): ErroDoGoogle {
try {
return lerErro(JSON.parse(texto));
} catch {
const pista =
status === 404
? ` — endereço não encontrado; a versão ${VERSAO_DA_API_DO_GOOGLE_ADS} da API pode ter sido desativada pelo Google`
: "";
return { message: `o Google respondeu HTTP ${status} sem o formato de erro esperado${pista}` };
}
}
/**
* `RESOURCE_EXHAUSTED` (cota) e `UNAVAILABLE` (instabilidade do lado do
* Google) se resolvem sozinhos — o mesmo raciocínio do 613 da Meta. O resto —
@@ -153,7 +169,7 @@ async function enviar(
partialFailure: false,
};
const url = `${ENDERECO_BASE}/${VERSAO_DA_API}/customers/${customerId}:uploadClickConversions`;
const url = `${ENDERECO_BASE}/${VERSAO_DA_API_DO_GOOGLE_ADS}/customers/${customerId}:uploadClickConversions`;
const headers: Record<string, string> = {
"content-type": "application/json",
@@ -180,12 +196,7 @@ async function enviar(
if (resposta.ok) return { tipo: "ok" };
const texto = await resposta.text().catch(() => "");
let corpoErro: ErroDoGoogle = {};
try {
corpoErro = lerErro(JSON.parse(texto));
} catch {
corpoErro = { message: texto.slice(0, 400) };
}
const corpoErro = lerCorpoDeErro(resposta.status, texto);
logger.warn("[conversoes.google] envio recusado", {
status: resposta.status,
@@ -202,4 +213,4 @@ export const transporteGoogle: TransporteDeConversao = {
};
/** Exportados para o teste vigiar as regras sem falar com a rede. */
export const INTERNOS = { formatarDataDeConversao, classificaErro, soDigitos, VERSAO_DA_API } as const;
export const INTERNOS = { formatarDataDeConversao, classificaErro, lerCorpoDeErro, soDigitos } as const;
@@ -0,0 +1,29 @@
/**
* A VERSÃO DA API DO GOOGLE ADS TEM UM LUGAR SÓ.
*
* Irmão declarado de `lib/graph-version.ts`, e pelo mesmo motivo: o número
* copiado em dois arquivos é o defeito, porque no dia do bump o esquecido não
* falha — ele responde com a versão antiga. Quem cobra é
* `tests/unit/versao-do-google-ads-num-lugar-so.test.ts`.
*
* ─── Por que isto existe: a v17 entrou na main já desativada ─────────────────
*
* O transporte nasceu com `"v17"` escrito à mão. O Google desativa versões por
* cronograma público, e a v17 já não respondia: toda chamada voltava 404 com
* uma página HTML. O 404 é lido como erro PERMANENTE (`classificaErro`), então
* cada venda virava `recusado_pela_plataforma` sem nova tentativa — o envio
* inteiro morto, com o sintoma apontando para "a plataforma recusou".
*
* ─── A janela, medida em 18/09/2026 na página oficial ────────────────────────
*
* https://developers.google.com/google-ads/api/docs/sunset-dates
*
* v22 desativação out/2026 (tentativa) ← a mais velha ainda viva
* v23 fev/2027 · v24 mai/2027 · v25 ago/2027 · v26 lançamento out/2026
*
* Escolhida a v25: a mais nova já lançada, com a desativação mais distante.
* Quando esta versão entrar em aviso de desativação, o bump é AQUI, e é
* manutenção esperada, não bug. Reconfira o corpo do `uploadClickConversions`
* contra a referência da versão nova antes de subir.
*/
export const VERSAO_DA_API_DO_GOOGLE_ADS = "v25";
+8 -1
View File
@@ -80,7 +80,14 @@ async function ehEcoDeEnvioNosso(
.eq("direction", "outbound")
// `sent_via` separa o que NASCEU aqui do que veio do celular: a linha do
// celular é gravada como `external_device` e nunca pode servir de álibi.
.in("sent_via", ["ai", "user"])
//
// `automation` entrou junto do carimbo novo (#652). A mensagem que a REGRA
// manda nasceu aqui tanto quanto a da IA e a do composer; sem ela nesta
// lista, o eco do próprio envio da regra era lido como resposta pelo celular
// e a IA ficava pausada na conversa por causa de uma mensagem que o CRM
// mandou sozinho. Lista e carimbo andam juntos: quem escreve estes valores é
// `origemDaMensagem`, em `app/api/v1/messages/_handler.ts`.
.in("sent_via", ["ai", "user", "automation"])
// Sem `external_id` = ainda não confirmada pelo canal = ainda em voo. É esta
// a janela exata em que o eco é indistinguível de digitação humana.
.is("external_id", null)
+26
View File
@@ -0,0 +1,26 @@
/**
* A base pública desta instalação — o endereço que o operador cola no painel da Meta.
*
* `env.*` e NÃO `process.env.NEXT_PUBLIC_APP_URL` direto: variáveis `NEXT_PUBLIC_`
* são substituídas no BUILD, e a imagem genérica do self-host é construída com
* `https://placeholder.invalid` (Dockerfile). Lendo direto do `process.env`, a tela
* mostrava essa URL — e quem a colasse no dashboard apontaria o webhook para o nada,
* sem erro em lugar nenhum.
*
* Existe como módulo porque três rotas passaram a precisar do mesmo endereço (a de
* canal oficial, a de canal do parceiro e a de reaplicar o webhook): a cópia era o
* defeito em potencial — duas telas dizendo URLs diferentes para o MESMO webhook.
*/
import { env } from "@/lib/env";
/** Aceita `NextRequest` e qualquer objeto com estas duas peças (testes). */
export function basePublicaDaInstalacao(req: { headers: Headers; nextUrl: URL }): string {
const configurada = env.NEXT_PUBLIC_APP_URL;
const usavel = configurada && !configurada.includes("placeholder.invalid") ? configurada : null;
const base = usavel ?? req.headers.get("origin") ?? `${req.nextUrl.protocol}//${req.nextUrl.host}`;
// A barra final virou responsabilidade DESTE módulo ao unificar as três cópias: o
// caminho é colado com `/`, e um `NEXT_PUBLIC_APP_URL` terminado em barra produzia
// `https://crm.exemplo.com//api/v1/...` — que a Meta aceita no painel e recusa no
// override do número (`(#100) Invalid callback URL`).
return base.replace(/\/+$/, "");
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Lê caminhos (um por linha) na entrada e responde `sim` se ALGUM deles pode
# mudar o que o e2e mede; `nao` só quando TODOS estão na lista abaixo. Entrada
# vazia responde `sim`: não saber o que mudou nunca pode virar "não precisa".
#
# Usado pelo e2e.yml em pull_request. O e2e é o maior consumidor da fila do
# Actions — 56% dos minutos de runner medidos de 15 a 18/09/2026, três partes de
# ~23 min em todo push de PR — e um PR só de documentação, teste de unidade ou
# workflow alheio pagava as três partes para medir o mesmo produto de antes.
#
# A lista é de quem NÃO alcança, e não de quem alcança, de propósito (mesma
# razão do scripts/pr-mexe-na-imagem.sh): errar para "rodar" custa vaga, errar
# para "pular" deixa passar regressão de tela com `e2e` verde.
#
# Cada entrada, e por quê:
# - docs/, tasks/, .changes/, evidence/ e as pastas de ferramenta de agente:
# nenhum código do produto nem spec as lê.
# - tests/unit/, tests/invariants/, tests/cercas/, tests/shell/ e `*.test.ts(x)`
# em qualquer pasta: são testes de OUTRAS suítes (vitest, test:db, test:shell).
# Não mudam o comportamento do produto. O que eles quebram de compilação o
# `build-and-size` (obrigatório) reprova. `tests/e2e/`, `tests/setup/` e
# `tests/fixtures/` NÃO estão aqui — são do e2e ou podem ser.
# - `.github/workflows/*.yml`, menos o próprio e2e.yml: outro workflow não
# muda o que este roda. `.github/actions/` NÃO está aqui — o e2e usa
# `preparar-node`.
# - `*.md` da RAIZ e `.github/*.md`: inertes. Markdown em subdiretório fica
# de fora da regra: há código que lê `.md` do próprio disco
# (lib/agent-engine/playbooks/*.md).
# - infra/executor-proprio/: a máquina que RODA os jobs, não o produto.
set -euo pipefail
algum=nao
while IFS= read -r caminho || [ -n "$caminho" ]; do
[ -z "$caminho" ] && continue
algum=sim
case "$caminho" in
.github/workflows/e2e.yml) echo sim; exit 0 ;;
docs/* | tasks/* | .changes/* | evidence/* | infra/executor-proprio/* \
| .agents/* | .agent/* | .claude/* | .codex/* | .cursor/* | .opencode/* \
| .specs/* | .lina/* | scratchpad/*) ;;
tests/unit/* | tests/invariants/* | tests/cercas/* | tests/shell/*) ;;
tests/e2e/* | tests/setup/* | tests/fixtures/*) echo sim; exit 0 ;;
*.test.ts | *.test.tsx) ;;
.github/workflows/*.yml | .github/workflows/*.yaml) ;;
.github/*.md | .github/ISSUE_TEMPLATE/*) ;;
# Daqui para baixo, só arquivo da RAIZ: qualquer outro caminho com `/` alcança.
*/*) echo sim; exit 0 ;;
*.md) ;;
*) echo sim; exit 0 ;;
esac
done
# Nenhuma linha lida: não sei o que mudou, então roda.
if [ "$algum" = nao ]; then echo sim; else echo nao; fi
+56
View File
@@ -26452,6 +26452,7 @@ as $$
),
envios as (
select count(*) filter (where m.sent_via = 'ai') as por_ia,
count(*) filter (where m.sent_via = 'automation') as por_automacao,
count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema,
count(*) filter (where m.sent_via = 'external_device') as por_humano_fora
from public.messages m
@@ -26522,6 +26523,7 @@ as $$
'vetos', (select vetados from vetos),
'execucoes_medidas', (select execucoes from vetos),
'envios_por_ia', (select por_ia from envios),
'envios_por_automacao', (select por_automacao from envios),
'envios_humano_no_sistema', (select por_humano_no_sistema from envios),
'envios_humano_fora', (select por_humano_fora from envios),
-- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é
@@ -28256,6 +28258,60 @@ comment on column public.automation_rules.trigger_config is
'Configuração do gatilho (issue #989). Vazio nos gatilhos que nascem de evento. No gatilho lead.date_field_due guarda {pipeline_id, campo, dias} — o campo de data pertence a UM funil, e sem essa dupla a varredura não sabe onde olhar.';
notify pgrst, 'reload schema';
-- 0311 · O webhook do NÚMERO, registrado pela própria instalação (issue #850, fatia F1).
--
-- ─── O que o usuário via ────────────────────────────────────────────────────
-- Conectar o canal oficial era metade do caminho: o canal ENVIAVA e não RECEBIA até
-- alguém entrar no painel da Meta, abrir a configuração do webhook, colar a URL de
-- callback e escolher os campos — por número. Quem não sabia disso (o produto é
-- self-host para quem NÃO programa) ficava com um canal que parece pronto e cujas
-- mensagens recebidas simplesmente não existem em lugar nenhum: nem erro, nem log.
--
-- ─── O que estas colunas guardam ────────────────────────────────────────────
-- O DESFECHO do registro automático, não a configuração: a URL que ficou registrada
-- (`meta_webhook_override_uri`), o motivo da última falha (`..._erro`) e quando foi
-- (`..._em`). São o que a tela lê para dizer "conectado, webhook pendente: <motivo>"
-- com botão de tentar de novo — em vez de dizer "conectado" e deixar a descoberta
-- para a primeira mensagem que nunca chega.
--
-- ─── Por que colunas, e não o `metadata` jsonb que já existe na tabela ──────
-- Porque a TELA consulta este estado a cada render e o desfecho tem três leitores
-- (GET do canal, POST de conexão, rota de re-registro): chave dentro de jsonb é
-- contrato que ninguém vê quebrar — o `metadata` da sessão é do ingest/roteamento, e
-- misturar os dois faz um `update` de lá apagar o desfecho daqui.
--
-- ─── Por que registrar DEPOIS de gravar a sessão ────────────────────────────
-- O GET de verificação da Meta chega no instante em que o override é registrado e
-- procura a sessão pelo `webhook_path_token`. Registrar antes de a linha existir
-- devolveria 404, e a Meta marcaria o webhook como inválido — pior que não registrar.
-- Ordem invertida = defeito, não preferência.
--
-- ─── Exposição: nenhuma nova ────────────────────────────────────────────────
-- A URL registrada contém o `webhook_path_token`, que JÁ vive nesta tabela
-- (`channel_sessions`, com `GRANT ALL` a anon/authenticated e RLS de isolamento por
-- organização desde as migrations 0106/0099). Não há coluna nova de segredo, não há
-- grant novo, não há policy nova: a coluna herda exatamente o acesso das vizinhas.
-- O que ela NÃO guarda é o token da Meta — esse continua só em
-- `meta_token_encrypted`, cifrado (fn_encrypt_oauth).
--
-- ─── O que NÃO entra aqui, de propósito ─────────────────────────────────────
-- * `message_template_status_update`: a Meta NÃO aceita override por número para este
-- tópico — ele continua indo para a URL do app (limite da plataforma, não escolha).
-- * Limpeza no arquivamento do canal e reaplicação na reconexão: é a fatia F1b, e
-- roda em cima destas mesmas colunas (`meta_webhook_override_uri` null = desfeito).
-- * Índice: as três colunas são lidas sempre pela chave primária da sessão.
alter table public.channel_sessions
add column if not exists meta_webhook_override_uri text,
add column if not exists meta_webhook_override_erro text,
add column if not exists meta_webhook_override_em timestamptz;
comment on column public.channel_sessions.meta_webhook_override_uri is
'URL de callback registrada na Meta para ESTE número (override por phone_number_id). Nulo = não registrado (ou desfeito). Contém o webhook_path_token, que já é desta tabela.';
comment on column public.channel_sessions.meta_webhook_override_erro is
'Motivo da última falha ao registrar o webhook, como a Graph API devolveu. Não é falha da conexão: o canal envia normalmente; o que depende disto é a ENTREGA. Nulo = última tentativa deu certo.';
comment on column public.channel_sessions.meta_webhook_override_em is
'Quando foi a última TENTATIVA de registrar (sucesso ou falha). A tela usa a data para o operador saber se o estado que ele vê é o de agora.';
-- ---- a resposta revisada para de segurar a Zona de perigo (migration 0273) ----
-- A FK inline da 0227 nasceu sem ação de exclusão (NO ACTION) e era a ÚNICA das
-- quatro que apontam para `public.messages(id)` fora do padrão `on delete set
@@ -0,0 +1,54 @@
-- 0311 · O webhook do NÚMERO, registrado pela própria instalação (issue #850, fatia F1).
--
-- ─── O que o usuário via ────────────────────────────────────────────────────
-- Conectar o canal oficial era metade do caminho: o canal ENVIAVA e não RECEBIA até
-- alguém entrar no painel da Meta, abrir a configuração do webhook, colar a URL de
-- callback e escolher os campos — por número. Quem não sabia disso (o produto é
-- self-host para quem NÃO programa) ficava com um canal que parece pronto e cujas
-- mensagens recebidas simplesmente não existem em lugar nenhum: nem erro, nem log.
--
-- ─── O que estas colunas guardam ────────────────────────────────────────────
-- O DESFECHO do registro automático, não a configuração: a URL que ficou registrada
-- (`meta_webhook_override_uri`), o motivo da última falha (`..._erro`) e quando foi
-- (`..._em`). São o que a tela lê para dizer "conectado, webhook pendente: <motivo>"
-- com botão de tentar de novo — em vez de dizer "conectado" e deixar a descoberta
-- para a primeira mensagem que nunca chega.
--
-- ─── Por que colunas, e não o `metadata` jsonb que já existe na tabela ──────
-- Porque a TELA consulta este estado a cada render e o desfecho tem três leitores
-- (GET do canal, POST de conexão, rota de re-registro): chave dentro de jsonb é
-- contrato que ninguém vê quebrar — o `metadata` da sessão é do ingest/roteamento, e
-- misturar os dois faz um `update` de lá apagar o desfecho daqui.
--
-- ─── Por que registrar DEPOIS de gravar a sessão ────────────────────────────
-- O GET de verificação da Meta chega no instante em que o override é registrado e
-- procura a sessão pelo `webhook_path_token`. Registrar antes de a linha existir
-- devolveria 404, e a Meta marcaria o webhook como inválido — pior que não registrar.
-- Ordem invertida = defeito, não preferência.
--
-- ─── Exposição: nenhuma nova ────────────────────────────────────────────────
-- A URL registrada contém o `webhook_path_token`, que JÁ vive nesta tabela
-- (`channel_sessions`, com `GRANT ALL` a anon/authenticated e RLS de isolamento por
-- organização desde as migrations 0106/0099). Não há coluna nova de segredo, não há
-- grant novo, não há policy nova: a coluna herda exatamente o acesso das vizinhas.
-- O que ela NÃO guarda é o token da Meta — esse continua só em
-- `meta_token_encrypted`, cifrado (fn_encrypt_oauth).
--
-- ─── O que NÃO entra aqui, de propósito ─────────────────────────────────────
-- * `message_template_status_update`: a Meta NÃO aceita override por número para este
-- tópico — ele continua indo para a URL do app (limite da plataforma, não escolha).
-- * Limpeza no arquivamento do canal e reaplicação na reconexão: é a fatia F1b, e
-- roda em cima destas mesmas colunas (`meta_webhook_override_uri` null = desfeito).
-- * Índice: as três colunas são lidas sempre pela chave primária da sessão.
alter table public.channel_sessions
add column if not exists meta_webhook_override_uri text,
add column if not exists meta_webhook_override_erro text,
add column if not exists meta_webhook_override_em timestamptz;
comment on column public.channel_sessions.meta_webhook_override_uri is
'URL de callback registrada na Meta para ESTE número (override por phone_number_id). Nulo = não registrado (ou desfeito). Contém o webhook_path_token, que já é desta tabela.';
comment on column public.channel_sessions.meta_webhook_override_erro is
'Motivo da última falha ao registrar o webhook, como a Graph API devolveu. Não é falha da conexão: o canal envia normalmente; o que depende disto é a ENTREGA. Nulo = última tentativa deu certo.';
comment on column public.channel_sessions.meta_webhook_override_em is
'Quando foi a última TENTATIVA de registrar (sucesso ou falha). A tela usa a data para o operador saber se o estado que ele vê é o de agora.';
@@ -0,0 +1,263 @@
-- ---------------------------------------------------------------------------
-- 0322_automacao_tem_numero_proprio — a mensagem que a automação manda tem NÚMERO PRÓPRIO (#652)
--
-- Decisão do mantenedor (16/09/2026, issue #652): "mensagem que não foi escrita
-- nem por pessoa nem pela IA ganha categoria própria. O painel de atrito passa a
-- mostrá-la num número NOVO, sem remover os números atuais."
--
-- O carimbo (`messages.sent_via='automation'`, gravado por `origemDaMensagem` em
-- `app/api/v1/messages/_handler.ts`) sai do `por_ia` sozinho, porque `por_ia`
-- conta `sent_via='ai'`. O que faltava era o número NOVO existir: sem ele, a
-- mensagem da regra simplesmente desaparece da contagem e a queda do "por IA"
-- fica sem explicação na tela.
--
-- Corpo DERIVADO da versão em vigor no `baseline.sql` (o MANIFEST proíbe
-- recriar a função de memória: ela carrega os prazos e o denominador das
-- migrations 0133/0134/0135/0137).
--
-- Aditivo: nenhuma chave atual muda de nome. O que muda é que as linhas de
-- automação deixam de somar em `envios_por_ia` — elas foram carimbadas `'ai'`
-- até este conserto — e passam a ter `envios_por_automacao`.
--
-- ⚠️ CORPO DERIVADO DA DEFINIÇÃO EM VIGOR, e não da anterior. A versão original
-- desta migration partia da definição de antes da 0266 (#1057), que acrescentou
-- `coalesce(lost_reason,'') <> 'moved_to_another_pipeline'` — o motivo próprio da
-- troca de funil. Recriar a função sem esse filtro APAGARIA a correção do #1057
-- em silêncio, porque esta migration roda depois. Medido na árvore integrada: o
-- baseline ficava com dois blocos da mesma função, e o último vencia — nos dois
-- caminhos de instalação alguma coisa se perdia.
--
-- Renumerada de 0272 para 0322 COM timestamp novo: renumerar só
-- o NNNN já fabricou 12 colisões de timestamp neste repositório.
--
-- Por que 0322 e não 0311: o #865 também pediu 0311, e os dois PRs esperam o
-- mesmo corte. A regra da casa decide por ordem de merge, o que faria o segundo
-- renumerar sob pressão — então eu cedo agora, e qualquer ordem funciona.
-- ---------------------------------------------------------------------------
drop function if exists public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int);
create or replace function public.fn_atrito_metrics(
p_org uuid,
p_from timestamptz,
p_to timestamptz,
p_abandono_horas int default 72,
p_repeticao_min float8 default 0.7,
p_espera_horas int default 4
) returns jsonb
language sql stable
set search_path = public
as $$
with
-- DENOMINADOR DEFINITIVO: demandas encerradas na janela. Não mais os casos.
demandas_j as (
select d.id, d.agent_case_id, d.aberta_em, d.fechada_em, d.desfecho
from public.demandas d
where d.organization_id = p_org
and d.fechada_em is not null
and d.fechada_em >= p_from
and d.fechada_em < p_to
),
-- Turnos: mensagens de TODAS as conversas da demanda (N:N), dentro da vida
-- dela. Uma demanda que atravessou dois canais soma os dois.
turnos as (
select d.id,
(select count(*)
from public.demanda_conversas dc
join public.messages m
on m.conversation_id = dc.conversation_id
and m.organization_id = p_org
and m.sent_at >= d.aberta_em
and m.sent_at < d.fechada_em
where dc.demanda_id = d.id) as n
from demandas_j d
),
-- Insistência: só existe onde houve caso. O payload declara o denominador
-- próprio (`demandas_com_caso`) para o número não ser lido como se fosse
-- sobre o total.
insistencia as (
select avg(c.followup_attempts)::float8 as media,
max(c.followup_attempts) as maximo,
count(*) as base
from demandas_j d
join public.agent_cases c on c.id = d.agent_case_id
),
humano as (
select e.case_id, count(*) as intervencoes, min(e.created_at) as primeiro_toque
from public.agent_case_events e
join demandas_j d on d.agent_case_id = e.case_id
where e.organization_id = p_org and e.actor_kind = 'human'
group by e.case_id
),
espera_fila as (
select extract(epoch from (h.primeiro_toque - d.aberta_em)) as segundos
from demandas_j d join humano h on h.case_id = d.agent_case_id
where h.primeiro_toque > d.aberta_em
),
retrabalho as (
select count(distinct e.case_id) as n
from public.agent_case_events e
join demandas_j d on d.agent_case_id = e.case_id
where e.organization_id = p_org
and (e.kind = 'escalated' or e.human_action = 'escalate')
),
abandono as (
select
count(*) filter (
where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to
and (cv.last_inbound_at is null or cv.last_outbound_at > cv.last_inbound_at)
and cv.last_outbound_at < now() - make_interval(hours => p_abandono_horas)
and cv.status not in ('resolved', 'closed')
) as abandonadas,
count(*) filter (
where cv.last_outbound_at >= p_from and cv.last_outbound_at < p_to
) as com_fala_nossa
from public.conversations cv
where cv.organization_id = p_org and cv.last_outbound_at is not null
),
-- INVARIANTE 4, agora VERIFICÁVEL: demanda aberta sem próximo passo é o
-- vazamento que a doutrina proíbe. Antes da 0119 isto não era enumerável.
sem_proximo_passo as (
select count(*) as n
from public.demandas d
where d.organization_id = p_org
and d.fechada_em is null
and d.proximo_passo is null
),
demandas_abertas as (
select count(*) as n from public.demandas d
where d.organization_id = p_org and d.fechada_em is null
),
inbounds as (
select m.conversation_id, m.sent_at, m.body,
lag(m.body) over (partition by m.conversation_id order by m.sent_at) as body_anterior,
lag(m.sent_at) over (partition by m.conversation_id order by m.sent_at) as sent_at_anterior
from public.messages m
where m.organization_id = p_org and m.direction = 'inbound' and m.body is not null
and m.sent_at >= p_from and m.sent_at < p_to
),
repeticao as (
select
count(*) filter (
where i.body_anterior is not null
and exists (select 1 from public.messages o
where o.organization_id = p_org and o.conversation_id = i.conversation_id
and o.direction = 'outbound'
and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at)
and public.fn_atrito_jaccard(i.body, i.body_anterior) >= p_repeticao_min
) as repetidas,
count(*) filter (
where i.body_anterior is not null
and exists (select 1 from public.messages o
where o.organization_id = p_org and o.conversation_id = i.conversation_id
and o.direction = 'outbound'
and o.sent_at > i.sent_at_anterior and o.sent_at < i.sent_at)
) as com_resposta_no_meio
from inbounds i
),
espera_calada as (
select count(*) filter (where prox.espera_s > p_espera_horas * 3600) as caladas,
count(*) as com_resposta,
percentile_cont(0.9) within group (order by prox.espera_s) as p90_s
from (
select extract(epoch from (
(select min(o.sent_at) from public.messages o
where o.organization_id = p_org and o.conversation_id = m.conversation_id
and o.direction = 'outbound' and o.sent_at > m.sent_at) - m.sent_at)) as espera_s
from public.messages m
where m.organization_id = p_org and m.direction = 'inbound'
and m.sent_at >= p_from and m.sent_at < p_to
) prox
where prox.espera_s is not null
),
envios as (
select count(*) filter (where m.sent_via = 'ai') as por_ia,
count(*) filter (where m.sent_via = 'automation') as por_automacao,
count(*) filter (where m.sent_via = 'user') as por_humano_no_sistema,
count(*) filter (where m.sent_via = 'external_device') as por_humano_fora
from public.messages m
where m.organization_id = p_org and m.direction = 'outbound'
and m.sent_at >= p_from and m.sent_at < p_to
),
vetos as (
select count(*) filter (where t.vetoed_gate is not null) as vetados,
count(distinct t.job_id) as execucoes
from public.before_send_traces t
where t.organization_id = p_org and t.created_at >= p_from and t.created_at < p_to
),
descadastros as (
select count(*) as n from public.contacts c
where c.organization_id = p_org and c.blocked_at is not null
and c.blocked_at >= p_from and c.blocked_at < p_to
),
pedidos_humano as (
select count(*) as n from public.crm_lead_activities a
where a.organization_id = p_org and a.type = 'handoff_triggered'
and a.performed_at >= p_from and a.performed_at < p_to
),
eficiencia as (
select count(*) filter (where status = 'won') as ganhos,
count(*) filter (
where status = 'lost'
-- A transferência entre funis não é perda comercial (migration 0266).
and coalesce(lost_reason, '') <> 'moved_to_another_pipeline'
) as perdidos
from public.crm_leads
where organization_id = p_org and status in ('won', 'lost')
and closed_at >= p_from and closed_at < p_to
)
select jsonb_build_object(
'escopo', jsonb_build_object(
'demandas', (select count(*) from demandas_j),
'demandas_com_caso', (select base from insistencia),
'demandas_abertas', (select n from demandas_abertas),
'de', p_from, 'ate', p_to,
'abandono_horas', p_abandono_horas,
'repeticao_min', p_repeticao_min,
'espera_horas', p_espera_horas,
-- Marca a régua do denominador: quem comparar dois períodos precisa saber
-- se foram medidos sobre casos ou sobre demandas.
'denominador', 'demandas'
),
'cliente', jsonb_build_object(
'turnos_p50', (select percentile_cont(0.5) within group (order by n) from turnos),
'turnos_p90', (select percentile_cont(0.9) within group (order by n) from turnos),
'insistencia_media', (select media from insistencia),
'insistencia_max', (select maximo from insistencia),
'pedidos_de_humano', (select n from pedidos_humano),
'descadastros', (select n from descadastros),
'abandonos', (select abandonadas from abandono),
'conversas_com_fala_nossa', (select com_fala_nossa from abandono),
'reperguntas', (select repetidas from repeticao),
'perguntas_com_resposta', (select com_resposta_no_meio from repeticao),
'esperas_caladas', (select caladas from espera_calada),
'esperas_medidas', (select com_resposta from espera_calada),
'espera_resposta_p90_s', (select p90_s from espera_calada)
),
'empresa', jsonb_build_object(
'intervencoes_por_demanda', (select avg(coalesce(h.intervencoes, 0))::float8
from demandas_j d left join humano h on h.case_id = d.agent_case_id),
'espera_humana_p50_s', (select percentile_cont(0.5) within group (order by segundos) from espera_fila),
'espera_humana_p90_s', (select percentile_cont(0.9) within group (order by segundos) from espera_fila),
'retrabalho', (select n from retrabalho),
'vetos', (select vetados from vetos),
'execucoes_medidas', (select execucoes from vetos),
'envios_por_ia', (select por_ia from envios),
'envios_por_automacao', (select por_automacao from envios),
'envios_humano_no_sistema', (select por_humano_no_sistema from envios),
'envios_humano_fora', (select por_humano_fora from envios),
-- O invariante 4 vira NÚMERO na tela: demanda aberta sem próximo passo é
-- vazamento, e vazamento invisível é o que a doutrina inteira combate.
'demandas_sem_proximo_passo', (select n from sem_proximo_passo)
),
'eficiencia', jsonb_build_object(
'ganhos', (select ganhos from eficiencia),
'perdidos', (select perdidos from eficiencia)
)
);
$$;
revoke all on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from public;
revoke execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int) from anon;
grant execute on function public.fn_atrito_metrics(uuid, timestamptz, timestamptz, int, float8, int)
to authenticated, service_role;
+2
View File
@@ -324,12 +324,14 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260915213849` | `0264_vocabulario_de_tags` | O vocabulário de etiquetas deixa de ser só de leitura (issue #852, fatia S4). Três funções em `public`: `fn_vocabulario_de_tags` (leitura, `security invoker`, devolve `tag, uso_em_contatos, uso_em_leads, uso_em_conversas, em_regras, cor, descricao, no_vocabulario` a partir dos três `text[]` e das sementes de `organizations.settings`), `fn_tags_normalizar` (pura/imutável, renomeia/junta/exclui numa lista desduplicando por nome canônico — é o que impede a junção `{"vip","VIP"}` de virar duplicata) e `fn_vocabulario_de_tags_operar` (a operação inteira numa transação: os três arrays, as sementes da organização e as ações `add_tag` de `automation_rules`, com `fn_role_at_least(p_org,'manager')` antes de qualquer escrita e `emit_event` por linha alterada). Renomear e juntar reescrevem a regra do agente; excluir NÃO apaga a regra — informa quantas continuam escrevendo (decisão de outra tela). Sem tabela nova, sem coluna nova, sem backfill: o vocabulário continua sendo `text[]` + jsonb em `settings`, porque automação, webhook (`lead.tag_added`) e MCP (`*.tags_changed`) já falam em string. `revoke execute ... from public, anon` nas três e `grant` só a `authenticated`/`service_role`. |
| `20260917120000` | `0271_extensoes_declarativas` | Cinco tabelas do framework declarativo: catálogo admitido, artefato imutável, instalação por origem/identidade, vínculo por organização e recibo durável. Oito RPCs service-only (admitir, preparar, concluir, falhar, cancelar, configurar, desfazer a última troca, remover da instalação) com ator vigente, fingerprint idempotente, `applied_now` que distingue a transição da repetição, e CAS pela revisão da instalação; mais a contagem de organizações ativas por instalação, que devolve só números. A instalação ganha `previous_artifact_id` (histórico de um passo), `revision` e remoção lógica (`removed_at`/`removed_by`); o vínculo ganha `deactivated_by_removal_at`. As CHECKs de `kind`/`status` do recibo são constraints nomeadas com drop/add, então um banco de desenvolvimento com a versão antiga do bloco se corrige ao reaplicar o apêndice (a cadeia `db push` não reaplica). Publicar espera a atualização do core (`dispatched` com menos de 15 min); remover não espera. prepare/finish/cancel/revert/remove e o gatilho de system_update_runs compartilham a trava consultiva; configurar serializa com a troca de ponteiro por `for share` na instalação. RLS de membership somente para leitura do vínculo; escrita direta do framework revogada inclusive de service_role. Apêndice idempotente. |
| `20260915193743` | `0263_etapa_de_perda_grava_o_motivo` | **A etapa de PERDA do lote grava o motivo na MESMA escrita.** `fn_mover_leads_em_lote` (0209) trocava só o `stage_id`; quando a etapa de destino é de perda, `trg_crm_lead_close_on_stage` fecha o negócio e a CHECK `crm_leads_lost_reason_required` recusa a linha — e, como a função é uma transação só ("move todos ou não move nenhum"), **UM** card sem motivo derrubava o LOTE INTEIRO, com a rota respondendo 500 (`internal_error`) a quem acabara de pedir o movimento. Medido no baseline (pg16, lote de 2 cards abertos para a etapa de perda): `SQLSTATE=23514 | new row for relation "crm_leads" violates check constraint "crm_leads_lost_reason_required"`. O quarto parâmetro `p_lost_reason` é a **mesma escrita** — uma escrita ANTES tem janela (entre as duas o negócio aparece `lost` sem motivo) e uma DEPOIS é linha já recusada. A coluna só entra na lista do `update` quando há motivo (SQL dinâmico) porque tocar `lost_reason` dispara `trg_validate_lost_reason_required`, que confere o valor contra o vocabulário do funil: reescrever o valor que já estava na linha recusaria um card cujo motivo saiu da configuração depois de usado (22023 `lost_reason_invalid`) enquanto o arrasto do mesmo card — que não toca a coluna — continuaria passando. `coalesce` com o valor da própria linha preserva o motivo do card que já era perdido (`p_lost_reason` nulo não apaga nada). `drop function` antes do `create or replace` porque o PostgreSQL não substitui assinatura (sem ele, `fn_mover_leads_em_lote(uuid, uuid[], uuid) is not unique`). Sem coluna nova, sem backfill, sem dado tocado; baseline com o corpo final. Os **três caminhos** ficam com uma só decisão (`lib/leads/motivo-da-perda.ts`): arrasto (`/leads/[id]/move`), lote (`/leads/bulk`) e a IA (`lib/leads/agent-stage-sync.ts` — o agente **não** move: recusa de negócio com item de inbox acionável, porque `lost_reason` não é texto livre e o motivo é decisão de quem está no negócio). Catracas: `tests/unit/etapa-de-perda-no-arrasto.test.ts`, `tests/unit/etapa-de-perda-no-lote.test.ts` e `tests/unit/etapa-de-perda-do-agente.test.ts` (vermelhas sem o fix). |
| `20260919103000` | `0322_automacao_tem_numero_proprio` | **A mensagem que a automação manda ganha número próprio no painel de atrito (#652).** O carimbo `messages.sent_via` passou a gravar `'automation'` quando quem manda é a automação (`origemDaMensagem`, em `app/api/v1/messages/_handler.ts`) — antes a pergunta era uma só (`!== "user"`) e um template fixo de regra saía `'ai'`, sem IA nenhuma no caminho. Efeito medido: a linha deixou de somar em `envios_por_ia`, e por isso `fn_atrito_metrics` ganha `por_automacao` no CTE `envios` e a chave `envios_por_automacao` em `empresa`, publicada no painel em Contenção com a nota que explica de onde ela vem (regra, texto fixo do follow-up e lembrete de agenda) e por que o número do agente cai onde há automação (`lib/metrics/atrito.ts`, `montarPares`). ADITIVO: nenhuma chave atual muda de nome; `por_ia`, `por_humano_no_sistema` e `por_humano_fora` seguem idênticos. A fórmula de "Respostas dadas pelo agente" (`taxaDeAutomacao`) NÃO mudou — incluir a automação no denominador baixaria a taxa de quem usa regra, e essa é a decisão que ficou aberta no PR, medida e declarada, não tomada em silêncio. Quem lê o número: operador de VPS com automação ligada — o "por IA" dele cai na proporção do que a regra manda, e agora existe o número que explica a queda. Teste de banco: `pnpm test:db` (invariante de atrito), prova no CI; aqui não há Docker. Corpo derivado da versão em vigor no `baseline.sql` (o MANIFEST proíbe recriar `fn_atrito_metrics` de memória: ela carrega prazos e denominadores das migrations 0133/0134/0135/0137). |
| `20260917034929` | `0268_regra_de_automacao_guarda_o_gatilho` | **A regra passa a guardar a CONFIGURAÇÃO do gatilho** (`automation_rules.trigger_config jsonb not null default '{}'`), e isso existe por causa de um gatilho que não nasce de evento: "quando faltarem N dias para uma data do funil" (issue #989). Quem o emite é a varredura `app/api/v1/cron/lead-date-field-due`, e ela só sabe onde olhar se a regra disser QUAL funil e QUAL campo de data, mais o N assinado (positivo = antes da data, negativo = depois). **jsonb, e não três colunas**: `pipeline_id`/`campo` ficariam vazios em 100% das regras dos outros nove gatilhos, e o gatilho seguinte pode precisar de outra coisa — é o mesmo desenho de `conditions` e `actions`, com o contrato de forma no TypeScript (`ConfigDoGatilhoDeData`). **Sem CHECK, de propósito**: recriar o CHECK a cada gatilho novo não é idempotente; a porta única é `createAutomationRuleSchema` (recusa a regra de data sem configuração) e a varredura pula o que chegar torto, contando em `pulados.config_invalida`, sem derrubar as outras organizações. `'{ }'` como default faz toda regra existente nascer com o objeto vazio — sem backfill e sem uma terceira forma de "sem configuração". `notify pgrst` porque a varredura SELECIONA a coluna. Gate: `tests/unit/data-do-funil-avisa-a-regra-certa.test.ts`. |
| `20260918014500` | `0277_pais_da_organizacao` | **O país da organização: onde moram o documento do titular, a lei citada e o prazo do direito de acesso (issue #1033, decisão do dono 25-a).** `organizations.country` (ISO-3166 alpha-2 em maiúsculas, `CHECK country is null or country ~ '^[A-Z]{2}$'`), **sem `default` e sem backfill**: `null` é Brasil, que é o comportamento de antes desta migration — nenhuma linha existente é reescrita e nenhum DEFAULT de outra coluna muda, então quem já instalou continua exatamente onde está. A localização é por ORGANIZAÇÃO, e não por instalação: duas organizações no mesmo banco podem estar em países diferentes (`APP_COUNTRY` no `.env` responderia por processo, e o processo serve todas). Nada de RLS, grant ou policy: a coluna nasce na tabela que já tem as regras dela. **O perfil do país é CÓDIGO, não configuração de instalação:** `lib/legal/perfil-do-pais.ts` (rótulo e validador do documento, lei citada, calendário de dias úteis e padrões de PII), resolvido por `perfilDaOrganizacao(supabase, orgId)` no formato de `lib/catalogo/moeda-da-org.ts` — nenhuma rota lê a coluna inline, porque duas leituras divergem no dia em que uma ganhar fallback e a outra não, e aqui a divergência prometeria a lei de um país com o prazo de outro. **A régua para um país entrar** (risco escrito na issue): o documento responde a um direito legal do titular, e citar a lei errada é pior do que não citar artigo nenhum — país entra com a citação REVISADA, ou não entra; `PAISES_OFERECIDOS` (o que o seletor mostra) exige `lei.revisada`, e o registro pode conhecer mais países do que a lista oferece. Sem citação revisada o documento não cita lei nenhuma (não há fallback para a LGPD). Sem coluna nova além desta, sem tabela nova, sem seed de feriado (o calendário vem versionado com o produto, em `lib/lgpd/holidays-br.ts`). O apêndice do baseline entra ANTES do bloco da VARREDURA anon, com `add column if not exists` + `drop constraint if exists` antes do `add constraint` (quem ATUALIZA precisa da trava de forma tanto quanto quem instala do zero). Gate: `tests/unit/pais-da-organizacao.test.ts`. |
| `20260916120000` | `0266_transferencia_entre_funis_nao_e_perda` | **A transferência entre funis não é perda comercial — e a regra de automação transfere em vez de abrir um segundo negócio** (issue #992). Duas metades do MESMO nome. (1) `fn_validate_lost_reason_required` ganha o canônico `moved_to_another_pipeline`, o motivo PRÓPRIO da troca de funil: em `lib/leads/motivo-da-perda.ts` ele vira `MOTIVO_DA_TRANSFERENCIA` e passa a ser o padrão da origem encerrada (antes, `other`) — sem ele no array do trigger o encerramento volta com `22023 lost_reason_invalid`, a origem fica aberta no funil antigo e o negócio existe nos dois lugares; é o defeito que a troca existe para consertar. (2) `fn_attendant_metrics` e `fn_atrito_metrics` deixam de contá-lo: `count(*) filter (where status = 'lost' and coalesce(lost_reason, '') <> 'moved_to_another_pipeline')` — quem foi levado para outro funil não é perda de ninguém, e o contador por responsável para de engordar com movimento administrativo. **Sem coluna, sem constraint, sem backfill:** as três funções são `create or replace` puros e o estado anterior segue válido (negócio já fechado com `other` continua contando como perda — a migration não reescreve histórico); o apêndice do baseline entra antes do bloco da varredura anon. Corrige também o texto da P-01 em `docs/business-rules/00-business-rules-catalog.md`, que citava `moved_to_pipeline_X`, o motivo que o banco recusa. Catraca: `tests/unit/automacao-troca-de-funil-transfere-o-negocio.test.ts`. |
| `20260917150401` | `0273_zona_de_perigo_apaga_com_resposta_revisada` | **A Zona de perigo volta a apagar dados operacionais de organização que já enviou resposta revisada (issue #949).** `ai_reply_drafts.message_id` foi criada pela 0227 como `references public.messages(id)` SEM ação de exclusão — NO ACTION —, e era a ÚNICA das quatro FKs para `public.messages(id)` fora do padrão `on delete set null` das irmãs (`ai_agent_suggestions.message_id` na v. 11337, `messages.reply_to_message_id` na 14759, `calendar_appointments.outcome_message_id` na 19939). O apagamento da Zona de perigo (`lib/settings/apagar-dados-operacionais.ts`) começa por `messages` — é a ordem declarada em `RAIZES_DO_APAGAMENTO`, porque `messages.contact_id` é RESTRICT para `contacts` —, então o PRIMEIRO delete da organização era recusado pelo banco: medido, `SQLSTATE=23503 | update or delete on table "messages" violates foreign key constraint "ai_reply_drafts_message_id_fkey" on table "ai_reply_drafts"`. O app traduz isso no primeiro item de `ResultadoDoApagamento` com `ok: false`, `tabela: "messages"`, e **nada** é apagado: a organização fica intacta e o operador vê a ação falhar sem causa visível. **A ação escolhida é `set null`, e a evidência é o padrão das irmãs:** a irmã que passou pela mesma decisão escreveu o porquê no próprio cabeçalho — "apagar a citada não pode levar junto a resposta, que é conteúdo próprio. Perder o fio é aceitável; perder a resposta é apagar histórico por causa de um ponteiro" (14759) — e `ai_reply_drafts` guarda conteúdo próprio: `approved_body`, `edited_by`, o trace da decisão e o feedback humano. **Por que `cascade` foi preterido:** existe caminho legítimo que apaga MENSAGEM por motivo alheio à resposta — a deduplicação de eco (`delete from public.messages ... sent_via = 'external_device' ...`, v. 21770) e a exclusão de uma mensagem avulsa pela UI (`app/api/v1/messages/_handler.ts`) —, e ali cascade apagaria um rascunho revisado como efeito colateral de limpeza de ponteiro, exatamente o que a doutrina da 14759 proíbe; além disso cascade não acrescenta nada à promessa da Zona de perigo, porque quem leva os rascunhos da organização é o cascade de `conversations` (`ai_reply_drafts.conversation_id`). Estado final do caminho do defeito idêntico ao prometido — `messages` cai, `conversations` cai e leva os drafts —, sem o 23503 no meio. `message_id` é nullable (`null` sempre significou "ainda não enviado" para o filtro de leitura), então não há DEFAULT nem backfill. Nome do constraint preservado (`ai_reply_drafts_message_id_fkey`), que é o que aparece na mensagem de erro citada no suporte. **Caminhos irmãos que herdavam a mesma recusa, levantados por grep (nenhum arquivo de TypeScript foi tocado; `grep -rl 'from("messages")' app lib` cruzado com `.delete()`):** exclusão de contato (`app/api/v1/contacts/_handler.ts` apaga `messages` antes de `conversations` e `contacts` — a recusa virava o 409 enganoso de "o contato ainda tem registros vinculados") e exclusão de mensagem avulsa (`app/api/v1/messages/_handler.ts`). As rotas de admin (`admin/tenants/[id]/route.ts`, `admin/inbox/conversations/[id]/route.ts`) **não** entram: elas só contam/leem `messages`. A cascata de LGPD NÃO entra na lista: `fn_lgpd_cascade_redact_contact` anonimiza, não apaga `messages` — o único outro `delete from public.messages` em SQL é o do eco (v. 21770). Baseline INSTALL/UPDATE idempotente (`drop constraint if exists` + `add constraint`). Gate: `tests/invariants/zona-de-perigo-apaga-com-resposta-revisada.test.ts`. |
| `20260917150000` | `0274_travas_de_suporte_cobrem_toda_tabela` | **As travas do modo somente leitura do suporte cobrem toda tabela da organização já na primeira aplicação do schema**, e a instalação nova chega ao mesmo conjunto de travas que a atualização. A enumeração da 0220 que planta as restritivas `support_write_{insert,update,delete}` vira `public.fn_aplicar_travas_de_suporte()` (sem parâmetro, idempotente: `drop policy if exists` + `create policy`), chamada depois de toda tabela — na cadeia de migrations, por esta migration; no `baseline.sql`, a definição fica antes da varredura de anon e a chamada é o último bloco do arquivo (o `do` avulso do bloco da 0220 saiu, e um comentário aponta para cá). Regra de seleção inalterada: tabela comum de `public`, RLS ligada, com `organization_id` (ou `organizations`, pela `id`); gravável por `authenticated` → as três restritivas, só do servidor → nenhuma. Não é `security definer`; `revoke execute` de `public, anon, authenticated, service_role`, sem grant — só quem aplica o schema a chama. Medido em pg17 descartável com o prelúdio do `scripts/test-db.sh`, baseline aplicado UMA vez: toda tabela alcançada pela regra tem as três travas, e o conjunto de políticas é o mesmo de duas aplicações (md5 idêntico). Sem tabela, coluna ou dado novo. Invariante: `tests/invariants/travas-de-suporte-cobrem-toda-tabela-na-instalacao.test.ts`, que lê um molde de aplicação única tirado pelo `scripts/test-db.sh` entre o install e o update — e prova que ele é de uma aplicação pelo contador `test_db.aplicacoes_do_baseline`, que o script grava a cada aplicação. |
| `20260918011500` | `0282_extensoes_perfil_v2` | **O banco passa a conhecer o perfil declarativo v2 (ADR-0003).** A validação do CATÁLOGO em `fn_extensions_admit_catalog` fixava `permissions` em `["navigation.tasks"]` (0271:194) e recusava chave desconhecida na entrada (0271:184): sem esta migration, um catálogo v2 é recusado com `extension_invalid_input` e o contrato novo viveria só no TypeScript. Permissões passam por `public.fn_extensions_permissoes_validas` (conjunto fechado, não vazio, sem repetição — espelho de `EXTENSION_PERMISSIONS` em `lib/extensions/capacidades.ts`), e o catálogo aceita o metadado de loja (`publisher_label`, `homepage`, `repository`, `tags`, `published_at`), que **não** entra no manifesto: o pacote descreve o que faz, o catálogo revisado descreve de quem é. Acrescenta `extension_permissions_changed`, a recusa que a spec v1 prometia "quando o contrato admitir outra permissão" — sem ela, atualizar de 1.0 para 1.1 acrescentaria uma porta sem ninguém na organização rever a lista que a tela existe para mostrar; vale em `fn_extensions_finish_install` (kind `update`) e em `fn_extensions_revert_install`. As três funções são reescritas INTEIRAS com o corpo da 0271 preservado, e não por substituição de texto sobre `prosrc` em tempo de execução, que dependeria do estado de cada clone. Sem tabela, coluna ou dado novo. Apêndice idempotente derivado do próprio arquivo da migration. |
| `20260918230000` | `0311_webhook_do_numero_no_canal_oficial` | **Três colunas de DESFECHO do registro automático do webhook do canal oficial** (`channel_sessions.meta_webhook_override_uri` / `_erro` / `_em`): a URL que ficou registrada na Meta, o motivo da última falha e quando foi a tentativa. Fecha o defeito silencioso da fatia F1 da #850 — conectar o canal oficial deixava o operador com um canal que ENVIA e não RECEBE até ele colar a URL de callback à mão no painel da Meta, e por número; quem não sabia disso não via erro em lugar nenhum. Por COLUNAS, e não no `metadata` jsonb da sessão: são três leitores (GET do canal, POST de conexão, rota de re-registro) e chave dentro de jsonb é contrato que o `update` do ingest apaga sem avisar. `add column if not exists`, sem índice (lida sempre pela chave primária), sem grant novo: a URL carrega o `webhook_path_token`, que já vive nesta tabela sob RLS por organização. A limpeza no arquivamento e a reaplicação na reconexão ficam para a F1b, em cima destas mesmas colunas. Baseline INSTALL/UPDATE idempotente. |
| `20260918220000` | `0308_espera_longa_dorme` | **A espera longa de um fluxo de follow-up passa a sobreviver ao contato mandar mensagem**, que é o que faltava para uma cadência de retorno ("volte a falar daqui a 28 dias") caber num fluxo em vez de morar no prompt do agente. Duas coisas a matavam, as duas caladas: `lib/followup/reactivity.ts` ou CANCELA a inscrição parada num `wait` (`cancel_on_reply`) ou grava `inbound_woke` e **corta o timer** — e numa espera de 28 dias a cliente de manutenção fala com o estúdio várias vezes, sem que isso signifique antecipar nem desistir do retorno; e o índice único anti-spam trancaria o contato fora de qualquer outra cadência por um mês. Por isso a regra vivia no prompt chamando `crm_schedule_followup` (que grava em `cron_jobs`, é imune e não ocupa vaga), onde não dá para editar o prazo, ver quem está esperando nem medir o resultado. O status novo `dormente` é a **projeção** em runtime de `wait.immune_to_reply` (campo novo do nó, só no modo `fixed`, exigido ≥24h por `immune_wait_too_short` em `validate-publish.ts`) — não uma coluna `imune`, que seria segunda verdade sobre o mesmo fato e divergiria no primeiro republish; quem a escreve é o mesmo `wake_status` que já projeta `waiting_reply` (`node-handlers.ts` → `engine.ts`). É o status que faz a feature custar **zero** na reatividade: `LIVE_STATUSES` não o inclui, então a inscrição dormente nem é carregada — nenhuma query nova por mensagem recebida, nenhum grafo lido ali — com uma exceção deliberada, o ramo de STOP/opt-out, que alcança o dormente como alcança qualquer outro (LGPD não admite exceção de status). **`idx_followup_enrollments_one_live` NÃO é tocado**: ele enumera os status que ocupam vaga e `dormente` fica de fora por construção, então a vaga é liberada sem uma linha de DDL sobre o índice e o guard anti-empilhamento continua com a força de antes para os status que já cobria. `dormente` entra na perna **com relógio** do CHECK de coerência (é o `next_eval_at` que o acorda, pelo mesmo claim — não há segundo agendador), e nas duas listas de `fn_claim_due_followup_enrollments` mais no índice parcial do claim: **é aí que esta migration falha calada se alguém a encurtar** — sem isso a inscrição dorme e nunca acorda, sem nada reprovando. Os dois CHECKs saem pelo catálogo (`pg_constraint`), não pelo nome, porque em clone que passou por dump/restore o nome gerado diverge e o drop por nome fixo falharia em silêncio (mesmo cuidado da 0145). Re-aplicável: os CHECKs só ampliam o conjunto aceito, o predicado novo do índice cobre todas as linhas do antigo, e nenhum banco tem `dormente` antes desta migration — sem backfill e sem dado tocado. |
| `20260918001726` | `0276_rodada_do_banco` | **A rodada de atualização carrega o que aconteceu com o banco — disputa, retentativas e passada — e a tela de atualização conta isso quando termina.** Três colunas em `system_update_runs` (`disputa_de_banco`, `retentativas_do_banco`, `passada_do_banco`) guardam, por rodada, se houve disputa de lock com o sistema no ar, quantas retentativas o kit gastou e em qual passada o baseline reaplicado fechou — a mecânica que o PR #997 mostrou (o baseline reaplicado sobrevive à disputa) e que até aqui só existia no log do servidor. Nulo é "não medido" (caminho que não passou pelo banco, como atualização só de código) e a tela não inventa texto para isso. A CHECK `system_update_runs_rodada_do_banco_coerente` recusa número incoerente — mais retentativas do que passadas. |
| `20260918210000` | `0306_google_ads_captura_de_clique` | `google_ads_landing_pages` (WhatsApp + template por organização) e `google_ads_click_refs` (par token curto ↔ gclid, criado no clique e consumido quando a mensagem chega). Fecha a lacuna que `lib/plataformas-de-anuncio/registry.ts` (0213) já declarava: sem landing page não havia extrator de `gclid` para o Google Ads. Server-side only, mesmo desenho de `ad_platform_connections` — RLS ligada sem policies, grants de anon/authenticated revogados. Sem função nova em `public`. Recorte da frente de Google Ads do PR #965 (@automatikpg-ux), cuja frente de Instagram sai em PR próprio por decisão do dono do produto; renumerada de 0263 para 0306 porque 0263 já está ocupada na `main` (a numeração original do contribuidor era 0252, renumerada por ele para 0263 ao atualizar a branch). |
@@ -71,7 +71,17 @@ describe("0087 · o canal da sessão chega ao clone", () => {
const cols = sql(`select column_name from information_schema.columns
where table_schema = 'public' and table_name = 'channel_sessions'
and column_name like 'meta\\_%' order by 1`).split("\n");
expect(cols).toEqual(["meta_phone_number_id", "meta_token_encrypted", "meta_waba_id"]);
// As três `meta_webhook_override_*` (migration 0311) entraram de propósito: são o
// desfecho do registro do webhook do número ao conectar o canal oficial. A cerca
// continua valendo — ela existe para pegar coluna que entrou SEM querer.
expect(cols).toEqual([
"meta_phone_number_id",
"meta_token_encrypted",
"meta_waba_id",
"meta_webhook_override_em",
"meta_webhook_override_erro",
"meta_webhook_override_uri",
]);
});
it("waha_session_name deixou de ser obrigatório — senão meta_cloud é inexprimível", () => {
@@ -65,6 +65,7 @@ const RAW: AtritoRaw = {
vetos: 18,
execucoes_medidas: 120,
envios_por_ia: 600,
envios_por_automacao: 150,
envios_humano_no_sistema: 300,
envios_humano_fora: 100,
demandas_sem_proximo_passo: 6,
@@ -299,6 +300,7 @@ describe("zero lisonjeiro — ausência de dado é null, nunca 0", () => {
const vazio = {
...RAW.empresa,
envios_por_ia: 0,
envios_por_automacao: 0,
envios_humano_no_sistema: 0,
envios_humano_fora: 0,
};
@@ -358,3 +360,38 @@ describe("formatação", () => {
expect(formatarDuracao(segundos)).toBe(esperado);
});
});
/**
* O NÚMERO DA AUTOMAÇÃO TEM LUGAR (#652) — o contrário dele mente.
*
* Quando o carimbo da automação saiu de `'ai'` (issue #652), `envios_por_ia`
* CAIU para quem usa regra. A queda é correta — o agente não escreveu aquelas
* mensagens —, mas sem um número próprio ela chegaria na tela como o agente
* encolhendo, sem nada que a explicasse. Este bloco prende o LUGAR do número
* novo: ele é publicado, com o valor que veio do banco, e não infla a conta do
* agente em nenhuma das duas pontas.
*/
describe("o número próprio da automação (#652)", () => {
it("`envios_por_automacao` é publicado no painel, em Contenção", () => {
const contencao = montarPares(RAW).find((p) => p.chave === "contencao");
expect(contencao, "Contenção sumiu do painel").toBeDefined();
const medida = contencao!.danos.find((d) => d.chave === "envios_por_automacao");
expect(
medida,
"o payload traz `envios_por_automacao` e o painel não mostra: a org com automação vê o 'por IA' cair sem explicação na tela",
).toBeDefined();
expect(medida!.valor).toBe(150);
expect(medida!.unidade).toBe("contagem");
expect(formatarMedida(medida!)).toContain("150");
});
it("o número do agente não é inflado pela automação", () => {
const contencao = montarPares(RAW).find((p) => p.chave === "contencao")!;
expect(contencao.eficiencia.valor).toBe(600);
// 600 do agente sobre 600 + 300 + 100 de saídas COM dono entre agente e
// pessoa: a automação (150) não entra em nenhuma das duas pontas, senão o
// número do agente subiria por mensagem que ele não escreveu.
expect(taxaDeAutomacao(RAW.empresa)).toBeCloseTo(0.6, 6);
});
});
@@ -0,0 +1,153 @@
/**
* O CARIMBO DE ORIGEM DA LINHA — quem enviou diz quem enviou (issue #652).
*
* ─── O defeito, medido na main de 17/09/2026 ─────────────────────────────────
*
* `app/api/v1/messages/_handler.ts` gravava `sent_via` com uma pergunta só:
*
* sent_via: ctx.actor.type !== "user" ? "ai" : "user"
*
* Tudo que não era pessoa saía `'ai'` — inclusive o que a AUTOMAÇÃO envia. As
* ações de regra (`lib/automation/actions/send-whatsapp.ts`), o texto fixo do
* follow-up (`lib/followup/enviar-texto-fixo.ts`) e o lembrete de agenda
* (`app/api/v1/cron/agenda-reminder/route.ts`) chamam este handler com
* `actor: { type: "webhook_source" }`, então um template FIXO — sem IA nenhuma
* no caminho — aparecia no balão como "IA" para o dono da conversa.
*
* A decisão do mantenedor (comentário de 16/09/2026 na #652) é categoria
* própria: "mensagem que não foi escrita nem por pessoa nem pela IA ganha
* categoria própria". O valor escolhido aqui é `'automation'` — o mesmo que o
* CHECK de `messages.sent_via` já aceita (`supabase/baseline.sql`) e que o
* union de `lib/types/messaging.ts` declara.
*
* ─── Por que o teste olha a LINHA GRAVADA e não a função ─────────────────────
*
* O que a tela lê é a coluna. Um teste que chamasse a função de decisão
* continuaria verde num handler que a ignorasse — e é exatamente onde o defeito
* morava: a decisão existia (`!== "user"`), só respondia à pergunta errada.
* Aqui o fake é o banco: o INSERT passa por ele e é o valor persistido que a
* asserção lê.
*
* O banco falso é o COMPARTILHADO (`tests/helpers/duble-do-handler.ts`), e não
* um `makeSupabase` local: um dublê por arquivo mede o dublê, não o handler — e
* a catraca `tests/unit/send-message-handler-nao-ganha-novo-duble.test.ts`
* existe justamente porque a lista de legados só encolhe.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { sendMessageHandler } from "@/app/api/v1/messages/_handler";
import type { HandlerCtx } from "@/lib/api/handlers/types";
import type { SendMessageInput } from "@/lib/schemas";
import { criarDubleDoHandler } from "../helpers/duble-do-handler";
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({ storage: { from: () => ({ createSignedUrl: vi.fn() }) } }),
}));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => {}) }));
const ORG = "11111111-1111-4111-8111-111111111111";
const CONV = "22222222-2222-4222-8222-222222222222";
const CONTACT = "33333333-3333-4333-8333-333333333333";
const SESSION = "44444444-4444-4444-8444-444444444444";
const USER = "55555555-5555-4555-8555-555555555555";
/** O que o WAHA devolve no envio: o id BARE, sem o chat. */
const BARE = "3EB0ABCDEF0123456789";
const input = {
conversation_id: CONV,
type: "text",
body: "Thiago, consigo te colocar amanhã às 15h.",
} as SendMessageInput;
function ctxComAtor(actor: HandlerCtx["actor"]): HandlerCtx {
return { organization_id: ORG, actor, requestId: "req-652" };
}
function wahaRespondendo() {
vi.stubEnv("WAHA_API_BASE_URL", "http://localhost:3030");
vi.stubEnv("WAHA_API_KEY", "test-key");
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ id: { id: BARE } }), { status: 200 })),
);
}
/**
* O dublê compartilhado, com a conversa que o caminho de envio lê.
*
* `channel_sessions.metadata` vazio = canal aberto: o gate de pré-go-live não
* bloqueia quando o ator não é pessoa, que é o caso dos dois primeiros testes.
*/
function duble() {
return criarDubleDoHandler({
conversation: {
id: CONV,
organization_id: ORG,
contact_id: CONTACT,
channel_session_id: SESSION,
is_group: false,
group_chat_id: null,
contacts: { phone_number: "+553****8888", wa_identity: null, wa_lid: null, is_blocked: false },
channel_sessions: {
provider: "waha",
waha_session_name: "default",
status: "WORKING",
archived_at: null,
metadata: {},
},
},
});
}
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("o carimbo de origem da linha enviada", () => {
it("⭐ a REGRA DE AUTOMAÇÃO (webhook_source) grava 'automation', nunca 'ai'", async () => {
// O template fixo de uma regra não passa por IA nenhuma. Com o carimbo
// 'ai', o balão mostrado ao dono atribuía à IA um texto que a regra montou.
wahaRespondendo();
const { supabase, capturas } = duble();
await sendMessageHandler(supabase, ctxComAtor({ type: "webhook_source", id: "regra-1" }), input);
const linha = capturas.inserts.messages?.at(-1);
expect(
linha?.sent_via,
"a mensagem da automação se apresentou como IA — é o defeito da #652",
).toBe("automation");
expect(
linha?.sent_by_user_id,
"linha de automação não tem pessoa: `sent_by_user_id` é null",
).toBeNull();
});
it("CONTROLE: a pessoa pelo CRM continua 'user' — e com autoria", async () => {
wahaRespondendo();
const { supabase, capturas } = duble();
await sendMessageHandler(supabase, ctxComAtor({ type: "user", id: USER }), input);
const linha = capturas.inserts.messages?.at(-1);
expect(linha?.sent_via).toBe("user");
expect(linha?.sent_by_user_id).toBe(USER);
});
it("CONTROLE: o agente de IA continua 'ai' — a categoria nova não engoliu a dele", async () => {
// Sem este caso, carimbar TUDO que não é pessoa 'automation' ficaria verde —
// e a IA deixaria de ter rótulo próprio, que é o defeito na direção oposta.
wahaRespondendo();
const { supabase, capturas } = duble();
await sendMessageHandler(
supabase,
ctxComAtor({ type: "ai_agent", id: "agente-1", role: "agent" }),
input,
);
expect(capturas.inserts.messages?.at(-1)?.sent_via).toBe("ai");
});
});
@@ -31,7 +31,7 @@ const PESADOS: Record<string, string[]> = {
};
const SEM_GRUPO: Record<string, string[]> = {
"ci.yml": ["verify", "invariants"],
"e2e.yml": ["e2e"],
"e2e.yml": ["e2e", "e2e-alcance"],
"publish-image.yml": ["imagens-ok", "promover-stable", "a-tag-veio-da-main"],
};
+15 -2
View File
@@ -38,8 +38,21 @@ const RAIZ = path.resolve(__dirname, "../..");
const CAMINHO = ".github/workflows/e2e.yml";
const workflow = readFileSync(path.join(RAIZ, CAMINHO), "utf8");
/** Linhas de COMANDO: comentário que MENCIONA a regra não é a regra. */
const COMANDOS = workflow
/**
* Só o job `e2e-parte`: é nele que o teto corta. O arquivo tem outros jobs com
* `actions/checkout` (o `e2e-alcance` roda antes das partes), e um índice sobre
* o arquivo inteiro compararia o relógio das partes com o checkout de outro job.
*/
const JOB_PARTE = (() => {
const inicio = workflow.indexOf("\n e2e-parte:\n");
if (inicio < 0) return "";
const resto = workflow.slice(inicio + 1);
const fim = resto.slice(1).search(/\n [a-zA-Z0-9_-]+:\n/);
return fim < 0 ? resto : resto.slice(0, fim + 1);
})();
/** Linhas de COMANDO do job: comentário que MENCIONA a regra não é a regra. */
const COMANDOS = JOB_PARTE
.split("\n")
.filter((l) => !l.trim().startsWith("#"))
.map((l) => l.trim());
@@ -0,0 +1,85 @@
/**
* O `e2e` (check OBRIGATÓRIO) passou a aceitar `skipped` das partes num caso só:
* pull_request que não alcança nada que o e2e mede (`e2e-alcance` → `e2e=nao`,
* scripts/pr-alcanca-o-e2e.sh).
*
* Toda porta que aceita `skipped` é uma porta por onde um desligamento passa
* verde (issue #459, gatilho-dos-jobs-de-entrega.test.ts). Por isso o script do
* agregador é EXECUTADO contra a matriz inteira de desfechos, e o conjunto do que
* passa tem de ser exatamente o declarado — mesmo método de
* imagens-ok-so-aceita-pulo-declarado.test.ts.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Sem parser YAML nas dependências: recorte do PRIMEIRO `run: |` do job `e2e`.
function scriptDoAgregador(): string {
const linhas = readFileSync(".github/workflows/e2e.yml", "utf-8").split("\n");
const job = linhas.findIndex((l) => l === " e2e:");
const run = linhas.findIndex((l, i) => i > job && /^\s+run: \|$/.test(l));
const indent = (linhas[run + 1] ?? "").match(/^\s*/)![0].length;
const corpo: string[] = [];
for (const l of linhas.slice(run + 1)) {
if (l.trim() && l.match(/^\s*/)![0].length < indent) break;
corpo.push(l.slice(indent));
}
return corpo.join("\n");
}
const SCRIPT = scriptDoAgregador();
const DESFECHOS = "success failure skipped cancelled";
// Uma invocação de bash para a matriz inteira; o código de saída é lido FORA de
// `if` (dentro da condição o bash desliga o `set -e` do subshell e a sonda
// aceitaria tudo).
function combinacoesAceitas(): string[] {
const programa = `
for EVENTO in pull_request push; do
for ALCANCE in sim nao ""; do
for PORTAO in ${DESFECHOS}; do
for PARTES in ${DESFECHOS}; do
export EVENTO ALCANCE PORTAO PARTES
( eval "$SCRIPT_DO_JOB" ) >/dev/null 2>&1
rc=$?
[ $rc -eq 0 ] && echo "$EVENTO \${ALCANCE:-vazio} $PORTAO $PARTES"
done
done
done
done
true`;
return execFileSync("bash", ["-c", programa], {
env: { ...process.env, SCRIPT_DO_JOB: SCRIPT, GITHUB_STEP_SUMMARY: "/dev/null" },
encoding: "utf-8",
})
.split("\n")
.filter(Boolean);
}
describe("e2e só aceita o pulo declarado", () => {
it("controle positivo: o recorte pegou o script que lê os três resultados", () => {
for (const v of ["$PORTAO", "$ALCANCE", "$PARTES", "$EVENTO"]) {
expect(SCRIPT).toContain(v);
}
});
it("da matriz inteira de desfechos (96), passa exatamente o que foi declarado", { timeout: 60_000 }, () => {
expect(combinacoesAceitas().sort()).toEqual(
[
// PR que alcança: as partes têm de passar.
"pull_request sim success success",
// PR que não alcança: partes puladas, e SÓ puladas — `failure` não é pulo.
"pull_request nao success skipped",
// Fora de PR, a régua de antes: partes `success`, qualquer que seja o
// output (fora de PR ele é sempre `sim`; `nao` aqui não abre porta).
"push sim success success",
"push nao success success",
// Saída vazia com o job de alcance `success` não acontece (ele sempre
// escreve a saída); se acontecer, só passa com as partes MEDIDAS.
"pull_request vazio success success",
"push vazio success success",
].sort(),
);
});
});
@@ -300,3 +300,58 @@ describe("eco do próprio envio — a IA não se cala por ter falado", () => {
expect(conversa.bot_silenced_until, "encurtou um handoff formal").toBe("infinity");
});
});
/**
* O ECO DO ENVIO DA AUTOMAÇÃO (#652).
*
* Depois de o carimbo da automação virar `'automation'`, a linha do envio deixa
* de casar com o filtro de `sent_via` desta checagem — e o eco do próprio envio
* da regra passa a ser lido como "o atendente respondeu pelo celular". O
* desfecho é a IA pausada na conversa (a tela mostra "Automático pausado", um
* estado legítimo que ninguém investiga), causado por uma mensagem que o CRM
* mandou sozinho.
*
* A checagem e o carimbo são duas pontas do MESMO vocabulário: quem mexer numa
* sem a outra reabre `lib/waha/ingest-celular.test.ts:315` (a janela conhecida
* em que o eco duplica) do lado de dentro da regra.
*/
describe("eco do envio da automação — reconhecido como nosso (#652)", () => {
it("⭐ envio da AUTOMAÇÃO em voo + eco com o MESMO texto: o bot NÃO é pausado", async () => {
const { admin, conversa } = banco([emVoo({ sent_via: "automation" })]);
await dispatchWahaEvent(admin as never, SESSION as never, envelope(eco(TEXTO)), "req-652-1");
expect(
conversa.bot_silenced_until,
"a regra mandou uma mensagem e a IA ficou pausada por causa do eco dela mesma",
).toBeNull();
});
it("o envio da automação já CONFIRMADO não vira uma segunda linha na conversa", async () => {
// O eco depois do ack: a linha do envio já tem `external_id`, o dedup por id
// casa e nada é inserido. É o caso normal (o eco chega segundos depois do
// envio voltar), e ele não pode depender do valor de `sent_via`.
const { admin, messages } = banco([
emVoo({ sent_via: "automation", external_id: eco(TEXTO).id, status: "sent" }),
]);
await dispatchWahaEvent(admin as never, SESSION as never, envelope(eco(TEXTO)), "req-652-2");
expect(
messages.length,
"a frase da automação apareceu duas vezes na conversa",
).toBe(1);
});
it("o eco da automação continua GRAVANDO a linha — o gate barra o silêncio, nunca o insert", async () => {
// A direção oposta, e ela é o desenho do #108: gravar é tolerante (perder
// mensagem é pior que duplicar), silenciar é estrito. O caso roda para a
// automação pela mesma razão que roda para a IA e para o composer.
const { admin, messages } = banco([emVoo({ sent_via: "automation" })]);
await dispatchWahaEvent(admin as never, SESSION as never, envelope(eco(TEXTO)), "req-652-3");
expect(messages.length).toBe(2);
});
});
@@ -0,0 +1,130 @@
/**
* O executor próprio (infra/executor-proprio/) só pode rodar trabalho NOSSO.
*
* O repositório é público. Num `pull_request` de fork o GitHub roda o workflow
* da branch do fork, então quem abre o PR pode reescrever `runs-on:` e mirar a
* nossa máquina. Duas camadas, e este arquivo vigia as duas:
*
* 1. A GUARDA na máquina (so-o-que-e-nosso.sh, gravada na imagem): é a que
* vale contra fork. Ela é EXECUTADA aqui contra payloads de cada origem.
* 2. O ROTEAMENTO nos nossos workflows: decide para quem não o edita. Só os
* jobs pesados vão para a máquina, e a publicação na `main` nunca vai —
* imagem que o parque instala se constrói nas máquinas do GitHub.
*/
import { execFileSync } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const GUARDA = "infra/executor-proprio/so-o-que-e-nosso.sh";
const REPO = "melgarafael/DeskcommCRM";
function guarda(evento: string, payload: unknown, repo = REPO): number {
const dir = mkdtempSync(join(tmpdir(), "guarda-"));
const caminho = join(dir, "evento.json");
writeFileSync(caminho, typeof payload === "string" ? payload : JSON.stringify(payload));
try {
execFileSync("bash", [GUARDA], {
// Herda o ambiente (ProcessEnv exige NODE_ENV) e SOBRESCREVE as três que a
// guarda lê — no CI elas existem e descreveriam o job de verdade.
env: { ...process.env, GITHUB_REPOSITORY: repo, GITHUB_EVENT_NAME: evento, GITHUB_EVENT_PATH: caminho },
stdio: "pipe",
});
return 0;
} catch (e) {
return (e as { status: number }).status;
}
}
const prDe = (origem: string | null) => ({ pull_request: { head: { repo: origem ? { full_name: origem } : null } } });
describe("a guarda da máquina", () => {
it.each([
["push", {}],
["workflow_dispatch", {}],
["schedule", {}],
["merge_group", {}],
["pull_request", prDe(REPO)],
])("aceita %s de dentro do repositório", (evento, payload) => {
expect(guarda(evento, payload)).toBe(0);
});
it.each([
["pull_request de fork", "pull_request", prDe("alguem/DeskcommCRM")],
["pull_request com o fork apagado", "pull_request", prDe(null)],
["pull_request com payload ilegível", "pull_request", "{isto não é json"],
["pull_request_target", "pull_request_target", prDe(REPO)],
["issue_comment", "issue_comment", {}],
["workflow_run", "workflow_run", {}],
["evento vazio", "", {}],
])("recusa %s", (_nome, evento, payload) => {
expect(guarda(evento, payload)).not.toBe(0);
});
it("recusa outro repositório mesmo com evento aceito", () => {
expect(guarda("push", {}, "alguem/OutroRepo")).not.toBe(0);
});
it("a imagem instala a guarda como hook de entrada do runner", () => {
const dockerfile = readFileSync("infra/executor-proprio/Dockerfile", "utf-8");
expect(dockerfile).toContain("ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/deskcomm/so-o-que-e-nosso.sh");
expect(dockerfile).toMatch(/COPY so-o-que-e-nosso\.sh .*\/opt\/deskcomm\//);
});
});
// --- roteamento --------------------------------------------------------------
const TODOS =
"${{ vars.EXECUTOR_PROPRIO == 'ligado' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && 'deskcomm-proprio' || 'ubuntu-latest' }}";
const SO_PR =
"${{ vars.EXECUTOR_PROPRIO == 'ligado' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && 'deskcomm-proprio' || 'ubuntu-latest' }}";
const ESPERADO: Record<string, string> = {
"ci.yml::verify-parte": TODOS,
"ci.yml::invariants-majors": TODOS,
"e2e.yml::e2e-parte": TODOS,
"perf.yml::build-and-size": TODOS,
"publish-image.yml::imagem-do-app-sobe": SO_PR,
"publish-image.yml::imagens-de-fundo-sobem": SO_PR,
};
// Sem parser YAML nas dependências: o `runs-on:` de 4 espaços pertence ao
// último job de 2 espaços visto antes dele.
function runsOnPorJob(): Map<string, string> {
const mapa = new Map<string, string>();
for (const arquivo of readdirSync(".github/workflows").filter((a) => /\.ya?ml$/.test(a))) {
let job = "";
let emJobs = false;
for (const linha of readFileSync(`.github/workflows/${arquivo}`, "utf-8").split("\n")) {
if (/^jobs:\s*$/.test(linha)) emJobs = true;
const j = emJobs && linha.match(/^ {2}([a-zA-Z0-9_-]+):\s*$/);
if (j) job = j[1]!;
const r = linha.match(/^ {4}runs-on: (.*)$/);
if (r && job) mapa.set(`${arquivo}::${job}`, r[1]!.trim());
}
}
return mapa;
}
describe("o roteamento dos workflows", () => {
const mapa = runsOnPorJob();
it("controle positivo: o recorte enxerga os jobs", () => {
expect(mapa.size).toBeGreaterThanOrEqual(12);
expect([...mapa.values()]).toContain("ubuntu-latest");
});
it("exatamente os jobs pesados podem ir para a máquina, cada um com a expressão declarada", () => {
const naMaquina = Object.fromEntries([...mapa].filter(([, v]) => v.includes("deskcomm-proprio")));
expect(naMaquina).toEqual(ESPERADO);
});
it("nenhum workflow com pull_request_target manda job para a máquina", () => {
for (const arquivo of readdirSync(".github/workflows")) {
const texto = readFileSync(`.github/workflows/${arquivo}`, "utf-8");
if (/^\s+pull_request_target:/m.test(texto)) expect(texto, arquivo).not.toContain("deskcomm-proprio");
}
});
});
+11 -2
View File
@@ -190,9 +190,18 @@ const GATILHO_ESPERADO: Record<string, { condicao: string | null; efeito: string
"workflow chamava desde 2026-08-27, e `test:db` sozinho mede um banco VAZIO — constraint " +
"que só quebra com linha existente passava verde.",
},
"e2e.yml::e2e-parte": {
"e2e.yml::e2e-alcance": {
condicao: null,
efeito: "São as partes da matriz Playwright; sem elas o `e2e` fica sem nada para ler.",
efeito:
"Este job responde se o PR alcança algo que o e2e mede. Sem ele as partes nunca " +
"rodam e o agregador `e2e` reprova — o PORTAO dele exige `success` aqui.",
},
"e2e.yml::e2e-parte": {
condicao: "needs.e2e-alcance.outputs.e2e == 'sim'",
efeito:
"São as partes da matriz Playwright. Só pulam em PR que não alcança nada que o " +
"e2e mede (scripts/pr-alcanca-o-e2e.sh), e o agregador `e2e` só aceita o pulo com " +
"`e2e=nao` — vigiado por e2e-so-aceita-pulo-declarado.test.ts.",
},
"e2e.yml::e2e": {
condicao: "always()",
@@ -0,0 +1,92 @@
/**
* O CARTÃO DO GOOGLE ADS SEM AS CREDENCIAIS DA INSTALAÇÃO.
*
* `googleAdsEstaConfigurado` e `faltaParaConectarOGoogleAds` entraram na main
* (#1165) sem nenhum chamador — medido: `git grep` fora de `config.ts` vazio.
* O cartão mostrava "Conectar com Google" numa instalação sem as três
* variáveis, e o clique terminava em `google_ads_nao_configurado`; o
* fragmento de release prometia o contrário ("o botão não aparece").
*
* Os casos testam o PAR (sem credencial → sem botão E com o que falta; com
* credencial → o botão), e o último amarra a PÁGINA às duas funções: um
* `configurado` cravado em `true` no call site passaria nos dois primeiros.
*/
import fs from "node:fs";
import path from "node:path";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FormularioDeConversoesGoogle } from "@/app/app/settings/conversoes/_formGoogle";
import type { EstadoDaConexaoGoogle } from "@/lib/plataformas-de-anuncio/google/estado-da-conexao";
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
vi.mock("@/app/actions/settings/updateGoogleAdsConnection", () => ({
updateGoogleAdsConnection: vi.fn(),
}));
afterEach(cleanup);
const SEM_CONEXAO: EstadoDaConexaoGoogle = {
temRefreshToken: false,
habilitada: false,
customerId: null,
loginCustomerId: null,
conversionActionId: null,
};
const LINK_DE_CONECTAR = /\/api\/v1\/plataformas-de-anuncio\/google\/connect/;
function linksDeConectar(container: HTMLElement): Element[] {
return Array.from(container.querySelectorAll("a")).filter((a) =>
LINK_DE_CONECTAR.test(a.getAttribute("href") ?? ""),
);
}
describe("cartão do Google Ads", () => {
it("sem as credenciais: nenhum link de conectar, e diz o que falta pelo nome", () => {
const { container } = render(
<FormularioDeConversoesGoogle
estado={SEM_CONEXAO}
idioma="pt-BR"
configurado={false}
falta={["GOOGLE_ADS_DEVELOPER_TOKEN", "GOOGLE_ADS_OAUTH_CLIENT_SECRET"]}
/>,
);
expect(linksDeConectar(container)).toHaveLength(0);
expect(screen.getByTestId("google-ads-nao-configurado")).toBeTruthy();
const falta = screen.getByTestId("google-ads-o-que-falta").textContent ?? "";
expect(falta).toContain("GOOGLE_ADS_DEVELOPER_TOKEN");
expect(falta).toContain("GOOGLE_ADS_OAUTH_CLIENT_SECRET");
});
it("sem as credenciais e JÁ conectada antes: nem o formulário nem o reconectar aparecem", () => {
const { container } = render(
<FormularioDeConversoesGoogle
estado={{ ...SEM_CONEXAO, temRefreshToken: true, customerId: "1234567890" }}
idioma="pt-BR"
configurado={false}
falta={["GOOGLE_ADS_DEVELOPER_TOKEN"]}
/>,
);
expect(linksDeConectar(container)).toHaveLength(0);
expect(container.querySelector("form")).toBeNull();
});
it("com as credenciais: o botão de conectar aparece", () => {
const { container } = render(
<FormularioDeConversoesGoogle estado={SEM_CONEXAO} idioma="pt-BR" configurado falta={[]} />,
);
expect(linksDeConectar(container)).toHaveLength(1);
expect(screen.queryByTestId("google-ads-nao-configurado")).toBeNull();
});
it("a página passa as duas funções da config — não um valor cravado", () => {
const fonte = fs.readFileSync(
path.join(__dirname, "..", "..", "app", "app", "settings", "conversoes", "page.tsx"),
"utf8",
);
expect(fonte).toMatch(/configurado=\{googleAdsEstaConfigurado\(\)\}/);
expect(fonte).toMatch(/falta=\{faltaParaConectarOGoogleAds\(\)\}/);
});
});
@@ -0,0 +1,180 @@
/**
* A MENSAGEM ESCRITA PELA IA SEGUE IA — mesmo quando uma regra a dispara (#652).
*
* ─── O que este teste protege ────────────────────────────────────────────────
*
* A decisão do mantenedor na #652 (16/09/2026) classifica `messages.sent_via`
* por AUTORIA: `'automation'` é a mensagem que "não foi escrita nem por pessoa
* nem pela IA" — template fixo de regra, texto fixo de follow-up, lembrete de
* agenda. A ação `send_ai_message` ("Mensagem escrita pela IA",
* `lib/automation/actions/send-ai-message.ts`) é disparada por uma regra, mas o
* TEXTO é escrito por um agente publicado (`gerarAbordagemDeFormulario`). Pela
* decisão, a linha é da IA.
*
* O risco é silencioso: a ação chama `sendMessageHandler` com
* `actor: { type: "webhook_source" }` — o MESMO ator das ações de template —, e
* um carimbo decidido só pelo tipo do ator reclassifica a mensagem da IA como
* automação sem conflito de merge, sem erro de tipo e sem teste vermelho: o
* balão passa a dizer "Automação" e a mensagem sai de `envios_por_ia`.
*
* ─── Por que merece catraca ──────────────────────────────────────────────────
*
* O teste executa a AÇÃO registrada (não a função de decisão) e lê a LINHA que o
* handler real insere num fake do banco — é a coluna que a tela e a métrica
* leem. O que é dublado são as guardas e o modelo, que não decidem o carimbo;
* `sendMessageHandler` e `origemDaMensagem` rodam de verdade.
*
* Anti-vacuidade: o caso de controle executa `send_whatsapp_message` (template,
* mesmo ator) pelo mesmo caminho e exige que a linha exista — sem ele, uma ação
* que nunca chegasse ao INSERT deixaria o caso principal vermelho pelo motivo
* errado, e um fake quebrado não seria distinguível do defeito.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ActionCtx } from "@/lib/automation/types";
const ORG = "11111111-1111-4111-8111-111111111111";
const CONV = "22222222-2222-4222-8222-222222222222";
const CONTACT = "33333333-3333-4333-8333-333333333333";
const SESSION = "44444444-4444-4444-8444-444444444444";
const AGENTE = "66666666-6666-4666-8666-666666666666";
const BARE = "3EB0ABCDEF0123456789";
const FRONTEIRA = {
organization_id: ORG,
contact_id: CONTACT,
conversation_id: CONV,
service_revision: 1,
demanda_id: null,
demanda_revision: null,
};
// ─── O modelo: a chamada paga. O texto que ele "escreve" é o dado da ação. ───
vi.mock("@/lib/agent-engine/agent/abordagem-de-formulario", () => ({
gerarAbordagemDeFormulario: vi.fn(async () => ({ ok: true, texto: "Oi Thiago, vi seu formulário." })),
}));
vi.mock("@/lib/agent-engine/db/request-pool", () => ({ getRequestPool: () => ({}) }));
vi.mock("@/lib/automation/dados-do-formulario", () => ({
dadosDoFormularioDoContexto: vi.fn(async () => ({ dados: {}, origem: null, veioDeFormulario: false })),
}));
// ─── Guardas que não decidem o carimbo ───
vi.mock("@/lib/ai/elegibilidade/consulta-pre-go-live", () => ({
decidirPreGoLiveDoCanalViaSupabase: vi.fn(async () => ({ permite: true })),
}));
vi.mock("@/lib/ai/elegibilidade/autorizacao", () => ({ autorizarContatoParaIA: vi.fn(async () => {}) }));
vi.mock("@/lib/atendimento/origem-automacao", () => ({
serviceForAutomation: vi.fn(async () => FRONTEIRA),
}));
vi.mock("@/lib/atendimento/origem", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/atendimento/origem")>()),
assertServiceBoundarySupabase: vi.fn(async () => {}),
}));
vi.mock("@/lib/agenda/efeito", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/agenda/efeito")>()),
assertAgendaEffectSupabase: vi.fn(async () => {}),
}));
vi.mock("@/lib/automation/throttle", () => ({
espacarEnvio: vi.fn(async () => {}),
checkDailyLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/automation/desfecho-do-envio", () => ({
reportarEnvio: vi.fn(async (_ctx: unknown, type: string) => ({ type, status: "success", detail: {} })),
}));
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({ storage: { from: () => ({ createSignedUrl: vi.fn() }) } }),
}));
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => {}) }));
import { criarDubleDoHandler } from "../helpers/duble-do-handler";
import { getAction } from "@/lib/automation/actions";
import "@/lib/automation/actions/send-ai-message";
import "@/lib/automation/actions/send-whatsapp";
type Row = Record<string, unknown>;
/** Fake do banco no molde de `automacao-carimbo-de-origem.test.ts`: o INSERT guarda a linha. */
/**
* O banco falso é o COMPARTILHADO (`tests/helpers/duble-do-handler.ts`). Um dublê
* local a mais faria `send-message-handler-nao-ganha-novo-duble` reprovar — e com
* razão: cada cópia é um lugar onde o contrato do handler pode divergir do real
* sem ninguém ver.
*/
function duble() {
return criarDubleDoHandler({
conversation: {
id: CONV,
organization_id: ORG,
contact_id: CONTACT,
channel_session_id: SESSION,
is_group: false,
group_chat_id: null,
contacts: { phone_number: "+5531999998888", wa_identity: null, wa_lid: null, is_blocked: false },
channel_sessions: {
provider: "waha",
waha_session_name: "default",
status: "WORKING",
archived_at: null,
metadata: {},
},
},
});
}
function ctxDaRegra(admin: SupabaseClient): ActionCtx {
return {
admin,
organizationId: ORG,
ruleId: "regra-formulario",
ruleName: "Lead do formulário",
event: { id: "evento-1" } as unknown as ActionCtx["event"],
context: { contact: { id: CONTACT, phone_number: "+5531999998888", is_blocked: false } },
requestId: "req-652-ia",
};
}
function wahaRespondendo() {
vi.stubEnv("WAHA_API_BASE_URL", "http://localhost:3030");
vi.stubEnv("WAHA_API_KEY", "test-key");
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ id: { id: BARE } }), { status: 200 })));
}
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("a mensagem escrita pela IA numa regra de automação", () => {
it("⭐ send_ai_message grava sent_via='ai' — a autoria é da IA, não da regra", async () => {
wahaRespondendo();
const { supabase, capturas } = duble();
const resultado = await getAction("send_ai_message")!.execute(ctxDaRegra(supabase), {
channel_session_id: SESSION,
agent_id: AGENTE,
instruction: "Cumprimente o lead pelo nome.",
});
expect(capturas.inserts.messages ?? [], `a ação não chegou ao INSERT: ${JSON.stringify(resultado)}`).toHaveLength(1);
expect(capturas.inserts.messages?.at(-1)?.body).toBe("Oi Thiago, vi seu formulário.");
expect(
capturas.inserts.messages?.at(-1)?.sent_via,
"a mensagem ESCRITA PELA IA foi carimbada como automação — a decisão da #652 é por autoria",
).toBe("ai");
});
it("CONTROLE: send_whatsapp_message (template da regra) chega ao mesmo INSERT", async () => {
wahaRespondendo();
const { supabase, capturas } = duble();
const resultado = await getAction("send_whatsapp_message")!.execute(ctxDaRegra(supabase), {
channel_session_id: SESSION,
template: "Oi, recebemos seu formulário.",
});
expect(capturas.inserts.messages ?? [], `o controle não chegou ao INSERT: ${JSON.stringify(resultado)}`).toHaveLength(1);
expect(capturas.inserts.messages?.at(-1)?.sent_via).not.toBeUndefined();
});
});
@@ -0,0 +1,129 @@
/**
* `OPENROUTER_BASE_URL` vale em TODO caminho que fala com a OpenRouter — não só
* no `resolveLanguageModel` (que `gateway-destino-por-caminho.test.ts` já cobre).
*
* O agente publicado (botão "Sugerir resposta", no app) e o turno do worker
* montavam o cliente com o endereço fixo `openrouter.ai`. Quem apontava a
* variável para um gateway compatível via os pontos do painel funcionarem e o
* agente morrer com `401 Missing Authentication header`: a chave do gateway ia
* para a OpenRouter.
*
* Técnica: `globalThis.fetch` interceptado, SDK real no caminho, nenhuma
* chamada de rede sai. A asserção é o host de destino.
*/
import type { LanguageModel } from "ai";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const envMock: Record<string, string> = {};
vi.mock("@/lib/env", () => ({
get env() {
return envMock;
},
}));
// Só o 4º caso lê banco: binding da organização em openrouter, sem `base_url`
// no painel. `@/lib/ai/gateway` fica REAL — é a constante dele que está em jogo.
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
from: (tabela: string) => {
const linha =
tabela === "ai_purpose_bindings"
? { provider: "openrouter", credential_id: "cred-1", model_id: "qwen3.8-flash", base_url: null }
: { api_key_encrypted: "x", api_key_iv: "y", api_key_tag: "z" };
const chain = {
select: () => chain,
eq: () => chain,
not: () => chain,
maybeSingle: async () => ({ data: linha }),
};
return chain;
},
}),
}));
vi.mock("@/lib/crypto/aes_gcm", () => ({
decryptKey: () => "chave-decifrada-da-organizacao",
byteaToBuffer: (v: unknown) => v,
}));
const PROXY = "https://meu-proxy.example.com/v1";
let fetchOriginal: typeof globalThis.fetch;
let destinos: string[];
async function destinoDe(model: LanguageModel) {
const { generateText } = await import("ai");
try {
await generateText({ model, prompt: "oi" });
} catch {
// O stub não imita o formato do provedor; o host já foi capturado.
}
return destinos;
}
// A primeira importação de `lib/ai/runtime/agent` transforma um grafo grande
// (medido: 34s numa máquina com load 63). Paga-se aqui, com prazo próprio, para
// o primeiro caso não estourar os 15s do teste; o `resetModules` de cada caso
// reavalia os módulos, mas a transformação fica em cache.
beforeAll(async () => {
await import("@/lib/ai/runtime/agent");
}, 120_000);
beforeEach(() => {
destinos = [];
fetchOriginal = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
destinos.push(new URL(url).host);
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
}) as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = fetchOriginal;
vi.unstubAllEnvs();
vi.resetModules();
});
describe("OPENROUTER_BASE_URL em todo caminho", () => {
it("agente publicado (app) vai ao gateway da variável", async () => {
vi.stubEnv("OPENROUTER_BASE_URL", PROXY);
vi.resetModules();
const { buildModel } = await import("@/lib/ai/runtime/agent");
expect(await destinoDe(buildModel("openrouter", "sk-x", "qwen3.8-flash"))).toEqual([
"meu-proxy.example.com",
]);
});
it("turno do worker, sem base_url no painel, vai ao gateway da variável", async () => {
vi.stubEnv("OPENROUTER_BASE_URL", PROXY);
vi.resetModules();
const { createDefaultRegistry } = await import("@/lib/agent-engine/edge/llm/providers");
const model = createDefaultRegistry().openrouter!("sk-x", "qwen3.8-flash");
expect(await destinoDe(model)).toEqual(["meu-proxy.example.com"]);
});
it("credencial da organização, sem base_url no painel, vai ao gateway da variável", async () => {
// Caminho de `lib/ai/gateway-binding.ts` (`instanciar`), que lê a constante
// `OPENROUTER_BASE_URL` de `lib/ai/gateway.ts` — e não o `env` do módulo.
vi.stubEnv("OPENROUTER_BASE_URL", PROXY);
vi.resetModules();
const { resolverModeloDoPonto } = await import("@/lib/ai/gateway-binding");
const r = await resolverModeloDoPonto(
"sentiment_classify",
"33333333-3333-4333-8333-333333333333",
"anthropic/claude-haiku-4-5",
);
expect(r?.origem).toBe("binding");
expect(await destinoDe(r!.model)).toEqual(["meu-proxy.example.com"]);
});
it("sem a variável, continua na OpenRouter", async () => {
vi.stubEnv("OPENROUTER_BASE_URL", "");
vi.resetModules();
const { buildModel } = await import("@/lib/ai/runtime/agent");
expect(await destinoDe(buildModel("openrouter", "sk-x", "qwen3.8-flash"))).toEqual([
"openrouter.ai",
]);
});
});
+107 -15
View File
@@ -292,43 +292,135 @@ describe("a origem só vale na primeira mensagem do contato", () => {
return { admin, filtros, ordens };
}
/** As colunas que a consulta pode filtrar — em snake_case, como no banco. */
type LinhaDeMensagem = {
id: string;
organization_id: string;
contact_id: string;
direction: string;
sent_at: string;
};
/**
* Um banco de MENTIRA com as DUAS organizações, que aplica os filtros.
*
* O outro fake só anota os `eq` — prende a FORMA da consulta. Este prende o
* EFEITO: sem o filtro de organização, a linha da outra organização entra no
* resultado e a resposta muda. Não há RLS aqui, do mesmo jeito que não há no
* client de admin (service role) que a função usa.
*/
function bancoDeDuasOrganizacoes(linhas: LinhaDeMensagem[]) {
const filtros: Record<string, unknown> = {};
const consulta = {
eq(coluna: string, valor: unknown) {
filtros[coluna] = valor;
return consulta;
},
order() {
return consulta;
},
limit() {
return consulta;
},
async maybeSingle() {
const encontradas = linhas
.filter((linha) =>
Object.entries(filtros).every(
([coluna, valor]) => (linha as Record<string, unknown>)[coluna] === valor,
),
)
.sort((a, b) => a.sent_at.localeCompare(b.sent_at));
return {
data: encontradas[0] ? { id: encontradas[0].id } : null,
count: encontradas.length,
error: null,
};
},
};
const admin = { from: () => ({ select: () => consulta }) } as never;
return { admin, filtros };
}
it("a mensagem que chegou agora é a mais antiga de entrada: vale", async () => {
const { admin } = bancoDeMensagens({ id: "msg-1", count: 1 });
await expect(ehAPrimeiraMensagemDoContato(admin, "contato-1", "msg-1")).resolves.toBe(true);
await expect(ehAPrimeiraMensagemDoContato(admin, "org-1", "contato-1", "msg-1")).resolves.toBe(
true,
);
});
it("já havia mensagem de entrada antes desta: NÃO vale", async () => {
// É o caso do link encaminhado adiante: o código chega, mas não é a
// primeira coisa que este contato escreveu. A origem não entra.
const { admin } = bancoDeMensagens({ id: "msg-0", count: 2 });
await expect(ehAPrimeiraMensagemDoContato(admin, "contato-1", "msg-1")).resolves.toBe(false);
await expect(ehAPrimeiraMensagemDoContato(admin, "org-1", "contato-1", "msg-1")).resolves.toBe(
false,
);
});
it("reentrega (sem id novo) só vale quando há UMA única mensagem de entrada", async () => {
const uma = bancoDeMensagens({ id: "msg-1", count: 1 });
await expect(ehAPrimeiraMensagemDoContato(uma.admin, "contato-1", null)).resolves.toBe(true);
await expect(ehAPrimeiraMensagemDoContato(uma.admin, "org-1", "contato-1", null)).resolves.toBe(
true,
);
const varias = bancoDeMensagens({ id: "msg-1", count: 3 });
await expect(ehAPrimeiraMensagemDoContato(varias.admin, "contato-1", null)).resolves.toBe(false);
await expect(
ehAPrimeiraMensagemDoContato(varias.admin, "org-1", "contato-1", null),
).resolves.toBe(false);
});
it("sem linha nenhuma, e com erro de leitura, não vale: na dúvida não se grava", async () => {
const vazio = bancoDeMensagens({ id: null, count: 0 });
await expect(ehAPrimeiraMensagemDoContato(vazio.admin, "contato-1", "msg-1")).resolves.toBe(
false,
);
await expect(
ehAPrimeiraMensagemDoContato(vazio.admin, "org-1", "contato-1", "msg-1"),
).resolves.toBe(false);
const comErro = bancoDeMensagens({ id: "msg-1", count: 1 }, true);
await expect(ehAPrimeiraMensagemDoContato(comErro.admin, "contato-1", "msg-1")).resolves.toBe(
false,
);
await expect(
ehAPrimeiraMensagemDoContato(comErro.admin, "org-1", "contato-1", "msg-1"),
).resolves.toBe(false);
});
it("a pergunta é sobre as mensagens de ENTRADA deste contato, e na ordem de chegada", async () => {
// Se a consulta não filtrasse por contato, a origem de um contato decidiria
// a de outro; se não filtrasse por `inbound`, um envio nosso contaria como
it("a pergunta é sobre as mensagens de ENTRADA desta organização e deste contato, na ordem de chegada", async () => {
// Se a consulta não filtrasse por organização, a resposta seria sobre um
// contato que não é deste tenant; sem `contact_id`, a de um contato
// decidiria a de outro; sem `inbound`, um envio nosso contaria como
// primeira mensagem dele.
const { admin, filtros, ordens } = bancoDeMensagens({ id: "msg-1", count: 1 });
await ehAPrimeiraMensagemDoContato(admin, "contato-9", "msg-1");
expect(filtros).toEqual({ contact_id: "contato-9", direction: "inbound" });
await ehAPrimeiraMensagemDoContato(admin, "org-9", "contato-9", "msg-1");
expect(filtros).toEqual({
organization_id: "org-9",
contact_id: "contato-9",
direction: "inbound",
});
expect(ordens).toEqual(["sent_at", "created_at"]);
});
it("mensagem de ENTRADA de outra organização não decide esta (#1108)", async () => {
// O `contact_id` de hoje é uuid e não colide entre tenants: este caso não
// encena uma colisão real, ele prende a REGRA — a consulta sai pelo client
// de admin, sem RLS, então o que existe fora da fronteira não pode entrar
// na resposta. A mensagem da outra organização é a mais antiga: sem o
// filtro é ela que responde, e a origem da página deste contato passaria a
// ser decidida por dado de outro tenant.
const { admin, filtros } = bancoDeDuasOrganizacoes([
{
id: "msg-de-fora",
organization_id: "org-b",
contact_id: "contato-1",
direction: "inbound",
sent_at: "2026-01-01T00:00:00.000Z",
},
{
id: "msg-1",
organization_id: "org-a",
contact_id: "contato-1",
direction: "inbound",
sent_at: "2026-02-01T00:00:00.000Z",
},
]);
await expect(
ehAPrimeiraMensagemDoContato(admin, "org-a", "contato-1", "msg-1"),
).resolves.toBe(true);
expect(filtros.organization_id).toBe("org-a");
});
});
+63
View File
@@ -0,0 +1,63 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
// scripts/pr-alcanca-o-e2e.sh decide se um PR pula as três partes do e2e. Errar
// para "nao" deixa passar regressão de tela com o check obrigatório `e2e` verde.
const SCRIPT = "scripts/pr-alcanca-o-e2e.sh";
function responde(caminhos: string[]): string {
return execFileSync("bash", [SCRIPT], { input: caminhos.join("\n") + "\n", encoding: "utf-8" }).trim();
}
describe("pr-alcanca-o-e2e", () => {
it.each([
["app/(app)/leads/page.tsx"],
["components/ui/button.tsx"],
["lib/qualquer.ts"],
["hooks/use-x.ts"],
["supabase/baseline.sql"],
["supabase/migrations/20260918000000_0300_x.sql"],
["tests/e2e/leads.spec.ts"],
["tests/e2e/helpers/login.ts"],
["tests/setup/vitest.setup.ts"],
["tests/fixtures/x.json"],
["scripts/seed-e2e-credentials.ts"],
["scripts/gerar-env-e2e.sh"],
["package.json"],
["pnpm-lock.yaml"],
["playwright.config.ts"],
["next.config.ts"],
["middleware.ts"],
[".github/workflows/e2e.yml"],
[".github/actions/preparar-node/action.yml"],
["lib/agent-engine/playbooks/platform.md"],
["hostgator-setup-kit/install.sh"],
])("%s → roda", (caminho) => {
expect(responde(["docs/leia.md", caminho])).toBe("sim");
});
it("entrada vazia roda — não saber o que mudou nunca vira 'pula'", () => {
expect(responde([])).toBe("sim");
});
it("PR só de documentação, teste de outra suíte, fragmento, workflow alheio e executor pula", () => {
expect(
responde([
"docs/runbooks/deploy.md",
"tasks/todo.md",
".changes/fragmento.md",
"tests/unit/x.test.ts",
"tests/invariants/y.test.ts",
"tests/cercas/selecao.ts",
"tests/shell/z.test.sh",
"lib/api/wrappers.test.ts",
"components/x/y.test.tsx",
".github/workflows/ci.yml",
".github/PULL_REQUEST_TEMPLATE.md",
"CLAUDE.md",
"infra/executor-proprio/vaga.sh",
]),
).toBe("nao");
});
});
@@ -128,6 +128,20 @@ function valoresEmitidos(): Map<string, string[]> {
mapa.set(v, [...(mapa.get(v) ?? []), path.relative(RAIZ, arquivo)]);
}
}
// A decisão da origem passou a viver numa FUNÇÃO nomeada (#652,
// `origemDaMensagem`): a cadeia com três ramos não cabe numa linha (o
// prettier quebra em 100 colunas) e o extrator de par só lia dois valores.
// Aqui a função citada na escrita é aberta e os literais que ela DEVOLVE
// contam como emissores — a forma que o próximo ramo vai usar.
for (const m of src.matchAll(/sent_via:\s*([a-zA-Z_$][\w$]*)\s*\(/g)) {
const nome = m[1]!;
const definicao = new RegExp(`function\\s+${nome}\\s*\\([\\s\\S]*?\\n\\}`).exec(src);
if (!definicao) continue;
for (const devolvido of definicao[0].matchAll(/return\s+"([a-z_]+)"/g)) {
const v = devolvido[1]!;
mapa.set(v, [...(mapa.get(v) ?? []), path.relative(RAIZ, arquivo)]);
}
}
}
}
return mapa;
@@ -0,0 +1,119 @@
/**
* A VERSÃO DA API DO GOOGLE ADS: viva, e num lugar só.
*
* O transporte de conversões entrou na main com `"v17"` escrito à mão, e a v17
* já estava desativada: toda chamada voltava 404 em HTML, lido como erro
* permanente — cada venda virava `recusado_pela_plataforma` sem nova tentativa.
* Nenhum teste olhava o número, porque o número não tinha dono.
*
* Três guardas, cada uma por um modo de falha:
*
* 1. **Piso da janela viva.** A mais velha ainda viva em 18/09/2026 é a v22
* (página de sunset do Google, citada em `versao-da-api.ts`). Voltar para
* abaixo disso reprova. O piso só SOBE, e sobe quando o Google desativar a
* v22 — é a mesma manutenção do bump.
* 2. **Um lugar só.** Versão do Google Ads escrita fora de `versao-da-api.ts`
* reprova — o molde é `versao-da-graph-num-lugar-so.test.ts`.
* 3. **Motivo legível.** O 404 da versão desativada chega em HTML; o detalhe
* que vai à tela não pode ser marcação.
*
* Ponto cego DECLARADO: a catraca lê o literal escrito no fonte. Versão
* MONTADA (`"v" + 25`) passa — quem escreve assim está driblando de propósito.
*/
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { INTERNOS } from "@/lib/plataformas-de-anuncio/google/conversions";
import { VERSAO_DA_API_DO_GOOGLE_ADS } from "@/lib/plataformas-de-anuncio/google/versao-da-api";
const RAIZ = path.join(__dirname, "..", "..");
/** A mais velha ainda viva, medida em 18/09/2026. Só sobe. */
const PISO_DA_JANELA_VIVA = 22;
const MODULO_UNICO = path.join("lib", "plataformas-de-anuncio", "google", "versao-da-api.ts");
const PASTA_DO_GOOGLE_ADS = path.join("lib", "plataformas-de-anuncio", "google");
const ONDE_PROCURAR = ["app", "lib", "components", "hooks", "workers", "scripts"];
const PASTAS_IGNORADAS = new Set(["node_modules", ".git", ".next"]);
const CODIGO = /\.(ts|tsx|mjs|js)$/;
/** Endereço do Google Ads com versão embutida, em qualquer arquivo. */
const ENDERECO_COM_VERSAO = /googleads\.googleapis\.com\/v\d+/;
/** Literal de versão solto (`"v25"`, `/v25/`) — só dentro da pasta do Google Ads. */
const LITERAL_DE_VERSAO = /(?<=["'`/])v\d{2}(?=["'`/:])/;
function arquivosDeCodigo(dir: string): string[] {
const abs = path.join(RAIZ, dir);
if (!fs.existsSync(abs)) return [];
const saida: string[] = [];
for (const entrada of fs.readdirSync(abs, { withFileTypes: true })) {
if (PASTAS_IGNORADAS.has(entrada.name)) continue;
const rel = path.join(dir, entrada.name);
if (entrada.isDirectory()) saida.push(...arquivosDeCodigo(rel));
else if (CODIGO.test(entrada.name) && !/\.test\.tsx?$/.test(entrada.name)) saida.push(rel);
}
return saida;
}
/** Tira comentários de linha e de bloco — história medida em comentário não reprova. */
function semComentarios(fonte: string): string {
return fonte.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
describe("versão da API do Google Ads", () => {
it(`está dentro da janela viva (>= v${PISO_DA_JANELA_VIVA}) — a v17 da main reprovava aqui`, () => {
const casa = /^v(\d+)$/.exec(VERSAO_DA_API_DO_GOOGLE_ADS);
expect(casa, `formato inesperado: ${VERSAO_DA_API_DO_GOOGLE_ADS}`).not.toBeNull();
expect(Number(casa![1])).toBeGreaterThanOrEqual(PISO_DA_JANELA_VIVA);
});
it("mora num lugar só: nenhum outro arquivo de produção escreve a versão", () => {
const infratores: string[] = [];
for (const arquivo of ONDE_PROCURAR.flatMap(arquivosDeCodigo)) {
if (arquivo === MODULO_UNICO) continue;
const fonte = semComentarios(fs.readFileSync(path.join(RAIZ, arquivo), "utf8"));
const naPasta = arquivo.startsWith(PASTA_DO_GOOGLE_ADS + path.sep);
if (ENDERECO_COM_VERSAO.test(fonte) || (naPasta && LITERAL_DE_VERSAO.test(fonte))) {
infratores.push(arquivo);
}
}
expect(infratores).toEqual([]);
});
it("o transporte monta o endereço COM a constante (não com um literal)", () => {
const fonte = fs.readFileSync(path.join(RAIZ, PASTA_DO_GOOGLE_ADS, "conversions.ts"), "utf8");
expect(fonte).toMatch(/\$\{ENDERECO_BASE\}\/\$\{VERSAO_DA_API_DO_GOOGLE_ADS\}\/customers\//);
});
});
describe("erro sem JSON vira motivo legível", () => {
const HTML_DO_404 =
"<!DOCTYPE html><html lang=en><meta charset=utf-8><title>Error 404 (Not Found)!!1</title>" +
"<p><b>404.</b> <ins>That’s an error.</ins><p>The requested URL was not found on this server.";
it("404 em HTML: nada de marcação no detalhe, e a pista da versão aparece", () => {
const corpo = INTERNOS.lerCorpoDeErro(404, HTML_DO_404);
const r = INTERNOS.classificaErro(404, corpo);
expect(r.tipo).toBe("permanente");
if (r.tipo !== "permanente") throw new Error("inalcançável");
expect(r.detalhe).not.toMatch(/[<>]/);
expect(r.detalhe).toContain(VERSAO_DA_API_DO_GOOGLE_ADS);
expect(r.detalhe).toMatch(/desativada/);
});
it("outro status sem JSON: diz o status, sem a pista da versão", () => {
const corpo = INTERNOS.lerCorpoDeErro(400, "Bad Request");
expect(corpo.message).toContain("HTTP 400");
expect(corpo.message).not.toMatch(/desativada/);
});
it("erro em JSON continua lido como o Google manda", () => {
const corpo = INTERNOS.lerCorpoDeErro(
400,
JSON.stringify({ error: { code: 400, status: "INVALID_ARGUMENT", message: "gclid inválido" } }),
);
expect(corpo).toEqual({ code: 400, status: "INVALID_ARGUMENT", message: "gclid inválido" });
});
});
@@ -0,0 +1,640 @@
/**
* O webhook do NÚMERO, registrado pela instalação ao conectar (issue #850, fatia F1).
*
* O defeito que este arquivo tranca: conectar o canal oficial deixava o operador com
* um canal que ENVIA e não RECEBE — a Meta só entrega no endereço que estiver no
* painel dela, e colar essa URL era trabalho manual, por número, que quem não
* programa não sabe que existe. Aqui o handler e o caso de uso rodam DE VERDADE
* (dublê só no banco, no `fetch` e nas guardas), e cada caso mede um desfecho:
*
* 1. o módulo fala com a Meta na ORDEM certa (inscrição na WABA, depois o número)
* e diz QUAL etapa falhou quando falha;
* 2. o desfecho é GRAVADO na sessão, para a tela mostrar "webhook pendente" com
* motivo em vez de "conectado" e silêncio;
* 3. falhar o registro NÃO desfaz a conexão — o canal continua enviando;
* 4. o registro acontece DEPOIS da gravação da sessão (o GET de verificação da Meta
* chega no instante do registro e procura a sessão pelo token do caminho);
* 5. par trocado (número de uma WABA, id de outra) é recusado ANTES de gravar.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { CHANNEL_PROVIDER_META } from "@/lib/channels/capabilities";
import { appDaMeta } from "@/lib/channels/meta/app";
import { requireRole } from "@/lib/auth/require-role";
import { validateMetaCredentials } from "@/lib/channels/meta/validate-credentials";
import { encryptWebhookSecret, decryptWebhookSecret } from "@/lib/webhooks/secrets";
vi.mock("@/lib/auth/require-role", () => ({ requireRole: vi.fn() }));
vi.mock("@/lib/impersonate/support", () => ({ requireSupportWrite: vi.fn(async () => null) }));
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/webhooks/secrets", () => ({
encryptWebhookSecret: vi.fn(),
decryptWebhookSecret: vi.fn(),
}));
vi.mock("@/lib/channels/meta/validate-credentials", () => ({ validateMetaCredentials: vi.fn() }));
vi.mock("@/lib/channels/meta/app", () => ({ appDaMeta: vi.fn() }));
vi.mock("@/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));
import { POST as conectar } from "@/app/api/v1/channels/official/route";
import { POST as registrarDeNovo } from "@/app/api/v1/channels/official/webhook/route";
import { registrarWebhookDaSessao } from "@/lib/channels/meta/webhook-da-sessao";
import { registrarWebhookDoNumero } from "@/lib/channels/meta/webhook-override";
const ORG = "org-1";
const USER = "user-1";
const SESSAO = "sessao-1";
const NUMERO = "111222333444555";
const WABA = "999888777666555";
const PATH_TOKEN = "tok-do-caminho";
const BASE = "https://crm.exemplo.com";
const VERIFY = "verify-token-do-banco";
const VERSION = "v23.0";
type Linha = Record<string, unknown>;
/** Registro do que o dublê de banco viu — é por aqui que a ORDEM é medida. */
interface Registro {
linhas: Linha[];
escritas: Array<{ tipo: string; table: string; patch: Linha; recusada: boolean }>;
/** O cliente falso — o caso de uso recebe ele como `admin`. */
client: unknown;
/** Chamadas ao `fetch` (a Graph API), na ordem em que saíram. */
meta: Array<{ url: string; metodo: string; corpo: Linha }>;
}
function sessao(extra: Linha = {}): Linha {
return {
id: SESSAO,
organization_id: ORG,
provider: CHANNEL_PROVIDER_META,
meta_phone_number_id: NUMERO,
meta_waba_id: WABA,
meta_token_encrypted: "cifrado",
webhook_path_token: PATH_TOKEN,
archived_at: null,
...extra,
};
}
function makeDb(opts: { sessions?: Linha[]; recusaColunaDoDesfecho?: boolean } = {}): Registro {
const linhas: Linha[] = opts.sessions ?? [sessao()];
const registro: Registro = { linhas, escritas: [], meta: [], client: null };
class Q implements PromiseLike<unknown> {
private filtros: Array<[string, unknown]> = [];
private colunas = "";
private unica = false;
constructor(
private readonly table: string,
private readonly op: "select" | "update" | "insert",
private readonly patch: Linha | null = null,
) {}
select(cols?: string): this {
this.colunas = cols ?? "";
return this;
}
eq(col: string, val: unknown): this {
this.filtros.push([col, val]);
return this;
}
is(col: string, val: unknown): this {
this.filtros.push([col, val]);
return this;
}
maybeSingle(): this {
this.unica = true;
return this;
}
/** O banco SEM a migration 0311 recusa qualquer menção às colunas do desfecho. */
private semColuna(): { code: string; message: string } | null {
if (opts.recusaColunaDoDesfecho !== true) return null;
const citada =
this.colunas.includes("meta_webhook_override") ||
Object.keys(this.patch ?? {}).some((c) => c.startsWith("meta_webhook_override"));
return citada
? {
code: "PGRST204",
message:
"Could not find the 'meta_webhook_override_uri' column of 'channel_sessions' in the schema cache",
}
: null;
}
private casam(): Linha[] {
return linhas.filter((l) =>
this.filtros.every(([c, v]) => (l[c] ?? null) === v),
);
}
private executar(): { data: unknown; error: unknown } {
const ausente = this.semColuna();
if (this.op === "select") {
if (ausente) return { data: null, error: ausente };
const achadas = this.casam();
return { data: this.unica ? (achadas[0] ?? null) : achadas, error: null };
}
registro.escritas.push({
tipo: this.op,
table: this.table,
patch: this.patch ?? {},
recusada: ausente !== null,
});
if (ausente) return { data: null, error: ausente };
if (this.op === "insert") {
const nova = { id: SESSAO, webhook_path_token: PATH_TOKEN, ...(this.patch ?? {}) };
linhas.push(nova);
return { data: this.colunas ? nova : null, error: null };
}
for (const l of this.casam()) Object.assign(l, this.patch);
return { data: this.colunas ? (this.casam()[0] ?? null) : null, error: null };
}
then<R1 = unknown, R2 = never>(
onOk?: ((v: { data: unknown; error: unknown }) => R1 | PromiseLike<R1>) | null,
onErr?: ((r: unknown) => R2 | PromiseLike<R2>) | null,
): PromiseLike<R1 | R2> {
return Promise.resolve(this.executar()).then(onOk, onErr);
}
}
const client = {
from: (table: string) => ({
select: (cols?: string) => new Q(table, "select").select(cols),
update: (patch: Linha) => new Q(table, "update", patch),
insert: (patch: Linha) => new Q(table, "insert", patch),
}),
};
vi.mocked(createAdminClient).mockReturnValue(client as never);
registro.client = client;
(globalThis as unknown as { __registro: Registro }).__registro = registro;
return registro;
}
/**
* `fetch` dublê que responde por URL, e guarda a ORDEM.
*
* O `meta[0]` é a inscrição na WABA e o `meta[1]` é o número — é isso que os testes
* de ordem leem. Resposta 200 com corpo vazio é o que a Meta devolve nos dois POSTs.
*/
function stubMeta(respostas: {
inscricao?: { status?: number; body?: Linha } | "throw";
numero?: { status?: number; body?: Linha } | "throw";
waba?: { status?: number; body?: Linha } | "throw";
}): Registro["meta"] {
const registro = (globalThis as unknown as { __registro: Registro }).__registro;
const chamadas: Registro["meta"] = [];
if (registro) registro.meta = chamadas;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const corpo = init?.body ? (JSON.parse(String(init.body)) as Linha) : {};
chamadas.push({ url: String(url), metodo: String(init?.method ?? "GET"), corpo });
const alvo = String(url);
const resposta = alvo.includes("/subscribed_apps")
? respostas.inscricao
: alvo.includes("/phone_numbers")
? respostas.waba
: respostas.numero;
if (resposta === "throw") throw new Error("getaddrinfo ENOTFOUND graph.facebook.com");
const { status = 200, body = {} } = resposta ?? {};
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}),
);
return chamadas;
}
function authOk(): void {
const user = {
id: USER,
email: "a@example.com",
full_name: null,
avatar_url: null,
is_platform_admin: false,
idioma: "pt-BR" as const,
organizations: [{ organization_id: ORG, organization_name: "Org", role: "admin" }],
};
vi.mocked(requireRole).mockResolvedValue({
ok: true,
user: user as never,
org: { orgId: ORG, name: "Org", role: "admin" },
} as never);
}
const erroDaMeta = (detalhe: string) => ({
error: { message: "Invalid parameter", error_data: { details: detalhe } },
});
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
process.env.META_GRAPH_VERSION = VERSION;
vi.mocked(encryptWebhookSecret).mockResolvedValue("cifrado" as never);
vi.mocked(decryptWebhookSecret).mockResolvedValue("token-da-meta" as never);
vi.mocked(appDaMeta).mockResolvedValue({ appSecret: "segredo", verifyToken: VERIFY } as never);
vi.mocked(validateMetaCredentials).mockResolvedValue({
ok: true,
displayPhoneNumber: "5541999999999",
verifiedName: "Canal de teste",
qualityRating: "GREEN",
} as never);
});
describe("registrarWebhookDoNumero — a conversa com a Meta, na ordem certa", () => {
it("inscreve o app na WABA e DEPOIS aponta o webhook do número, com o verify token", async () => {
const chamadas = stubMeta({});
const desfecho = await registrarWebhookDoNumero({
phoneNumberId: NUMERO,
wabaId: WABA,
token: "token-da-meta",
callbackUrl: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`,
verifyToken: VERIFY,
});
expect(desfecho).toEqual({ ok: true, url: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}` });
// A ordem é a regra, não detalhe: sem a inscrição na WABA a Meta não entrega nada,
// e o override sozinho daria a impressão de canal pronto.
expect(chamadas).toHaveLength(2);
expect(chamadas[0]!.url).toBe(`https://graph.facebook.com/${VERSION}/${WABA}/subscribed_apps`);
expect(chamadas[1]!.url).toBe(`https://graph.facebook.com/${VERSION}/${NUMERO}`);
expect(chamadas[1]!.corpo).toEqual({
webhook_configuration: {
override_callback_uri: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`,
verify_token: VERIFY,
},
});
});
it("falhando a inscrição na WABA, NÃO vai ao número e diz qual etapa falhou", async () => {
const chamadas = stubMeta({
inscricao: { status: 400, body: erroDaMeta("(#200) Permissions error") },
});
const desfecho = await registrarWebhookDoNumero({
phoneNumberId: NUMERO,
wabaId: WABA,
token: "token-da-meta",
callbackUrl: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`,
verifyToken: VERIFY,
});
expect(desfecho).toEqual({
ok: false,
etapa: "inscricao_na_waba",
motivo: "(#200) Permissions error",
});
expect(chamadas).toHaveLength(1);
});
it("recusando o override do número, o motivo é o DETALHE da Meta (não o 'invalid parameter')", async () => {
stubMeta({
numero: { status: 400, body: erroDaMeta("(#100) Param webhook_configuration must be an object") },
});
const desfecho = await registrarWebhookDoNumero({
phoneNumberId: NUMERO,
wabaId: WABA,
token: "token-da-meta",
callbackUrl: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`,
verifyToken: VERIFY,
});
expect(desfecho.ok).toBe(false);
expect(desfecho).toMatchObject({ etapa: "configuracao_do_numero" });
expect((desfecho as { ok: false; motivo: string }).motivo).toContain("must be an object");
});
it("rede caída vira motivo de rede, e não exceção", async () => {
stubMeta({ inscricao: "throw" });
const desfecho = await registrarWebhookDoNumero({
phoneNumberId: NUMERO,
wabaId: WABA,
token: "token-da-meta",
callbackUrl: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`,
verifyToken: VERIFY,
});
expect(desfecho.ok).toBe(false);
expect((desfecho as { ok: false; motivo: string }).motivo).toContain("rede indisponível");
});
});
describe("registrarWebhookDaSessao — o desfecho gravado na sessão", () => {
it("guarda URL, motivo e data na linha da sessão", async () => {
const registro = makeDb();
stubMeta({});
const desfecho = await registrarWebhookDaSessao({
admin: registro.client as never,
channelSessionId: SESSAO,
phoneNumberId: NUMERO,
wabaId: WABA,
tokenCifrado: "cifrado",
webhookPathToken: PATH_TOKEN,
base: BASE,
});
expect(desfecho).toMatchObject({
registrado: true,
url: `${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`,
erro: null,
});
const gravado = registro.escritas.at(-1)?.patch ?? {};
expect(gravado.meta_webhook_override_uri).toBe(`${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`);
expect(gravado.meta_webhook_override_erro).toBeNull();
expect(gravado.meta_webhook_override_em).toBe(desfecho.em);
});
it("sem verify token na instalação, NÃO chama a Meta e explica o que falta", async () => {
const registro = makeDb();
const chamadas = stubMeta({});
vi.mocked(appDaMeta).mockResolvedValue({ appSecret: "segredo", verifyToken: null } as never);
const desfecho = await registrarWebhookDaSessao({
admin: registro.client as never,
channelSessionId: SESSAO,
phoneNumberId: NUMERO,
wabaId: WABA,
tokenCifrado: "cifrado",
webhookPathToken: PATH_TOKEN,
base: BASE,
});
expect(chamadas).toHaveLength(0);
expect(desfecho.registrado).toBe(false);
expect(desfecho.erro).toContain("verify token");
});
it("credencial ilegível não vira 'a Meta recusou' — o operador troca a credencial", async () => {
const registro = makeDb();
const chamadas = stubMeta({});
vi.mocked(decryptWebhookSecret).mockResolvedValue(null as never);
const desfecho = await registrarWebhookDaSessao({
admin: registro.client as never,
channelSessionId: SESSAO,
phoneNumberId: NUMERO,
wabaId: WABA,
tokenCifrado: "cifrado",
webhookPathToken: PATH_TOKEN,
base: BASE,
});
expect(chamadas).toHaveLength(0);
expect(desfecho.erro).toContain("decifrada");
});
it("banco SEM a migration 0311: não lança e ainda devolve o desfecho para a resposta", async () => {
const registro = makeDb({ recusaColunaDoDesfecho: true });
stubMeta({});
const desfecho = await registrarWebhookDaSessao({
admin: registro.client as never,
channelSessionId: SESSAO,
phoneNumberId: NUMERO,
wabaId: WABA,
tokenCifrado: "cifrado",
webhookPathToken: PATH_TOKEN,
base: BASE,
});
// A doutrina do repo: código NOVO sobre banco sem a migration não pode quebrar o
// que já funcionava. O registro ACONTECEU (a Meta foi chamada); o que não pôde
// ser feito foi guardar o estado — e a rota devolve o estado na resposta.
expect(desfecho.registrado).toBe(true);
expect(desfecho.url).toBe(`${BASE}/api/v1/webhooks/meta/${PATH_TOKEN}`);
});
});
describe("validateMetaCredentials com wabaId — o par número/WABA (fonte real)", () => {
/** A validação de verdade, sem o dublê do módulo: é ela que ganhou a segunda pergunta. */
const real = async () =>
(
await vi.importActual<typeof import("@/lib/channels/meta/validate-credentials")>(
"@/lib/channels/meta/validate-credentials",
)
).validateMetaCredentials;
const credencialOk = { display_phone_number: "5541999999999", verified_name: "Canal" };
it("aceita quando a Meta lista o número na WABA informada", async () => {
stubMeta({ numero: { status: 200, body: credencialOk }, waba: { body: { data: [{ id: NUMERO }] } } });
const desfecho = await (await real())({
phoneNumberId: NUMERO,
token: "x".repeat(30),
wabaId: WABA,
});
expect(desfecho.ok).toBe(true);
});
it("recusa o par trocado: o número não está na lista daquela WABA", async () => {
// A credencial RESPONDE (é a mesma que envia) — o que não fecha é a conta: o
// número pertence a outra WABA, e a Meta entrega o webhook lá, não aqui.
stubMeta({
numero: { status: 200, body: credencialOk },
waba: { body: { data: [{ id: "outro-numero" }] } },
});
const desfecho = await (await real())({
phoneNumberId: NUMERO,
token: "x".repeat(30),
wabaId: WABA,
});
expect(desfecho.ok).toBe(false);
expect((desfecho as { ok: false; motivo: string }).motivo).toContain("não pertence à WABA");
});
it("lista vazia da WABA aponta a CREDENCIAL, não o operador", async () => {
// Credencial sem permissão na WABA devolve 200 com `data: []`. Dizer "não
// pertence" seria mandar o operador conferir o que está certo.
stubMeta({ numero: { status: 200, body: credencialOk }, waba: { body: { data: [] } } });
const desfecho = await (await real())({
phoneNumberId: NUMERO,
token: "x".repeat(30),
wabaId: WABA,
});
expect(desfecho.ok).toBe(false);
expect((desfecho as { ok: false; motivo: string }).motivo).toContain("não devolveu nenhum número");
});
it("sem wabaId não há segunda chamada — quem só quer saber se a credencial presta não paga por ela", async () => {
const chamadas = stubMeta({ numero: { status: 200, body: credencialOk } });
const desfecho = await (await real())({
phoneNumberId: NUMERO,
token: "x".repeat(30),
});
expect(desfecho.ok).toBe(true);
expect(chamadas).toHaveLength(1);
expect(chamadas[0]!.url).toContain(`/${NUMERO}?fields=`);
});
});
describe("POST /api/v1/channels/official — conectar registra o webhook", () => {
const conectarReq = () =>
new NextRequest("https://crm.exemplo.com/api/v1/channels/official", {
method: "POST",
headers: { origin: BASE, "content-type": "application/json" },
body: JSON.stringify({ phone_number_id: NUMERO, waba_id: WABA, token: "x".repeat(30) }),
});
it("registra DEPOIS de gravar a sessão e devolve o estado do registro", async () => {
const registro = makeDb({ sessions: [] });
stubMeta({});
authOk();
const res = await conectar(conectarReq());
const corpo = (await res.json()) as { data: Record<string, unknown> };
expect(res.status).toBe(200);
expect(corpo.data).toMatchObject({ connected: true });
// O endereço é o da SESSÃO: o domínio é decisão de instalação
// (`NEXT_PUBLIC_APP_URL`, com o `origin` como reserva) e o que esta fatia promete
// é que a URL que a Meta recebeu é a MESMA que a tela mostra — divergir mandaria o
// operador conferir um valor que não é o que está valendo.
const registroGravado = corpo.data.webhookRegistro as {
registrado: boolean;
url: string;
erro: string | null;
};
expect(registroGravado.registrado).toBe(true);
expect(registroGravado.erro).toBeNull();
expect(registroGravado.url).toContain(`/api/v1/webhooks/meta/${PATH_TOKEN}`);
expect(registro.meta[1]!.corpo).toEqual({
webhook_configuration: {
override_callback_uri: registroGravado.url,
verify_token: VERIFY,
},
});
// A ordem: a linha existe ANTES de a Meta ser chamada. O GET de verificação dela
// chega no instante do registro e procura a sessão pelo token do caminho — se o
// registro viesse primeiro, a Meta acharia 404 e marcaria o webhook como inválido.
expect(registro.escritas.some((e) => e.tipo === "insert")).toBe(true);
expect(registro.meta.length).toBeGreaterThan(0);
});
it("a Meta recusar o registro NÃO desfaz a conexão — o canal segue enviando", async () => {
makeDb({ sessions: [] });
stubMeta({ numero: { status: 400, body: erroDaMeta("(#100) Invalid callback URL") } });
authOk();
const res = await conectar(conectarReq());
const corpo = (await res.json()) as { data: Record<string, unknown> };
expect(res.status).toBe(200);
expect(corpo.data.connected).toBe(true);
expect(corpo.data.webhookRegistro).toMatchObject({ registrado: false, url: null });
expect(String((corpo.data.webhookRegistro as { erro: string }).erro)).toContain("Invalid callback URL");
});
it("par trocado (número que não é da WABA informada) é recusado ANTES de gravar", async () => {
const registro = makeDb({ sessions: [] });
authOk();
vi.mocked(validateMetaCredentials).mockResolvedValue({
ok: false,
motivo: `o número ${NUMERO} não pertence à WABA ${WABA}`,
} as never);
const res = await conectar(conectarReq());
const corpo = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(422);
// `fail()` (lib/api/wrappers) aninha em `error`: o corpo é { error: { code, message } }.
expect(String((corpo.error as { message: string }).message)).toContain("não pertence");
// Nada gravado e nenhuma chamada de registro: o override aponta o webhook de um
// número pelo id, e o id não carrega a WABA — registrar o par trocado seria
// entregar mensagem de um canal que a instalação não controla.
expect(registro.escritas).toHaveLength(0);
expect(registro.meta).toHaveLength(0);
expect(vi.mocked(validateMetaCredentials)).toHaveBeenCalledWith(
expect.objectContaining({ wabaId: WABA }),
);
});
});
describe("POST /api/v1/channels/official/webhook — tentar de novo", () => {
const req = () =>
new NextRequest("https://crm.exemplo.com/api/v1/channels/official/webhook", {
method: "POST",
headers: { origin: BASE },
});
it("reaplica com a credencial JÁ guardada e devolve o desfecho de agora", async () => {
const registro = makeDb();
stubMeta({});
authOk();
const res = await registrarDeNovo(req());
const corpo = (await res.json()) as { data: Record<string, unknown> };
expect(res.status).toBe(200);
const desfecho = corpo.data as { registrado: boolean; url: string; callbackUrl: string };
expect(desfecho.registrado).toBe(true);
expect(desfecho.url).toContain(`/api/v1/webhooks/meta/${PATH_TOKEN}`);
expect(desfecho.callbackUrl).toBe(desfecho.url);
// A credencial veio da SESSÃO (decifrada), não do corpo da requisição: pedir o
// token de novo obrigaria o operador a ter à mão o que o CRM já guardou.
expect(vi.mocked(decryptWebhookSecret)).toHaveBeenCalledWith(expect.anything(), "cifrado");
expect(registro.meta[0]!.url).toContain(`/${WABA}/subscribed_apps`);
});
it("a Meta recusar de novo devolve 200 com o motivo — estado, não erro de requisição", async () => {
makeDb();
stubMeta({ inscricao: { status: 400, body: erroDaMeta("(#200) Permissions error") } });
authOk();
const res = await registrarDeNovo(req());
const corpo = (await res.json()) as { data: Record<string, unknown> };
// 4xx aqui faria o cliente tratar como falha e ESCONDER o motivo que a Meta deu —
// o operador precisa ler "(#200) Permissions error" para ir ajustar a permissão.
expect(res.status).toBe(200);
expect(corpo.data.registrado).toBe(false);
expect(String(corpo.data.erro)).toContain("Permissions error");
});
it("org sem canal oficial responde 422 e não chama a Meta", async () => {
const registro = makeDb({ sessions: [] });
const chamadas = stubMeta({});
authOk();
const res = await registrarDeNovo(req());
expect(res.status).toBe(422);
expect(chamadas).toHaveLength(0);
expect(registro.meta).toHaveLength(0);
});
it("canal ARQUIVADO não é 'consertado': a URL dele já foi rotacionada", async () => {
const chamadas = stubMeta({});
makeDb({ sessions: [sessao({ archived_at: "2026-09-01T00:00:00.000Z" })] });
authOk();
const res = await registrarDeNovo(req());
expect(res.status).toBe(422);
expect(chamadas).toHaveLength(0);
});
});