mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge remote-tracking branch 'origin/main' into feat/marketplace-de-extensoes
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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ê'", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
Executable
+20
@@ -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"
|
||||
Executable
+159
@@ -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"
|
||||
Executable
+46
@@ -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
|
||||
Executable
+51
@@ -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)`,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
Executable
+54
@@ -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
|
||||
@@ -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;
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user