10 Commits
Author SHA1 Message Date
betoarts 667d3f4073 fix(agentes): arquivar desabilitado para o agente padrão, com erro legível (recorte do #714)
O item "Arquivar" do menu da lista de agentes fica desabilitado quando o
agente é o padrão da organização, com um title explicando o motivo, e o
erro cannot_archive_default da action vira frase legível em vez do código
cru "Falha: cannot_archive_default".

Recorte do commit ae3979791 do PR #714: só o hunk de AgentRowMenu.tsx.

Refs: #714
2026-09-22 23:14:47 -03:00
betoartsandClaude Opus 5 15a5ea3792 feat(cadastro): fila de pedidos de empresa nova, aprovada pelo dono da instalação (recorte do #714)
A fila registration_requests, a action que aprova ou recusa e os códigos de
auditoria registration.* vêm do commit 48183ec4 de @betoarts. A empresa só
nasce quando o administrador da instalação aprova, pelo mesmo caminho do
cadastro aberto (ensureTenantForUser).

Adaptado ao que a decisão do dono (doc 24, Decisão 2 = d) e os bloqueadores
publicados no #714 permitem:

- sem pedido para ENTRAR em empresa existente: exigia listar as empresas da
  instalação a visitante anônimo. Quem entra numa empresa existente entra
  pelo convite;
- sem confirmar e-mail pelo servidor: a aprovação exige email_confirmed_at
  gravado pelo provedor de auth e nunca o escreve;
- a CHECK de decisão não exige decided_by, que é "on delete set null":
  apagar a conta de quem aprovou violaria a constraint.

Migration 0383 (alocada pelo coordenador da rodada), com o terceiro modo
com_aprovacao na CHECK de platform_settings.signup_mode; o default continua
aberto. Tabela da instalação, anterior à organização: RLS ligada sem policy,
só service_role, como platform_settings (0253). Na branch do autor era
20260912115014_solicitacoes_de_cadastro, sem número.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019G7fFaatqXpzHA77xP9onS
2026-09-22 12:53:27 -03:00
betoartsandClaude Opus 5 7e818daea4 feat(kit): instalacao single-server — CRM e Supabase self-hosted na mesma VPS
Recorte do PR #714 (commits 783a0a9dc, c3c99696b, dd48f650e de @betoarts):
o instalador de uma pergunta (so o dominio), o override de compose e o
Caddyfile do modo single-server, o override do Supabase que prende Postgres e
gateway em loopback, a porta de entrada ubuntu-production-installer.sh e os
testes estruturais dos dois.

No kit, a mesma logica do autor reaplicada sobre a main atual (que mudou
desde a base do PR): dc/dc_files reconhecem SINGLE_SERVER=1, todo psql/pg_dump
efemero passa por pg_container (entra na bridge privada quando
PSQL_DOCKER_NETWORK existe), o validador da URL do Supabase prova o gateway
local antes de o Caddy existir, e o 1o admin e criado pelo gateway interno.

Fora deste recorte, de proposito: ENABLE_EMAIL_AUTOCONFIRM true (dependia da
fila de aprovacao de cadastro, que nao entra) e o preflight que apontava para
o GHCR do fork.

Refs: #714

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RccZbdYe8URAiWCpyvZimQ
2026-09-21 07:31:37 -03:00
betoartsandClaude Opus 5 b252958cb8 feat(dev): stack local com Supabase, WAHA e Redis num comando (recorte do #714)
Quem quer rodar o DeskcommCRM na propria maquina nao tinha caminho: o kit
da VPS assume dominio e proxy, e a cadeia historica de migrations nao sobe
em banco novo. Esta fatia traz o ambiente local completo:

- ubuntu-local-installer.sh: prepara a VM, sobe o Supabase local, gera o
  .env.local, sobe app/worker/WAHA/Redis e cria o dono;
- scripts/local-supabase.sh: sobe o Supabase local aplicando o BASELINE, e
  nao a cadeia historica (que nao sobe em banco novo -- a mesma razao que o
  CLAUDE.md registra), restaurando supabase/migrations por trap;
- scripts/local-env.sh: gera o .env.local com segredos aleatorios e faz
  backup do ambiente anterior quando ele nao e local;
- scripts/local-stack.sh: up/down/status/logs/reset, mais os quatro atalhos
  local:* no package.json;
- docker-compose.local.yml e docs/SETUP.md.

Recorte do PR #714, de @betoarts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2i1fgsteAjoAU6T8ewnq2
2026-09-20 18:05:40 -03:00
betoartsandClaude Opus 5 d6542aa90a fix(ia): revalidacao que falha zera o catalogo de modelos (recorte do #714)
O ramo de falha gravava validated_at:null e validation_error e deixava
models_available como estava -- a lista da validacao ANTERIOR. A tela
desenha essa contagem (CredentialCard.tsx:165) ao lado da mensagem de
erro, entao uma credencial que deixou de ser confiavel aparece com
catalogo, como se estivesse pronta.

A decifragem que falha passa a usar o logger estruturado no lugar do
console.error. Isso e consistencia com a casa, nao conserto de violacao:
o anti-pattern 14 do CLAUDE.md nomeia console.log, e o eslint permite
error explicitamente (eslint.config.mjs:25).

Recorte do PR #714, de @betoarts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2i1fgsteAjoAU6T8ewnq2
2026-09-20 17:42:17 -03:00
betoartsandClaude Opus 5 a6dd1c7012 fix(agenda): OAuth do Google funciona quando o navegador abre por localhost (recorte do #714)
A URL canonica da instalacao continua sendo o piso, mas quem desenvolve na
propria maquina abre o mesmo processo por localhost enquanto a configuracao
guardou o IP da rede. O OAuth compara o redirect byte a byte, entao o
consentimento voltava para outra origem e a conexao nao terminava.

So loopback e aceito (localhost, 127.0.0.1, ::1): host arbitrario nunca vence
a URL canonica, para que um cabecalho Host forjado nao troque o endereco a que
o Google devolve o codigo de autorizacao.

Recorte do PR #714, de @betoarts. A pagina da agenda mudou na main desde o
commit original; o conflito de import e de formatacao foi resolvido mantendo o
estilo da main e o efeito do autor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lou3N4D8u3CV1nmeqTdkZ7
2026-09-20 11:05:59 -03:00
betoartsandClaude Opus 5 29eb60b78e fix(ia): falha do classificador auxiliar deixa de calar o agente (recorte do #714)
Recorte do #714, de @betoarts (Humberto Moura Neto). A ideia e o diagnostico
sao dele; o que muda em relacao ao patch original e o LUGAR onde a guarda
entra, porque o arquivo alvo andou muito desde que ele escreveu.

=== O DEFEITO

`classifyStage` e `classifyJailbreak` rodam ANTES de o agente responder e sao
os dois ADVISORIOS do turno: um sugere o estagio do funil (quem confirma e o
modelo do agente, via `update_lead_state`), o outro so FLAGRA a mensagem no
trace e nunca vetou um inbound sozinho.

A excecao de `runModelCall` subia dos dois assim mesmo. Como eles sao os dois
elementos do `Promise.all` que abre `executarTurnoDoAgente`, qualquer falha
deles matava o turno inteiro e o cliente ficava sem resposta. O caso dominante
nao e o provedor cair: e o ponto auxiliar, em Configuracoes > Provedores de IA,
apontar para um modelo que saiu do painel ou para uma credencial revogada — o
modelo do agente de pe, o atendimento parado.

Os dois classificadores ja degradavam saida ILEGIVEL sem bloquear
(`parseStageSuggestion` devolve null, `parseJailbreakClassification` devolve
`none`). A excecao era o unico caminho que fugia dessa regra, subindo.

=== ONDE A GUARDA ENTRA, E POR QUE NAO ONDE ELE POS

O patch do #714 envolvia os dois CALL SITES em `inbound-turn.ts`, com dois
`await` em serie — era a forma daquele arquivo na epoca. Hoje o turno roda os
dois em `Promise.all`, e `tests/unit/classificadores-auxiliares-em-paralelo.test.ts`
exige, por AST, que as duas chamadas sejam elementos DIRETOS do mesmo array.
Aplicar o patch como escrito reintroduziria a latencia em serie que aquele
teste existe para impedir, e reprovaria o gate.

A guarda entra dentro de `classifyStage` e `classifyJailbreak`, que e o lugar
certo por tres razoes: e onde a regra de degradacao dessas funcoes ja mora
(a de parse), cobre todo call site futuro sem lista a manter, e deixa o turno
intocado — nenhum dos dois testes de AST do `inbound-turn.ts` precisou mudar.

=== O QUE CONTINUA SUBINDO

`LlmBudgetExceededError`, exatamente como o @betoarts escreveu. Quem a espera e
`comHandoffSeOrcamentoAcabar`: os auxiliares sao os PRIMEIROS a estourar o teto
do mes, e e essa escolta que passa a conversa para uma pessoa em vez de deixar
o lead no vacuo. Engolir o erro de orcamento aqui trocaria o handoff por um
turno que segue gastando.

=== O DEGRADE NAO E SILENCIOSO

`runModelCall` grava a chamada falha em `llm_calls` (purposes
`stage_classifier` / `jailbreak_detect`) ANTES de relancar, e o `warn` carimba
o run. O laco de retorno e essa linha — "a camada auxiliar parou de rodar" tem
onde ser lido, em Uso de IA. Nos campos do log vai so a mensagem do
fornecedor, truncada em 200: a mensagem do lead nunca entra (regra dura 8),
e ha caso medindo isso.

=== PROVA

`tests/unit/classificador-auxiliar-nao-derruba-o-turno.test.ts`, 5 casos.
Controle negativo rodado: com os dois arquivos revertidos ao estado da main,
3 dos 5 reprovam (os tres do degrade) e 2 passam — os de orcamento, que medem
a propriedade que NAO podia ser quebrada pelo `catch` novo.

A METADE do #714 que NAO entrou: `app/api/v1/ai/agents/[id]/versions/[vid]/test/route.ts`,
onde ele trocava `status: "error"` por `"failed"`. A main ja resolveu, e melhor
— o caminho de erro daquela rota ja grava `failed`, `error_message` e um
`logger.error`, e o `catch` vazio que tornava a falha indiagnosticavel tambem
ja foi fechado. Trazer o pedaco dele ali nao mudaria byte nenhum.

Refs: #714
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHzAhMNW4yRWfgR5Ack89m
2026-09-20 02:57:37 -03:00
betoartsandClaude Opus 5 2e62f8e4ec fix(kit): a instalação semeia o catálogo da OpenRouter antes de terminar
O catálogo dos provedores diretos vem no baseline. O da OpenRouter não cabe
lá — são ~400 modelos que mudam sozinhos — e chega pelo cron
`api/v1/cron/sync-model-catalog`, que o scheduler bate às 04:15 UTC
(docker/scheduler/entrypoint.sh:104). Quem terminasse a instalação DEPOIS
dessa rodada abria a tela de criar o primeiro agente e encontrava o seletor
de modelos vazio, com a chave OpenRouter já cadastrada e válida — e só no dia
seguinte descobriria que não era defeito. É primeira impressão: a tela que a
pessoa abre para testar a IA que veio vender o produto.

Depois do healthcheck (`APP_SAUDAVEL=1`), o instalador pede a sincronização
uma vez, pelo mesmo caminho do cron.

Três decisões, as três medidas:

- **Falha aberta.** A origem é externa (openrouter.ai) e pode estar fora do
  ar naquele minuto; uma instalação saudável não pode ser invalidada por
  isso. O comando mora na CONDIÇÃO de um `if`, onde o `set -euo pipefail`
  não aborta — provado nos dois sentidos com o bloco real extraído do
  arquivo: com `dc` falhando, imprime o aviso e segue (exit=0); com `dc` OK,
  imprime o ✓ e segue.
- **O segredo não passa pelo argv deste processo.** As aspas simples impedem
  a expansão aqui; quem expande `$INTERNAL_SECRET` é o `sh` de dentro do
  contêiner `scheduler`, que já o recebe pelo ambiente
  (docker-compose.prod.yml).
- **Nenhuma variável de ambiente nova.** `INTERNAL_SECRET` e o endereço
  interno `http://app:3000` já existiam; nada entra em `.env.example` nem em
  `lib/env.ts`, e nenhum `.env` antigo precisa mudar.

Quando o app NÃO fica saudável, o passo nem roda — a tela de "Quase lá"
continua sendo a última coisa que a pessoa lê.

Trabalho de @betoarts, recortado do #714.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 02:11:41 -03:00
betoartsandClaude Opus 5 455216d83c feat(kit): um comando tira esta instalação do Docker, e só ela
Tirar o CRM de uma VPS era trabalho manual, e o atalho conhecido
(`docker system prune -a`) é o errado: numa VPS que hospeda mais de uma
coisa, ele leva junto container, volume e imagem de quem ninguém mandou
apagar.

`desinstalar_docker.sh` descobre o projeto pelo label que o Docker Compose
grava (`com.docker.compose.project`) e remove SÓ os containers, volumes e
redes internas dele. O que fica: outras aplicações do mesmo daemon, imagens,
cache de build, a rede do proxy reverso (que é `external: true` no compose e
por isso não carrega o label do projeto), código, `.env`, backups e um
Supabase externo.

Duas guardas, as duas antes da primeira mutação: a confirmação exige digitar
`REMOVER-<projeto>` (`--force` pula, para descarte automatizado de ambiente),
e quando os containers do mesmo nome de projeto registram OUTRO diretório de
trabalho que ainda existe no disco, o script para e manda rodar a partir de
lá, em vez de assumir propriedade — duas cópias do repositório com o mesmo
basename produzem o mesmo nome Compose.

`tests/shell/desinstalar-docker.test.sh` prova a seleção com um `docker` de
mentira: que as 5 descobertas passam pelo label do projeto, que só o que foi
encontrado é parado/removido, e que `system prune`, `builder prune` e
`image rm` não aparecem no script. Ele entra no `test:shell`, que sobe de 14
para 15 gates — a lista da `main` inteira, mais este.

Trabalho de @betoarts, recortado do #714. O único ajuste de conteúdo foi o
nome: o original era `unistall_docker.sh` (typo de "uninstall"), e congelar
isso num script que o operador digita é dívida barata agora e cara depois.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 01:58:48 -03:00
betoartsandClaude Opus 5 65160c278d feat(email): SMTP como opção de envio, ao lado da Resend
Quem instala numa VPS era obrigado a abrir conta num serviço externo de envio
e verificar domínio lá antes de conseguir mandar o PRIMEIRO convite de equipe.
Com isto, quem já tem servidor de e-mail aponta para ele.

O que entra:

- `lib/email/smtp.ts` — transporte por nodemailer, com `formatFromAddress`
  sanitizando `<`, `>`, `"` e quebra de linha (injeção de cabeçalho) e
  devolvendo `null` sem remetente: sem remetente não existe e-mail, e nunca se
  inventa um domínio do produto.
- `lib/email/config.ts` — resolução da configuração: banco primeiro
  (`platform_smtp_settings`), `.env` como piso de rollback, senha cifrada pela
  mesma GUC das outras credenciais server-side.
- `app/actions/settings/smtp.ts` — grava e testa, com `requirePlatformAdmin()`,
  Zod e audit. O audit registra `senha_trocada` como booleano, nunca a senha.
- migration `0277` + apêndice idempotente no `baseline.sql` + linha no MANIFEST:
  singleton de escopo de instalação, RLS ligada sem policies, `revoke all` de
  `anon`/`authenticated`, grant só ao `service_role`.
- `lib/env.ts` e os dois `.env*.example` — as sete `SMTP_*`, todas com default,
  para `.env` antigo não quebrar ao atualizar.
- a tela de configuração, em `/admin/email`.
- três casos de teste do remetente do lado SMTP.

Este commit é o recorte do PR #714, e três coisas foram adaptadas do original:

1. **A Resend não sai.** O PR reduzia `lib/email/resend.ts` a um shim de cinco
   linhas, tirava o pacote do `package.json`, as chaves do Zod e dos
   `.env*.example`, e derrubava a tabela de configuração dela numa migration
   irmã. O dono do produto decidiu o contrário: os dois caminhos convivem e quem
   já tem Resend não mexe em nada. Nada disso entra; nenhum teste da Resend é
   removido.
2. **A tela mudou de lugar.** Estava em `/app/settings/resend`, que é a área de
   uma EMPRESA, para configurar um servidor da INSTALAÇÃO — e o sintoma disso
   estava no próprio PR, que precisou inventar um `platformOnly: true` no
   catálogo de navegação do tenant para escondê-la da empresa. Em `/admin`, ao
   lado de `/admin/meta` e `/admin/google`, a exceção deixa de ser necessária.
3. **A tela pede menos do que recebia.** Para desenhar o campo de senha, a tela
   não precisa da senha atual — basta saber que existe uma gravada. É o que ela
   recebe agora, na mesma disciplina de `temSegredoSalvo` em `/admin/google` e
   `/admin/meta`.

Crédito: @betoarts (PR #714).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013edWAVC1j5BuzoBzqVzu5h
2026-09-17 18:09:00 -03:00