14 Commits

Author SHA1 Message Date
VPS 4 Deploy Agent 7841a66119 feat(secretaria): aba Situações — o dono edita os roteiros pela tela
newwhats-build-and-promote / build (push) Has been skipped
newwhats-build-and-promote / promote (push) Successful in 12m5s
Ate agora as situacoes do contato so existiam no banco (sec_situacoes): sem tela e sem API,
so dava para ver e editar por SQL. Era a fase 4 do plano de situacoes, a unica que faltava.

API (plugins/secretaria/routes.ts):
- GET  /secretaria/situacoes        — lista (globais + do agente)
- PUT  /secretaria/situacoes/:id    — edita roteiro, rotulo, ativo, prioridade e config.
  `tipo` e `chave` NAO sao editaveis: sao estruturais (o resolvedor do brain procura pela
  chave) e mudalos quebraria o motor.
- GET  /secretaria/situacoes/uso    — quantas conversas cairam em cada situacao (30 dias),
  para o dono ver se a situacao esta pegando gente de verdade em vez de adivinhar.

UI (frontend/components/secretaria/SituacoesPanel.tsx + aba na SecretariaView):
- lista separada em BASE (vale uma, por precedencia) e SINALIZADORES (empilham), com o uso
  de 30d ao lado de cada uma;
- editor com o roteiro (o texto injetado no TOPO do prompt, acima da persona), rotulo e
  liga/desliga;
- no `inadimplente`, o campo de dias (parcela vencida ha mais de N dias) — a regra que o
  dono pediu, hoje so alteravel por SQL.

VOCABULARIO (a tela deixa explicito, porque a palavra e ambigua no sistema):
"situacao" aqui = situacao do CONTATO. Nao confundir com as "regras da clinica"
(agenda_situacoes, no satelite) nem com as "janelas de contato interno" (comunicacao
interna) — tres coisas diferentes com o mesmo nome.

Testado em dev: GET devolve as 5 situacoes; PUT salva (config dias 30->45->30); /uso
devolve dados reais (cadastrado=1); a aba compila e esta no bundle da pagina.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:40:15 +02:00
VPS 4 Deploy Agent 2a59e47e21 fix(deploy): sobe o queue-redis antes do backend
O deploy so recriava backend/frontend/nginx. O queue-redis e um servico NOVO e LOCAL
(fila de envio do BullMQ): sem ele de pe, o backend sobe sem conseguir enfileirar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:02:45 +02:00
VPS 4 Deploy Agent 9ef41a7fd3 feat(motor): fila de envio com BullMQ — rate limit por numero, retry e reconciler
Passos 2 e 3 do plano de escala (o 1, idempotencia, ja esta em prod na v1.0.4).

ANTES: o scheduler enviava DENTRO do tick do cron — laco sequencial com sleep fixo de ~1s.
Teto de ~1 msg/s para TODAS as sessoes somadas, zero retry, e nenhum controle real de taxa
POR NUMERO (que e o que causa ban no WhatsApp).

PASSO 2 — persistencia: Redis DEDICADO a fila (queue-redis), local ao motor, com AOF
(appendfsync everysec) e maxmemory-policy noeviction. NAO usei o Dragonfly da VPS3: ele e
cache, NAO persiste (um restart apagaria os jobs) e fica do outro lado da VPN, cujo tunel
morre ocioso. Em dev: queue-redis-dev, mesma config.

PASSO 3 — BullMQ como EXECUTOR (nao como fonte da verdade):
- send.queue.ts: uma fila POR INSTANCIA (o limiter do BullMQ e por fila, e o limite que
  importa e por NUMERO; rate limit por grupo e recurso do BullMQ Pro). Worker com
  concurrency 1 (envio paralelo pelo mesmo numero e o padrao que dispara o antispam) e
  limiter WA_SEND_RATE_PER_MIN (default 30/min; dev usa 10). attempts=3 com backoff
  exponencial (5s/10s/20s).
- scheduler: ENFILEIRA em vez de enviar em linha — o tick nao fica mais preso ~1s por
  mensagem (era isso que fazia o lote atravessar o minuto e ser reprocessado pelo tick
  seguinte). O outbox no Postgres segue sendo a fonte da verdade.
- RECONCILER (a cada 5 min): devolve a fila os envios presos em SENDING/FAILED ha >10min
  (Redis zerado, worker morto, tentativas esgotadas). E o que torna a fila DESCARTAVEL:
  perder o Redis atrasa envios, nao perde mensagens.
- shutdown fecha os workers (await scheduler.stop()).

PEGADINHA: nome de fila no BullMQ NAO pode conter ':' (ele usa dois-pontos no namespace das
chaves) — `wa-send:<id>` lanca "Queue name cannot contain :". Usar `wa-send-<id>`.

TESTADO EM DEV (com DISABLE_OUTBOUND=true — o kill-switch embrulha o proprio sock.sendMessage,
entao nada foi enviado de verdade):
- 12 destinatarios, limite 10/min: SENT=10 + SENDING=2 no 1o minuto; SENT=12 no seguinte.
  Rate limit por numero comprovado, sem duplicacao.
- Redis ZERADO (flushall) com envio pendente: o reconciler re-enfileirou e o worker entregou
  ("Envios orfaos re-enfileirados", requeued=1). Nada se perdeu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:59:39 +02:00
VPS 4 Deploy Agent 3183461118 chore(git): versiona as migrations (a regra *.sql, feita p/ dumps, engolia elas)
newwhats-build-and-promote / build (push) Has been skipped
newwhats-build-and-promote / promote (push) Successful in 2m17s
Sem o migration.sql no git, ninguem recria a tabela scheduled_message_deliveries — e o
codigo do scheduler quebra em qualquer ambiente novo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:44:53 +02:00
VPS 4 Deploy Agent b4741ab3a3 fix(motor): scheduler idempotente por destinatario (fim das mensagens duplicadas)
O agendamento era reenviado INTEIRO em dois casos reais — e os dois pioram com mais sessoes:

1) SOBREPOSICAO DE TICKS (nao precisa de crash): o envio e sequencial, ~1 msg/s. Um lote de
   100 destinatarios leva ~100s, mas o tick roda a cada minuto — o tick seguinte pegava o
   MESMO agendamento (ainda ACTIVE, nextRunAt <= now) e comecava a enviar tudo de novo, em
   paralelo com o primeiro.
2) CRASH/DEPLOY NO MEIO DO LOTE: o status so era gravado DEPOIS de percorrer todos os
   destinatarios. Ao reprocessar, quem ja tinha recebido recebia outra vez.

Agora ha uma linha por (agendamento, execucao, destinatario) na tabela nova
scheduled_message_deliveries, com UNIQUE (scheduleId, runKey, jid). Antes de enviar, o worker
RESERVA o destinatario com um INSERT: quem perde a corrida pula. Depois do envio marca SENT —
e SENT nunca e reenviado. FAILED pode ser retentado; SENDING orfao (>10min, processo morreu)
e retomado. runKey identifica a EXECUCAO (ONCE=once, RECURRING=ISO da ocorrencia, EVENT=dia),
entao a proxima ocorrencia legitima envia normalmente.

E o UNIQUE do banco que decide, nao uma checagem em memoria: dois ticks simultaneos disputam
o INSERT e so um ganha.

⚠️ A migracao foi escrita A MAO de proposito: `prisma migrate diff` gera DROP TABLE de TODAS as
tabelas sec_* (a Secretaria inteira) e do plugin_config_store, porque elas nascem das migrations
knex dos plugins e nao estao no schema.prisma. NUNCA rodar `prisma migrate deploy`/`db push`
neste projeto. Ver o aviso no topo do migration.sql.

Testado em dev (3 cenarios, contra o banco real): dois ticks concorrentes -> exatamente UM
envia; apos restart -> NAO reenvia para quem ja recebeu; nova ocorrencia -> envia normalmente.
Tabela aplicada em newwhats_dev; as 17 tabelas sec_* seguem intactas.

Passo 1 de 3 do plano de escala (2: persistencia no cache; 3: BullMQ como worker com
rate-limit por numero e retry/backoff, alimentado por este outbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:44:04 +02:00
VPS 4 Deploy Agent 7a7d33ce05 feat(infra): standby do Postgres de producao na VPS4 (replica fisica em streaming)
A VPS3 e ponto unico de falha: banco + cache + Gitea + registry na mesma maquina. Agora ha
uma copia QUENTE dos dados de producao fora dela — replica fisica (streaming) do cluster
inteiro, rodando na VPS4.

- pg-standby/: stack nova. Imagem postgis 18 (o cluster de prod usa PostGIS no scoreodonto);
  rede soc, sem porta publicada; hot_standby (aceita SELECT); memoria modesta para nao
  competir com os builds do CI.
- primario (VPS3): role replicator com senha nova (ninguem usava), slot fisico standby_vps4,
  e max_slot_wal_keep_size=2GB — GUARDA IMPORTANTE: sem isso, um standby parado faz o
  primario reter WAL para sempre e ENCHER O DISCO da VPS3. Com o teto, o slot e invalidado
  (a replica se refaz) e a producao continua de pe.
- pg_basebackup nao traz postgresql.conf (no Debian a config fica em /etc/postgresql, fora do
  PGDATA) e a imagem do PG 18 usa PGDATA=/var/lib/postgresql/18/docker por padrao: por isso o
  compose fixa PGDATA e o volume tem postgresql.conf/pg_hba.conf proprios do standby.

Verificado: pg_stat_replication no primario mostra 10.99.0.4 streaming com replay_lag de 19ms;
escrita no primario aparece na replica em segundos; a replica recusa escrita (read-only); os 9
bancos chegaram integros (dental_images 2822 imagens, newwhats_prod 125885 mensagens,
scoreodonto 1750 pacientes).

NAO E BACKUP: replica reproduz fielmente o DROP TABLE errado. Protege contra a MAQUINA morrer,
nao contra erro humano. NAO e failover automatico: promover exige acao manual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:44:54 +02:00
VPS 4 Deploy Agent 5d17ac0cba feat(dev): Dragonfly proprio para o dev (VPS4) — cache isolado da producao
Ate agora os ambientes de dev usavam o Dragonfly de PRODUCAO (VPS3), separados so pelo
numero do db. Isolamento logico, mesma maquina e mesmo processo: um FLUSHALL distraido,
uma chave sem prefixo ou um pico de memoria em dev batia na producao. Agora dev tem cache
proprio na VPS4, mesma logica do pgdev (banco de dev isolado).

- dragonfly-dev/: stack nova (rede soc, sem portas publicadas, senha propria em .env fora
  do git, maxmemory=1gb). --proactor_threads=2 e obrigatorio: o Dragonfly exige ~256MB de
  teto POR THREAD e, sem isso, abre uma por CPU (6 aqui) e recusa a subir.
- newwhats dev: DRAGONFLY_URL passa a apontar para dragonfly-dev:6379/1.
- rx dev: REDIS_HOST=dragonfly-dev (db 3). O .env nao e versionado.
- as 4 chaves do dev (incluindo plugin:config:secretaria, com as chaves de LLM) foram
  COPIADAS para o cache novo antes da virada; os dbs de dev (1 e 3) foram limpos no
  Dragonfly de producao, que agora so tem prod (db 0 = rx, db 2 = motor).

Verificado: dev.rx e motor dev com cache ok apontando para dragonfly-dev; config da
Secretaria integra; producao (rx v2.2.1 e motor v1.0.3) intacta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:35:38 +02:00
VPS 4 Deploy Agent b74833af59 fix(ci): placeholder de DRAGONFLY_PASS no build do motor
newwhats-build-and-promote / build (push) Has been skipped
newwhats-build-and-promote / promote (push) Successful in 7m52s
O compose do motor ganhou ${DRAGONFLY_PASS:?} quando o cache foi para a VPS3
(fee07c8), mas o workflow so definia NEWWHATS_DB_PASS. O compose aborta na
interpolacao antes de buildar: 'required variable DRAGONFLY_PASS is missing a
value'. Nao entra na imagem (e environment:, nao build-arg).

Passou despercebido porque as imagens v1.0.x foram transportadas a mao
(docker save | ssh docker load) — este caminho do CI nunca tinha rodado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:34:43 +02:00
VPS 4 Deploy Agent c5a2198dd9 fix(motor): /health checa banco e cache (503 quando o Postgres cai)
newwhats-build-and-promote / build (push) Failing after 24s
newwhats-build-and-promote / promote (push) Has been skipped
O endpoint respondia {status:ok} sem tocar em nada. Em 11/07/2026 o Postgres da
VPS3 recusou o motor por horas (pg_hba) e o /health seguiu dizendo 'ok' o tempo
todo — a queda so apareceu no log do Postgres. Agora segue o padrao de rx e
scoreodonto: checa banco (prisma) e cache (dragonfly), devolve latencias,
version/commit/environment/uptime, e responde 503 quando o banco esta fora.

O cache usa raw().ping() porque os metodos do DragonflyClient engolem excecoes.
Nenhum healthcheck de container aponta para /health, entao o 503 nao provoca
restart loop.

Testado em dev: caminho feliz 200 com checks; com o Postgres pausado, 503.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:51:54 +02:00
VPS 4 Deploy Agent 765f5287c6 chore(ops): aposenta os webhooks de deploy legados (CI build-once assumiu)
Os deploys de rx, rx-client e scoreodonto passaram a ser feitos pelo ciclo
Build Once -> Promote -> Deploy no CI (push publica por commit; tag vX.Y.Z
promove o mesmo artefato e deploya). Os listeners de webhook e suas units
systemd (webhook-rx, webhook-rx-client, webhook-scoreodonto) ficaram sem uso
e foram removidos; o webhook-listener do Clube67 continua ativo.

O GITEA_TOKEN sai do codigo e do unit file: agora vem do EnvironmentFile
/home/deploy/webhook-listener.env (600, fora do git).

Ponteiros dos submodulos atualizados: rx (d812d5c, remove o deploy legado)
e scoreodonto (4cd8334).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 17:11:57 +02:00
VPS 4 Deploy Agent eff6c214be chore(motor): remove Postgres/Dragonfly locais da VPS1 (dados na VPS3)
Concluídos os testes da migração para a VPS3, os serviços locais que ficaram de
pé para rollback foram removidos:
- docker-compose.yml: removidos os serviços newwhats-db (postgres:16) e
  newwhats-cache (redis:7), os volumes newwhats-db-data/cache-data e o mount do
  db/init.sql. Ficam só backend, frontend, nginx — e os volumes de sessões/mídia,
  que permanecem locais ao motor.
- deploy-prod.sh: removido o step que subia banco/cache locais (agora 3 passos).

Na VPS1: containers newwhatsmotor-newwhats-{db,cache}-1 e volumes de dados
removidos. Backup final do banco local em
/home/deploy/backups/newwhats-LOCAL-vps1_final_*.sql.gz (18K, subconjunto do que
já está na VPS3). O db/init.sql fica versionado como referência do schema.

Motor segue no ar (v1.0.2) usando newwhats_prod + Dragonfly /2 na VPS3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:59:39 +02:00
VPS 4 Deploy Agent b9263a6017 feat(deploy): poda imagens antigas ao fim do deploy do motor (atual + 1 anterior)
Mesma poda embutida no deploy-prod.sh do scoreodonto, agora no do motor: após
subir a nova versão, remove as imagens vX.Y.Z locais além das 2 mais recentes de
newwhats-{backend,frontend} — a atual e a anterior (rollback). Só tags vX.Y.Z;
nunca imagem em uso; best-effort (|| true), não aborta deploy nem rollback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 02:54:57 +02:00
VPS 4 Deploy Agent fee07c809c feat(motor): banco e cache na VPS3 (padroniza com o scoreodonto)
O Postgres do motor deixa de rodar num container na VPS1 e passa a usar o
banco `newwhats_prod` no Postgres nativo da VPS3 (10.99.0.3), e o cache passa
a usar o Dragonfly da VPS3 (DB /2; o dev usa /1). Mesmo padrão do scoreodonto:
backend/storage centralizados na VPS3, acessíveis pela VPN.

- backend: DATABASE_URL → 10.99.0.3:5432/newwhats_prod; DRAGONFLY_URL → 10.99.0.3:6379/2.
- backend entra na rede externa `soc` (além da `motor`): é por ela que o container
  alcança o WireGuard do host e, por ele, a VPS3 — a rede `motor`, isolada, não
  tinha rota para 10.99.0.3. O scoreodonto usa a mesma `soc`.
- removido o depends_on do db/cache locais (o backend não os usa mais).

Os serviços newwhats-db/newwhats-cache locais seguem DEFINIDOS e de pé de
propósito (rollback durante os testes) — serão removidos, com os volumes e o
init.sql, só quando o dono concluir a validação.

Dados: banco = clone completo de newwhats_dev (7 users, 2 instances, 125.885
messages, cérebro da Secretária), via pg_dump/pg_restore. Sessões de WhatsApp
NÃO migram (são arquivos, não banco). JWT_SECRET do motor prod passou a ser o do
dev, para o plugin_config_store (cifrado) decifrar direto; auto_reply=false.
Boot limpo e config durável verificados após 2 restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:07:23 +02:00
VPS 4 Deploy Agent 403c5623da feat(ops): sync de config/contas dev→prod do motor (newwhats)
Equivalente ao sync-config-dev-para-prod.sh do scoreodonto, adaptado ao motor:
o Postgres de prod NÃO é exposto na VPN (roda no container newwhatsmotor-newwhats-db-1
na VPS1), então o acesso é via SSH + docker exec, não docker run --network host.

Upsert (INSERT ... ON CONFLICT (id) DO UPDATE) das 11 tabelas de config/conta —
users, sectors, instances, teams, api_keys, plugin_pairs, ext_webhooks, sec_agents,
sec_brain_nodes, sec_numbers, sec_situacoes — na ordem de FK, numa transação.
INSERE o que falta, ATUALIZA o que mudou, nunca apaga o que só existe em prod.
NÃO migra sessões/mídias nem messages/chats/contacts/sec_conversations.

--with-config migra também a config cifrada do plugin (plugin_config_store): lê em
claro do Dragonfly de dev, força auto_reply=false, e regrava via pluginConfig.set()
no backend de prod, que recifra com o JWT de prod (o blob não é portável entre
ambientes pois a chave deriva do JWT_SECRET). Sem arg = dry-run (ROLLBACK);
--apply faz backup do prod antes de comitar.

Dev e prod já estão idênticos (migração manual de 10/jul), então o dry-run mostra
0 inseridos / N atualizados no-op — o script serve para os próximos ciclos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:03:06 +02:00
23 changed files with 1284 additions and 81 deletions
+4 -1
View File
@@ -54,8 +54,11 @@ jobs:
GIT_COMMIT: ${{ steps.vars.outputs.commit }}
BUILD_TIME: ${{ steps.vars.outputs.build_time }}
# Interpolação do compose: fail-fast `:?` no runtime, placeholder no build.
# Não entra na imagem (é `environment:`, não build-arg).
# Não entra na imagem (é `environment:`, não build-arg). Precisa de UMA entrada
# aqui para cada `${VAR:?}` do docker-compose.yml, senão o build nem começa
# (o compose aborta ao interpolar). Hoje: NEWWHATS_DB_PASS e DRAGONFLY_PASS.
NEWWHATS_DB_PASS: build-placeholder
DRAGONFLY_PASS: build-placeholder
run: docker compose build newwhats-backend newwhats-frontend
- name: Publicar por COMMIT (:<sha> + :latest)
+3
View File
@@ -19,6 +19,9 @@ ssh_alerts_config.json
# Databases / Backups / Dumps
*.sql
# ...exceto migrations: elas SAO codigo (sem elas ninguem recria o schema).
!**/prisma/migrations/**/*.sql
!**/migrations/*.sql
*.tar.gz
*.zip
+45
View File
@@ -0,0 +1,45 @@
# Dragonfly de DESENVOLVIMENTO (VPS4).
#
# Por quê: até 11/07/2026 os ambientes de dev usavam o Dragonfly de PRODUÇÃO (VPS3),
# separados só pelo número do db. Isolamento lógico, na mesma máquina e no mesmo processo:
# um `FLUSHALL` distraído, um bug de chave sem prefixo ou um pico de memória em dev batia
# na produção. Agora dev tem cache PRÓPRIO, aqui na VPS4 — a mesma lógica do `pgdev`
# (banco de dev isolado). A produção segue no Dragonfly da VPS3, intocada.
#
# Rede `soc`: é por ela que os containers de dev resolvem o host `dragonfly-dev`
# (a mesma rede onde vive o `pgdev`).
#
# Numeração dos db (mantida igual à que os projetos já usavam, para não confundir a doc):
# 1 = newwhats (motor) dev 3 = rx.scoreodonto dev
# É cache: NÃO tem persistência. Reiniciar = cache vazio, e isso é aceitável — o motor
# repovoa a config da Secretária a partir do Postgres (`plugin_config_store`) no boot.
services:
dragonfly-dev:
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
container_name: dragonfly-dev
restart: unless-stopped
# --maxmemory: teto do cache. A VPS4 tem 11 GB e roda os ambientes de dev + o runner do
# CI (que builda imagens grandes) — 1 GB é folgado para cache de dev.
# --proactor_threads=2: o Dragonfly exige ~256 MB de teto POR THREAD e, sem isto, abre uma
# por CPU (6 aqui) e recusa a subir: "There are 6 threads, so 1.50GiB are required".
# 2 threads bastam para dev e deixam as CPUs livres para os builds do CI.
command: >
--requirepass=${DRAGONFLY_DEV_PASS:?defina DRAGONFLY_DEV_PASS em .env}
--maxmemory=1gb
--proactor_threads=2
--dbnum=16
networks:
- soc
# Sem `ports:` de propósito: só é alcançável de dentro da rede `soc` (pelos containers
# de dev). Não fica exposto na VPS4 nem na VPN.
healthcheck:
test: ["CMD", "redis-cli", "-a", "${DRAGONFLY_DEV_PASS}", "ping"]
interval: 30s
timeout: 5s
retries: 3
security_opt:
- no-new-privileges:true
networks:
soc:
external: true
+28 -6
View File
@@ -37,7 +37,7 @@ echo " Deploy MOTOR (NewWhats) → PRODUÇÃO tag: $TAG"
echo " Host: newwhats.clube67.com (clube67.com NÃO é tocado)"
echo "──────────────────────────────────────────────────────────────"
echo "[1/4] pull das imagens $TAG (registry pela VPN) ..."
echo "[1/3] pull das imagens $TAG (registry pela VPN) ..."
# O registry é 10.99.0.3:3000 (WireGuard): o proxy HTTPS de git.clube67.com corta
# uploads em ~60 s e o blob de 175 MB do backend não passa por ele. Se o pull ainda
# assim falhar (VPN fora do ar), exigimos a imagem local COM A TAG EXATA — nunca :latest.
@@ -55,19 +55,41 @@ if ! docker compose $FILES pull newwhats-backend newwhats-frontend 2>/dev/null;
done
fi
echo "[2/4] subindo banco e cache ..."
docker compose $FILES up -d newwhats-db newwhats-cache
echo "[3/4] subindo aplicação (sem build) ..."
echo "[2/3] subindo aplicação (sem build) ..."
# Banco e cache ficam na VPS3. O `queue-redis` é LOCAL e precisa estar de pé ANTES do
# backend: é a fila de envio (BullMQ). Sem ele o backend sobe sem conseguir enfileirar.
docker compose $FILES up -d --no-build queue-redis
docker compose $FILES up -d --no-build newwhats-backend newwhats-frontend nginx
echo "[4/4] verificação (aguardando boot) ..."
echo "[3/3] verificação (aguardando boot) ..."
sleep 12
echo -n " versão no ar: "
curl -s --max-time 10 https://newwhats.clube67.com/api/version || echo "(ainda subindo)"
echo
docker compose $FILES logs --tail 8 newwhats-backend 2>&1 | grep -iE 'migration|plugin|listen|error' | tail -6 || true
echo "[poda] removendo imagens antigas (mantém as 2 versões mais recentes) ..."
# Política "atual + 1 anterior": após deployar, apaga as imagens vX.Y.Z locais
# além das 2 mais recentes de cada repo — a atual (recém-subida) e a anterior
# (rollback). Best-effort: roda com tudo já no ar e NUNCA aborta o deploy.
prune_old_images() {
local keep=2 repo tag olds inuse
inuse=$(docker ps --format '{{.Image}}' 2>/dev/null | sort -u)
for repo in "$@"; do
olds=$(docker images --format '{{.Tag}}' "$repo" 2>/dev/null \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | head -n -"$keep") || true
[ -z "$olds" ] && continue
while IFS= read -r tag; do
[ -z "$tag" ] && continue
printf '%s\n' "$inuse" | grep -qxF "$repo:$tag" && continue # nunca em uso
docker rmi "$repo:$tag" >/dev/null 2>&1 && echo " removida: $repo:$tag"
done <<< "$olds"
done
}
prune_old_images \
10.99.0.3:3000/ruicesar/newwhats-backend \
10.99.0.3:3000/ruicesar/newwhats-frontend || true
echo
echo "✅ Deploy do motor concluído (tag $TAG)."
echo " Rollback: ./deploy-prod.sh <tag-anterior>"
+57 -40
View File
@@ -25,23 +25,60 @@ services:
environment:
- PORT=8008
- NODE_ENV=production
- DATABASE_URL=postgresql://newwhats:${NEWWHATS_DB_PASS:?defina NEWWHATS_DB_PASS}@newwhats-db:5432/newwhats?schema=public
- DRAGONFLY_URL=redis://newwhats-cache:6379
# Banco e cache ficam na VPS3 (10.99.0.3), padronizado com o scoreodonto.
# Alcançados pela rede `soc` (que tem rota para o WireGuard do host).
# Banco: newwhats_prod (role newwhats). Cache: Dragonfly DB /2 (dev usa /1).
- DATABASE_URL=postgresql://newwhats:${NEWWHATS_DB_PASS:?defina NEWWHATS_DB_PASS}@10.99.0.3:5432/newwhats_prod?schema=public
- DRAGONFLY_URL=redis://:${DRAGONFLY_PASS:?defina DRAGONFLY_PASS}@10.99.0.3:6379/2
# Fila de envio (BullMQ). Redis PRÓPRIO e LOCAL — NÃO é o Dragonfly da VPS3:
# • o Dragonfly é cache e NÃO persiste: um restart apagaria os jobs pendentes;
# • a fila no caminho crítico não pode depender da VPN (o túnel morre ocioso).
# Ver queue-redis abaixo.
- QUEUE_REDIS_URL=redis://queue-redis:6379/0
# Teto de envio por NÚMERO (instância), por minuto — é o anti-ban de verdade,
# no lugar do sleep fixo que existia dentro do loop do scheduler.
- WA_SEND_RATE_PER_MIN=${WA_SEND_RATE_PER_MIN:-30}
- BAILEYS_SESSIONS_PATH=/app/sessions
- FRONTEND_URL=https://newwhats.clube67.com
volumes:
# Estado que NÃO vive na imagem: sessões do WhatsApp e mídia.
- newwhats-sessions:/app/sessions
- newwhats-media:/app/media
depends_on:
newwhats-db:
condition: service_healthy
newwhats-cache:
condition: service_started
security_opt:
- no-new-privileges:true
networks:
- motor
- motor # nginx/frontend
- soc # rota até a VPS3 (banco + Dragonfly)
# Redis DEDICADO à fila de envio (BullMQ). Não é cache — é o registro do trabalho a fazer.
#
# Por que não usar o Dragonfly (VPS3): ele é cache e NÃO persiste; um restart (aconteceu em
# 11/07) apagaria os jobs pendentes. E ele fica do outro lado da VPN, cujo túnel morre ocioso.
# Aqui: local ao motor, com AOF (appendfsync everysec) — sobrevive a restart e a reboot.
#
# Mesmo assim a fila NÃO é a fonte da verdade: quem manda é o outbox no Postgres
# (scheduled_message_deliveries). Se este Redis for zerado, o reconciler re-enfileira o que
# ficou pendente. Perder este volume atrasa envios; não perde mensagens.
queue-redis:
image: redis:7-alpine
restart: always
command: >
redis-server
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy noeviction
volumes:
- newwhats-queue:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 3
security_opt:
- no-new-privileges:true
networks:
- motor # só o backend fala com ele; sem porta publicada
newwhats-frontend:
build:
@@ -56,36 +93,9 @@ services:
networks:
- motor
newwhats-db:
image: postgres:16-alpine
restart: always
environment:
- POSTGRES_USER=newwhats
- POSTGRES_PASSWORD=${NEWWHATS_DB_PASS:?defina NEWWHATS_DB_PASS}
- POSTGRES_DB=newwhats
volumes:
- newwhats-db-data:/var/lib/postgresql/data
# Só roda no primeiro boot (volume vazio): cria as 20 tabelas do Prisma.
# As tabelas dos plugins (sec_*, plugin_config_store) nascem no boot do backend.
- ./db/init.sql:/docker-entrypoint-initdb.d/10-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U newwhats -d newwhats"]
interval: 5s
timeout: 5s
retries: 12
networks:
- motor
newwhats-cache:
image: redis:7-alpine
restart: always
# appendonly: a config dos plugins é cache quente aqui, mas a fonte durável
# é o Postgres (plugin_config_store, cifrado). Persistir evita reseed a cada boot.
command: ["redis-server", "--appendonly", "yes"]
volumes:
- newwhats-cache-data:/data
networks:
- motor
# Banco e cache foram movidos para a VPS3 (Postgres nativo + Dragonfly), ver o
# DATABASE_URL/DRAGONFLY_URL do backend acima. Os serviços locais postgres:16 e
# redis:7 (e o db/init.sql que semeava o schema no 1º boot) foram removidos.
nginx:
image: nginx:alpine
@@ -100,12 +110,19 @@ services:
- web
volumes:
# Só o estado que permanece local ao motor: sessões do WhatsApp e mídia.
# (db-data/cache-data foram removidos junto com os serviços — dados na VPS3.)
newwhats-sessions:
newwhats-media:
newwhats-db-data:
newwhats-cache-data:
# Fila de envio (BullMQ/AOF). Perder isto atrasa envios pendentes; não perde mensagens
# (o outbox no Postgres é a fonte da verdade e o reconciler re-enfileira).
newwhats-queue:
networks:
motor:
web:
external: true
# Rede compartilhada (criada fora deste compose) por onde os containers alcançam
# o WireGuard do host e, por ele, a VPS3 (10.99.0.3). O scoreodonto usa a mesma.
soc:
external: true
+167 -4
View File
@@ -25,6 +25,7 @@
"baileys": "file:./baileys-7.0.0-rc.9.tgz",
"baileys-official": "npm:baileys@^7.0.0-rc13",
"bcryptjs": "^3.0.3",
"bullmq": "^5.80.2",
"cors": "^2.8.5",
"croner": "^10.0.1",
"dotenv": "^16.4.7",
@@ -2776,6 +2777,78 @@
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"license": "MIT"
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
]
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -5102,6 +5175,30 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bullmq": {
"version": "5.80.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.80.2.tgz",
"integrity": "sha512-IobIZd0xFtpnw9W9RdqDX45nfdI2QSmq8rudMj99p3V5mIm9v4I3uPObqKj7i+6Xfd8Lzs3n1p+YNB7s6ykF4Q==",
"dependencies": {
"cron-parser": "4.9.0",
"ioredis": "5.10.1",
"msgpackr": "2.0.4",
"node-abort-controller": "3.1.1",
"semver": "7.8.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=12.22.0"
},
"peerDependencies": {
"redis": ">=5.0.0"
},
"peerDependenciesMeta": {
"redis": {
"optional": true
}
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -5378,6 +5475,17 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/croner": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz",
@@ -7016,6 +7124,14 @@
"node": "20 || >=22"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7134,6 +7250,35 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz",
"integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
@@ -7255,6 +7400,11 @@
"node": ">=10.0.0"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@@ -7264,6 +7414,20 @@
"node": ">=6.0.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
@@ -8243,10 +8407,9 @@
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"bin": {
"semver": "bin/semver.js"
},
@@ -30,6 +30,7 @@
"baileys": "file:./baileys-7.0.0-rc.9.tgz",
"baileys-official": "npm:baileys@^7.0.0-rc13",
"bcryptjs": "^3.0.3",
"bullmq": "^5.80.2",
"cors": "^2.8.5",
"croner": "^10.0.1",
"dotenv": "^16.4.7",
@@ -0,0 +1,36 @@
-- Idempotência de envio do scheduler: 1 linha por (agendamento, execução, destinatário).
--
-- ⚠️ ESTE SQL FOI ESCRITO À MÃO, DE PROPÓSITO.
-- NÃO rode `prisma migrate deploy` nem `prisma db push` neste projeto: o schema.prisma NÃO
-- descreve as tabelas dos PLUGINS (sec_agents, sec_brain_nodes, sec_conversations,
-- plugin_config_store, …), que nascem por migrations knex dos plugins. O Prisma as considera
-- "sobrando" e gera DROP TABLE para todas elas — apagaria a Secretária inteira.
-- Só o CREATE TABLE abaixo (extraído de `prisma migrate diff`) é seguro.
CREATE TABLE IF NOT EXISTS "scheduled_message_deliveries" (
"id" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"runKey" TEXT NOT NULL,
"jid" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'SENDING',
"error" TEXT,
"sentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "scheduled_message_deliveries_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "scheduled_message_deliveries_scheduleId_runKey_idx"
ON "scheduled_message_deliveries"("scheduleId", "runKey");
-- O UNIQUE é o coração da idempotência: quem perde a corrida do INSERT sabe que o
-- destinatário já foi reservado nesta execução e pula (em vez de enviar de novo).
CREATE UNIQUE INDEX IF NOT EXISTS "scheduled_message_deliveries_scheduleId_runKey_jid_key"
ON "scheduled_message_deliveries"("scheduleId", "runKey", "jid");
ALTER TABLE "scheduled_message_deliveries"
DROP CONSTRAINT IF EXISTS "scheduled_message_deliveries_scheduleId_fkey";
ALTER TABLE "scheduled_message_deliveries"
ADD CONSTRAINT "scheduled_message_deliveries_scheduleId_fkey"
FOREIGN KEY ("scheduleId") REFERENCES "scheduled_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -501,9 +501,10 @@ model ScheduledMessage {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
template MessageTemplate? @relation(fields: [templateId], references: [id])
logs ScheduledMessageLog[]
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
template MessageTemplate? @relation(fields: [templateId], references: [id])
logs ScheduledMessageLog[]
deliveries ScheduledMessageDelivery[]
@@map("scheduled_messages")
}
@@ -522,6 +523,37 @@ model ScheduledMessageLog {
@@map("scheduled_message_logs")
}
/// Idempotência de envio: UMA linha por (agendamento, execução, destinatário).
///
/// Sem isto, um agendamento era reenviado inteiro em dois casos reais:
/// • o lote demora mais que 1 min (1 msg/s) e o tick seguinte pega o MESMO agendamento
/// (ainda ACTIVE, nextRunAt <= now) e envia tudo de novo, em paralelo;
/// • o processo cai/deploya no meio do lote — o status só era gravado NO FIM.
/// Em ambos, quem já recebeu recebia de novo.
///
/// O UNIQUE (scheduleId, runKey, jid) é o que garante a idempotência: antes de enviar,
/// o worker "reserva" o destinatário inserindo a linha. Quem perder a corrida do INSERT
/// simplesmente pula. `runKey` identifica a EXECUÇÃO (ver runKeyFor no scheduler):
/// ONCE = "once" · RECURRING = ISO da ocorrência · EVENT = data do dia.
model ScheduledMessageDelivery {
id String @id @default(uuid())
scheduleId String
runKey String
jid String
/// SENDING (reservado) → SENT (entregue) | FAILED (erro; pode ser retentado)
status String @default("SENDING")
error String?
sentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@unique([scheduleId, runKey, jid])
@@index([scheduleId, runKey])
@@map("scheduled_message_deliveries")
}
enum RecipientMode {
MANUAL
ALL_CONTACTS
@@ -14,6 +14,12 @@ const envSchema = z.object({
TEMPORAL_TASK_QUEUE: z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: z.string().default('./sessions'),
FRONTEND_URL: z.string().default('http://localhost:3000'),
// Fila de envio (BullMQ). Redis DEDICADO e persistente (AOF) — NÃO o Dragonfly, que é
// cache sem persistência: jobs pendentes não podem evaporar num restart.
QUEUE_REDIS_URL: z.string().default('redis://localhost:6379/0'),
// Teto de envio por NÚMERO (instância), por minuto. É o anti-ban real; substitui o
// `sleep` fixo que existia dentro do laço do scheduler.
WA_SEND_RATE_PER_MIN: z.coerce.number().int().positive().default(30),
})
const parsed = envSchema.safeParse(process.env)
@@ -17,6 +17,7 @@ import { schedule as cronSchedule } from 'node-cron'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import { ensureSendWorker, getSendQueue, closeSendQueues, type SendJob } from './send.queue'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
@@ -25,6 +26,7 @@ export class SchedulerService {
private manager: WhatsAppConnectionManager
private minuteJob: ReturnType<typeof cronSchedule> | null = null
private eventJob: ReturnType<typeof cronSchedule> | null = null
private reconcileJob: ReturnType<typeof cronSchedule> | null = null
constructor(manager: WhatsAppConnectionManager) {
this.manager = manager
@@ -45,12 +47,24 @@ export class SchedulerService {
)
})
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
// A cada 5 min — devolve à fila os envios órfãos (Redis zerado, worker morto, retries
// esgotados). É o que torna a fila descartável sem perder mensagem.
this.reconcileJob = cronSchedule('*/5 * * * *', () => {
this.reconcilePending().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no reconciler')
)
})
logger.info('[Scheduler] Iniciado (minuto + evento diário + reconciler a cada 5 min)')
}
stop() {
async stop() {
this.minuteJob?.stop()
this.eventJob?.stop()
this.reconcileJob?.stop()
// Fecha workers/filas: um job interrompido no meio deixa a linha em SENDING, e o
// reconciler a recupera depois (nada se perde).
await closeSendQueues().catch(() => { /* encerrando mesmo assim */ })
}
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
@@ -119,6 +133,124 @@ export class SchedulerService {
// ── Executa um agendamento ─────────────────────────────────────────────────
/**
* Identifica a EXECUÇÃO de um agendamento. Junto com (scheduleId, jid) forma a chave de
* idempotência: reenviar a MESMA execução é proibido; uma execução nova (o próximo disparo
* de um RECURRING, o aniversário do ano que vem) é outra runKey e envia normalmente.
*/
private runKeyFor(sched: any): string {
if (sched.scheduleType === 'ONCE') return 'once'
if (sched.scheduleType === 'EVENT') return new Date().toISOString().slice(0, 10) // 1x por dia
// RECURRING: a ocorrência que venceu. Enquanto o nextRunAt não avança (lote em curso,
// processo reiniciado), a runKey é a mesma — e o UNIQUE impede o reenvio.
const occurrence: Date = sched.nextRunAt ?? sched.scheduledAt ?? new Date()
return new Date(occurrence).toISOString()
}
/**
* Reserva um destinatário para esta execução. Retorna false se ele JÁ foi enviado (ou está
* sendo enviado agora por outro tick) — nesse caso o chamador PULA, sem reenviar.
*
* É o UNIQUE (scheduleId, runKey, jid) no banco que decide, não uma checagem em memória:
* dois ticks simultâneos disputam o INSERT e só um ganha.
*/
private async claimRecipient(scheduleId: string, runKey: string, jid: string): Promise<boolean> {
try {
await prisma.scheduledMessageDelivery.create({ data: { scheduleId, runKey, jid } })
return true
} catch {
// Já existe linha para este (agendamento, execução, destinatário).
const existing = await prisma.scheduledMessageDelivery.findUnique({
where: { scheduleId_runKey_jid: { scheduleId, runKey, jid } },
})
if (!existing) return false
if (existing.status === 'SENT') return false // já recebeu — NUNCA reenviar
// FAILED → pode retentar. SENDING órfão (processo morreu no meio, >10 min) → retomar.
const stale = Date.now() - existing.updatedAt.getTime() > 10 * 60_000
if (existing.status === 'FAILED' || (existing.status === 'SENDING' && stale)) {
await prisma.scheduledMessageDelivery.update({
where: { id: existing.id },
data: { status: 'SENDING', error: null },
})
return true
}
return false // SENDING recente = outro tick está enviando AGORA
}
}
/**
* Envio de UMA mensagem — executado pelo worker da fila (não pelo tick do cron).
* O teto por número e o espaçamento são do BullMQ (limiter + concurrency 1).
*
* Lançar erro aqui é proposital: o BullMQ retenta com backoff. Só quando as tentativas
* acabam é que o outbox fica FAILED — e aí o reconciler pode reprogramar.
*/
private async deliver(job: SendJob): Promise<void> {
const { scheduleId, runKey, instanceId, jid, payload } = job
const sock = this.manager.getSocket(instanceId)
if (!sock) throw new Error(`Socket indisponível para a instância ${instanceId}`)
try {
await sock.sendMessage(jid, payload as any)
await prisma.scheduledMessageDelivery.update({
where: { scheduleId_runKey_jid: { scheduleId, runKey, jid } },
data: { status: 'SENT', sentAt: new Date(), error: null },
})
} catch (err: any) {
// Registra a falha no outbox (mesmo que o BullMQ vá retentar): assim o estado real
// fica visível em SQL, e não só no Redis.
await prisma.scheduledMessageDelivery.update({
where: { scheduleId_runKey_jid: { scheduleId, runKey, jid } },
data: { status: 'FAILED', error: String(err?.message ?? err).slice(0, 500) },
}).catch(() => { /* o throw abaixo já propaga a falha */ })
throw err
}
}
/**
* Reconciler — a rede de segurança que torna a fila descartável.
*
* O Redis da fila é persistente (AOF), mas ainda assim NÃO é a fonte da verdade. Se ele for
* zerado, se um worker morrer no meio de um job, ou se as tentativas do BullMQ se esgotarem,
* sobram linhas no outbox presas em SENDING/FAILED. Este job varre essas linhas e as coloca
* de volta na fila. Sem ele, "perder o Redis" viraria "perder mensagens".
*/
private async reconcilePending() {
const stale = new Date(Date.now() - 10 * 60_000) // 10 min sem avançar = órfão
const pending = await prisma.scheduledMessageDelivery.findMany({
where: { status: { in: ['SENDING', 'FAILED'] }, updatedAt: { lt: stale } },
include: { schedule: true },
take: 500,
})
if (pending.length === 0) return
let requeued = 0
for (const d of pending) {
const sched: any = d.schedule
if (!sched || sched.status === 'CANCELLED') continue
if (!this.manager.getSocket(sched.instanceId)) continue // número offline: tenta no próximo ciclo
ensureSendWorker(sched.instanceId, (job) => this.deliver(job))
const recipient = (sched.recipients as Recipient[]).find((r) => r.jid === d.jid) ?? { jid: d.jid }
await getSendQueue(sched.instanceId).add('send', {
scheduleId: d.scheduleId,
runKey: d.runKey,
instanceId: sched.instanceId,
jid: d.jid,
payload: this.buildPayload(sched.payload as any, recipient),
})
await prisma.scheduledMessageDelivery.update({
where: { id: d.id },
data: { status: 'SENDING', error: null },
})
requeued++
}
if (requeued > 0) {
logger.warn({ requeued }, '[Scheduler] Envios órfãos re-enfileirados a partir do outbox')
}
}
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
const sock = this.manager.getSocket(sched.instanceId)
@@ -128,25 +260,54 @@ export class SchedulerService {
return
}
let sentCount = 0
let failedCount = 0
const errors: string[] = []
const runKey = this.runKeyFor(sched)
let queuedCount = 0
let skippedCount = 0
// Garante que existe um worker consumindo a fila DESTE número (idempotente).
ensureSendWorker(sched.instanceId, (job) => this.deliver(job))
const queue = getSendQueue(sched.instanceId)
for (const recipient of recipients) {
if (!recipient.jid) continue
try {
const payload = this.buildPayload(sched.payload as any, recipient)
await sock.sendMessage(recipient.jid, payload)
sentCount++
// Pequeno delay anti-ban
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
} catch (err: any) {
failedCount++
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
// Idempotência (outbox): o tick seguinte pode pegar o MESMO agendamento e o processo
// pode cair/deployar no meio. Sem a reserva abaixo, quem já recebeu recebia de novo.
if (!(await this.claimRecipient(sched.id, runKey, recipient.jid))) {
skippedCount++
continue
}
// ENFILEIRA em vez de enviar aqui. O envio (com retry/backoff e teto por número) é do
// worker. Assim o tick do cron não fica preso ~1 s por mensagem — o que fazia um lote
// grande atravessar o minuto e ser reprocessado pelo tick seguinte.
await queue.add('send', {
scheduleId: sched.id,
runKey,
instanceId: sched.instanceId,
jid: recipient.jid,
payload: this.buildPayload(sched.payload as any, recipient),
})
queuedCount++
}
if (skippedCount > 0) {
logger.info(
{ scheduleId: sched.id, runKey, skippedCount, queuedCount },
'[Scheduler] Destinatários pulados (já enviados nesta execução) — duplicação evitada'
)
}
// Tick sobreposto: tudo já estava na fila/entregue. Não registra log nem avança o
// agendamento (não foi uma execução nova).
if (queuedCount === 0 && skippedCount > 0) return
// Os contadores do log refletem o que foi ENFILEIRADO. O resultado real de cada envio
// fica no outbox (scheduled_message_deliveries: SENT/FAILED) — é lá que se audita.
const sentCount = queuedCount
const failedCount = 0
const errors: string[] = []
// Persiste log
await prisma.scheduledMessageLog.create({
data: {
@@ -0,0 +1,123 @@
/**
* Fila de envio ao WhatsApp (BullMQ).
*
* POR QUE EXISTE
* O scheduler enviava em linha, dentro do tick do cron: laço sequencial, `sleep` fixo de ~1 s
* entre mensagens. Isso dava (a) teto de ~1 msg/s para TODAS as sessões somadas, (b) nenhum
* retry quando o envio falhava, e (c) nenhum controle real de taxa por número — o que é o que
* de fato provoca ban no WhatsApp.
*
* DESENHO — a fila NÃO é a fonte da verdade
* O dono do trabalho é o **outbox no Postgres** (`scheduled_message_deliveries`, com UNIQUE por
* agendamento+execução+destinatário). O BullMQ é só o EXECUTOR: dá retry com backoff, taxa por
* número e concorrência. Se este Redis for zerado, nada se perde: as linhas do outbox ficam em
* SENDING e o reconciler (no scheduler) as re-enfileira. Ver `scheduler.service.ts`.
*
* UMA FILA POR INSTÂNCIA (`wa-send-<instanceId>`)
* O rate limit do BullMQ é POR FILA. Uma fila só, global, limitaria o sistema inteiro — e o
* limite que importa é por NÚMERO (o WhatsApp bane o número, não o servidor). Rate limit por
* grupo dentro de uma fila é recurso do BullMQ Pro (pago); com uma fila por instância obtemos o
* mesmo efeito no OSS: cada número tem seu teto e sua vez, e um número lento não segura os outros.
*/
import { Queue, Worker, type Job } from 'bullmq'
import IORedis from 'ioredis'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
export type SendJob = {
scheduleId: string
runKey: string
instanceId: string
jid: string
payload: unknown
}
/** Resultado do envio, devolvido ao scheduler para ele marcar o outbox. */
export type SendJobHandler = (job: SendJob) => Promise<void>
// `:` é PROIBIDO em nome de fila no BullMQ (ele usa dois-pontos no namespace das chaves do
// Redis) — usar `wa-send:<id>` faz o construtor lançar "Queue name cannot contain :".
const QUEUE_PREFIX = 'wa-send-'
const queueName = (instanceId: string) => `${QUEUE_PREFIX}${instanceId}`
const queues = new Map<string, Queue<SendJob>>()
const workers = new Map<string, Worker<SendJob>>()
let connection: IORedis | null = null
function getConnection(): IORedis {
if (connection) return connection
connection = new IORedis(env.QUEUE_REDIS_URL, {
// Exigência do BullMQ: sem isto ele recusa a subir (comandos bloqueantes precisam
// esperar indefinidamente em vez de estourar por retry).
maxRetriesPerRequest: null,
})
connection.on('error', (err) => logger.error({ msg: 'Fila: erro no Redis', err: err.message }))
return connection
}
export function getSendQueue(instanceId: string): Queue<SendJob> {
const existing = queues.get(instanceId)
if (existing) return existing
const q = new Queue<SendJob>(queueName(instanceId), {
connection: getConnection(),
defaultJobOptions: {
// 3 tentativas com backoff exponencial (5s, 10s, 20s): cobre queda de rede e
// reconexão do Baileys sem martelar o WhatsApp.
attempts: 3,
backoff: { type: 'exponential', delay: 5_000 },
// Não acumular histórico: o registro de verdade é o outbox no Postgres.
removeOnComplete: 500,
removeOnFail: 1_000,
},
})
queues.set(instanceId, q)
return q
}
/**
* Garante que existe um worker consumindo a fila desta instância.
* Idempotente: chamar de novo não cria um segundo worker.
*/
export function ensureSendWorker(instanceId: string, handler: SendJobHandler): Worker<SendJob> {
const existing = workers.get(instanceId)
if (existing) return existing
const w = new Worker<SendJob>(
queueName(instanceId),
async (job: Job<SendJob>) => handler(job.data),
{
connection: getConnection(),
// 1 mensagem por vez POR NÚMERO: envio paralelo pelo mesmo número é justamente o
// padrão que dispara o antispam do WhatsApp. Números diferentes seguem em paralelo,
// cada um no seu worker.
concurrency: 1,
// Teto por número: WA_SEND_RATE_PER_MIN mensagens por minuto.
limiter: { max: env.WA_SEND_RATE_PER_MIN, duration: 60_000 },
}
)
w.on('failed', (job, err) => {
logger.warn(
{ instanceId, jid: job?.data?.jid, attempt: job?.attemptsMade, err: err.message },
'[Fila] Envio falhou (será retentado se ainda houver tentativas)'
)
})
workers.set(instanceId, w)
logger.info(
{ instanceId, ratePerMin: env.WA_SEND_RATE_PER_MIN },
'[Fila] Worker de envio iniciado (1 msg por vez neste número)'
)
return w
}
/** Encerra filas e workers (shutdown limpo — evita job "pendurado" em SENDING). */
export async function closeSendQueues(): Promise<void> {
await Promise.all([...workers.values()].map((w) => w.close()))
await Promise.all([...queues.values()].map((q) => q.close()))
workers.clear()
queues.clear()
await connection?.quit().catch(() => { /* já fechado */ })
connection = null
}
@@ -167,9 +167,44 @@ async function bootstrap() {
res.json({ online })
})
// Health check (sem autenticação)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
// Health check (sem autenticação). Padrão da stack (mesmo shape de rx/scoreodonto):
// status do backend + banco + cache. Responder 'ok' sem tocar no banco esconde queda —
// em 11/07/2026 o Postgres recusou o motor por horas e este endpoint seguiu dizendo 'ok'.
// 503 quando o banco está fora: o motor não serve para nada sem ele. Nenhum healthcheck
// de container aponta para cá (ver docker-compose.yml), então o 503 não causa restart loop.
app.get('/health', async (_req, res) => {
const checks: Record<string, { status: string; latency_ms?: number }> = {}
try {
const t = Date.now()
await prisma.$queryRaw`SELECT 1`
checks['database'] = { status: 'ok', latency_ms: Date.now() - t }
} catch {
checks['database'] = { status: 'down' }
}
try {
// `raw()` porque os métodos do DragonflyClient engolem exceções (retornariam
// null/false com o cache fora, indistinguível de um miss).
const t = Date.now()
await dragonfly.raw().ping()
checks['cache'] = { status: 'ok', latency_ms: Date.now() - t }
} catch {
checks['cache'] = { status: 'down' }
}
const dbOk = checks['database']?.status === 'ok'
const status = !dbOk ? 'down' : (checks['cache']?.status === 'down' ? 'degraded' : 'ok')
res.status(dbOk ? 200 : 503).json({
status,
version: process.env['APP_VERSION'] ?? 'dev',
commit: process.env['GIT_COMMIT'] ?? 'unknown',
environment: process.env['APP_ENV'] ?? 'DEV',
uptime_s: Math.round(process.uptime()),
timestamp: new Date().toISOString(),
checks,
})
})
// Versão (sem autenticação). A versão semântica NÃO entra na imagem: nasce da
@@ -277,7 +312,9 @@ async function bootstrap() {
// ── Graceful shutdown ─────────────────────────────────────────────────────
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Encerrando servidor...')
scheduler.stop()
// `await`: o stop fecha os workers da fila de envio. Sem esperar, um job em curso morre
// pela metade e a linha do outbox fica em SENDING até o reconciler recuperá-la.
await scheduler.stop()
httpServer.close()
await prisma.$disconnect()
process.exit(0)
@@ -0,0 +1,253 @@
/**
* Situações — o que muda COMO a Secretária atende cada contato.
*
* Duas camadas (o resolvedor está em plugins/secretaria/brain.ts):
* • BASE — exclusiva, por precedência (em_tratamento > cadastrado > lead_novo).
* Define a ABERTURA da conversa. Só UMA vale por contato.
* • SINALIZADOR (overlay) — EMPILHA sobre a base (inadimplente, falta_recente) e
* ajusta os guardrails. Um contato pode ser em_tratamento E inadimplente.
*
* O `roteiro` de cada situação é injetado no TOPO do prompt, acima da persona.
*
* ⚠️ Não confundir com as "regras da clínica" (vêm do satélite) nem com as "janelas de
* contato interno" (comunicação interna). Três coisas diferentes com o mesmo nome.
*/
import { useEffect, useState } from 'react'
import { Loader2, Layers, Flag, Check, X } from 'lucide-react'
import { secSituacaoApi, type SecSituacao } from '../../services/secretariaApi'
export function SituacoesPanel({
situacoes, uso, loading, selectedId, onSelect, onUpdate,
}: {
situacoes: SecSituacao[]
uso: Record<string, number>
loading: boolean
selectedId: string | null
onSelect: (s: SecSituacao) => void
onUpdate: (id: string, d: Partial<SecSituacao>) => void
}) {
const bases = situacoes.filter((s) => s.tipo === 'base')
const overlays = situacoes.filter((s) => s.tipo === 'overlay')
const Item = ({ s }: { s: SecSituacao }) => {
const n = uso[s.chave] ?? 0
return (
<button
onClick={() => onSelect(s)}
className={`w-full text-left px-2.5 py-2 rounded-xl transition-all border ${
selectedId === s.id
? 'bg-brand-500/15 border-brand-500/30'
: 'bg-white/[0.02] border-transparent hover:bg-white/5'
} ${s.ativo ? '' : 'opacity-40'}`}
>
<div className="flex items-center gap-2">
<span className={`text-xs font-bold truncate ${s.ativo ? 'text-slate-200' : 'text-slate-500'}`}>
{s.rotulo || s.chave}
</span>
{!s.ativo && <span className="text-[9px] text-slate-600 uppercase font-black">off</span>}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-slate-600 font-mono">{s.chave}</span>
{/* Uso real nos últimos 30 dias: mostra se a situação está pegando gente de
verdade, em vez de o dono adivinhar. */}
{n > 0 && (
<span className="text-[10px] text-slate-500">· {n} conversa{n > 1 ? 's' : ''}/30d</span>
)}
</div>
</button>
)
}
return (
<div className="flex-1 overflow-y-auto">
<div className="px-3 py-2">
<span className="text-[10px] font-black text-slate-600 uppercase tracking-widest">Situações</span>
</div>
{loading ? (
<div className="flex items-center justify-center py-10"><Loader2 size={20} className="animate-spin text-slate-600" /></div>
) : (
<div className="px-2 pb-4 space-y-4">
<div>
<div className="flex items-center gap-1.5 px-1 mb-1.5">
<Layers size={11} className="text-slate-600" />
<span className="text-[10px] font-black text-slate-600 uppercase tracking-wider">Base</span>
<span className="text-[9px] text-slate-700"> vale UMA (por precedência)</span>
</div>
<div className="space-y-1">
{bases.map((s) => <Item key={s.id} s={s} />)}
</div>
</div>
<div>
<div className="flex items-center gap-1.5 px-1 mb-1.5">
<Flag size={11} className="text-slate-600" />
<span className="text-[10px] font-black text-slate-600 uppercase tracking-wider">Sinalizadores</span>
<span className="text-[9px] text-slate-700"> empilham sobre a base</span>
</div>
<div className="space-y-1">
{overlays.map((s) => <Item key={s.id} s={s} />)}
</div>
</div>
</div>
)}
</div>
)
}
export function SituacaoEditor({
situacao, onUpdate, onClose,
}: {
situacao: SecSituacao
onUpdate: (id: string, d: Partial<SecSituacao>) => Promise<void> | void
onClose: () => void
}) {
const [roteiro, setRoteiro] = useState(situacao.roteiro ?? '')
const [rotulo, setRotulo] = useState(situacao.rotulo ?? '')
const [ativo, setAtivo] = useState(situacao.ativo)
const [dias, setDias] = useState<number>(Number((situacao.config as any)?.dias) || 30)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
// Trocar de situação na lista recarrega o formulário.
useEffect(() => {
setRoteiro(situacao.roteiro ?? '')
setRotulo(situacao.rotulo ?? '')
setAtivo(situacao.ativo)
setDias(Number((situacao.config as any)?.dias) || 30)
setSaved(false)
}, [situacao.id]) // eslint-disable-line react-hooks/exhaustive-deps
const isInadimplente = situacao.chave === 'inadimplente'
const save = async () => {
setSaving(true)
try {
await onUpdate(situacao.id, {
roteiro,
rotulo,
ativo,
// `dias` só existe no inadimplente: é o limite (parcela vencida há mais de N dias).
...(isInadimplente ? { config: { ...(situacao.config ?? {}), dias } } : {}),
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} finally {
setSaving(false)
}
}
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="px-5 py-3 border-b border-white/5 flex items-center justify-between shrink-0">
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-black text-slate-200">{situacao.rotulo || situacao.chave}</h2>
<span className={`text-[9px] px-1.5 py-0.5 rounded font-black uppercase ${
situacao.tipo === 'base' ? 'bg-brand-500/15 text-brand-400' : 'bg-amber-500/15 text-amber-400'
}`}>
{situacao.tipo === 'base' ? 'base' : 'sinalizador'}
</span>
</div>
<p className="text-[11px] text-slate-600 mt-0.5">
{situacao.tipo === 'base'
? 'Define a ABERTURA. Só uma base vale por contato (a de menor prioridade que casar).'
: 'EMPILHA sobre a base e ajusta os guardrails. Pode valer junto com qualquer base.'}
</p>
</div>
<button onClick={onClose} className="text-slate-600 hover:text-slate-300"><X size={16} /></button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<div className="flex items-center gap-4">
<label className="flex-1">
<span className="text-[10px] font-black text-slate-600 uppercase tracking-wider">Rótulo</span>
<input
value={rotulo}
onChange={(e) => setRotulo(e.target.value)}
className="mt-1 w-full bg-white/[0.03] border border-white/5 rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-brand-500/40"
/>
</label>
<label className="flex items-center gap-2 mt-5 cursor-pointer">
<input type="checkbox" checked={ativo} onChange={(e) => setAtivo(e.target.checked)} className="accent-brand-500" />
<span className="text-xs text-slate-400">Ativa</span>
</label>
</div>
{isInadimplente && (
<label className="block">
<span className="text-[10px] font-black text-slate-600 uppercase tracking-wider">
Considerar inadimplente a partir de
</span>
<div className="flex items-center gap-2 mt-1">
<input
type="number" min={1} value={dias}
onChange={(e) => setDias(Number(e.target.value))}
className="w-20 bg-white/[0.03] border border-white/5 rounded-lg px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-brand-500/40"
/>
<span className="text-xs text-slate-500">dias de parcela vencida em aberto</span>
</div>
</label>
)}
<label className="block">
<span className="text-[10px] font-black text-slate-600 uppercase tracking-wider">
{situacao.tipo === 'base' ? 'Roteiro de abertura' : 'Instrução (guardrail)'}
</span>
<p className="text-[11px] text-slate-600 mt-0.5 mb-1.5">
Vai para o <b>topo</b> do prompt, acima da persona. A IA usa como guia não recita para o
cliente. Escreva como instrução ("Este contato já é paciente…", "NÃO agende: encaminhe…").
</p>
<textarea
value={roteiro}
onChange={(e) => setRoteiro(e.target.value)}
rows={12}
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-3 py-2.5 text-xs text-slate-200 leading-relaxed focus:outline-none focus:border-brand-500/40 font-mono"
/>
</label>
</div>
<div className="px-5 py-3 border-t border-white/5 flex items-center justify-end gap-2 shrink-0">
{saved && <span className="text-[11px] text-emerald-400 flex items-center gap-1"><Check size={12} /> salvo</span>}
<button
onClick={save}
disabled={saving}
className="px-4 py-1.5 rounded-lg bg-brand-500/20 hover:bg-brand-500/30 text-brand-300 text-xs font-bold disabled:opacity-50 flex items-center gap-1.5"
>
{saving && <Loader2 size={12} className="animate-spin" />}
Salvar
</button>
</div>
</div>
)
}
/** Carrega situações + uso (30d). Mantém a página enxuta. */
export function useSituacoes() {
const [situacoes, setSituacoes] = useState<SecSituacao[]>([])
const [uso, setUso] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(false)
const load = async () => {
setLoading(true)
try {
const [s, u] = await Promise.all([
secSituacaoApi.list(),
secSituacaoApi.uso().catch(() => ({})), // uso é acessório: se falhar, a tela abre igual
])
setSituacoes(s)
setUso(u)
} finally {
setLoading(false)
}
}
const update = async (id: string, d: Partial<SecSituacao>) => {
const row = await secSituacaoApi.update(id, d)
setSituacoes((prev) => prev.map((s) => (s.id === row.id ? row : s)))
return row
}
return { situacoes, uso, loading, load, update }
}
@@ -8,14 +8,15 @@ import {
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
Maximize2, Save, HelpCircle,
Maximize2, Save, HelpCircle, Layers,
} from 'lucide-react'
import { useSecretariaStore } from '../../store/secretariaStore'
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from '../../services/secretariaApi'
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole, SecSituacao } from '../../services/secretariaApi'
import { SituacoesPanel, SituacaoEditor, useSituacoes } from '../../components/secretaria/SituacoesPanel'
// ─── Thin Nav ─────────────────────────────────────────────────────────────────
type NavTab = 'conversations' | 'brain' | 'calendar' | 'agents' | 'numbers'
type NavTab = 'conversations' | 'brain' | 'calendar' | 'agents' | 'numbers' | 'situacoes'
const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
{ id: 'conversations', icon: MessageSquare, label: 'Conversas' },
@@ -23,6 +24,7 @@ const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
{ id: 'calendar', icon: CalendarDays, label: 'Agenda' },
{ id: 'agents', icon: Bot, label: 'Agentes' },
{ id: 'numbers', icon: Phone, label: 'Números' },
{ id: 'situacoes', icon: Layers, label: 'Situações' },
]
function ThinNav({ active, onChange, onHelp }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void }) {
@@ -1543,6 +1545,8 @@ export default function SecretariaPage() {
const [showNewConv, setShowNewConv] = useState(false)
const [showNewSlot, setShowNewSlot] = useState(false)
const [openNode, setOpenNode] = useState<BrainNode | null>(null)
const [openSituacao, setOpenSituacao] = useState<SecSituacao | null>(null)
const sit = useSituacoes()
const [showHelp, setShowHelp] = useState(false)
// Boot
@@ -1563,8 +1567,12 @@ export default function SecretariaPage() {
const handleTabChange = useCallback((tab: NavTab) => {
setActiveTab(tab)
if (tab !== 'brain') setOpenNode(null)
if (tab !== 'situacoes') setOpenSituacao(null)
if (tab === 'calendar' && store.calendarSlots.length === 0) store.loadCalendar()
}, [store])
// Carrega ao ABRIR a aba (não no boot da página): são poucas linhas, mas não há motivo
// para buscá-las em quem nunca abre Situações.
if (tab === 'situacoes' && sit.situacoes.length === 0) void sit.load()
}, [store, sit])
// ── Agent actions ────────────────────────────────────────────────────────
@@ -1679,6 +1687,17 @@ export default function SecretariaPage() {
/>
)}
{activeTab === 'situacoes' && (
<SituacoesPanel
situacoes={sit.situacoes}
uso={sit.uso}
loading={sit.loading}
selectedId={openSituacao?.id ?? null}
onSelect={(s) => setOpenSituacao(s)}
onUpdate={(id, d) => { void sit.update(id, d) }}
/>
)}
{activeTab === 'numbers' && (
<NumbersPanel
numbers={store.numbers}
@@ -1702,6 +1721,15 @@ export default function SecretariaPage() {
onClose={() => store.selectConversation(null)}
onStatusChange={(status) => store.updateConversationStatus(store.selectedConversation!.id, status)}
/>
) : activeTab === 'situacoes' && openSituacao ? (
<SituacaoEditor
situacao={openSituacao}
onUpdate={async (id, d) => {
const row = await sit.update(id, d)
setOpenSituacao(row) // mantém o editor com o valor salvo
}}
onClose={() => setOpenSituacao(null)}
/>
) : activeTab === 'brain' && openNode ? (
<NodeEditorPanel
node={openNode}
@@ -1720,6 +1748,7 @@ export default function SecretariaPage() {
{activeTab === 'calendar' && 'Gerencie a agenda no painel lateral'}
{activeTab === 'agents' && 'Configure e selecione um agente no painel lateral'}
{activeTab === 'numbers' && 'Gerencie os números e papéis no painel lateral'}
{activeTab === 'situacoes' && 'Escolha uma situação no painel lateral para editar o roteiro'}
</p>
<p className="text-xs text-slate-700 mt-1">Secretária IA NewWhats</p>
</div>
@@ -141,3 +141,33 @@ export const secCalApi = {
update: (id: string, d: Partial<CalendarSlot>) => api.put<CalendarSlot>(`${BASE}/calendar/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/calendar/${id}`).then((r) => r.data),
}
// ─── Situações ────────────────────────────────────────────────────────────────
// Situação do CONTATO (o que muda COMO a Secretária atende cada pessoa).
// Não confundir com as "regras da clínica" (vêm do satélite) nem com as "janelas de
// contato interno" (comunicação interna) — três coisas diferentes, mesmo nome.
export type SituacaoTipo = 'base' | 'overlay'
export interface SecSituacao {
id: string
agent_id: string | null // null = vale para todos os agentes
tipo: SituacaoTipo // base = estágio exclusivo | overlay = sinalizador que empilha
chave: string // em_tratamento | cadastrado | lead_novo | inadimplente | falta_recente
rotulo: string | null
prioridade: number // base: menor = vence (precedência)
roteiro: string | null // injetado no TOPO do system prompt
config: Record<string, unknown> | null // ajustes do overlay (ex.: inadimplente → { dias })
ativo: boolean
created_at: string
updated_at: string
}
export const secSituacaoApi = {
list: (agentId?: string) =>
api.get<SecSituacao[]>(`${BASE}/situacoes`, { params: agentId ? { agent_id: agentId } : {} }).then((r) => r.data),
update: (id: string, d: Partial<SecSituacao>) =>
api.put<SecSituacao>(`${BASE}/situacoes/${id}`, d).then((r) => r.data),
/** Quantas conversas caíram em cada situação nos últimos 30 dias. */
uso: () => api.get<Record<string, number>>(`${BASE}/situacoes/uso`).then((r) => r.data),
}
@@ -303,5 +303,81 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
}
})
// ── Situações ────────────────────────────────────────────────────────────
// Situação do CONTATO (não confundir com as "regras da clínica", que vêm do satélite,
// nem com as "janelas de contato interno" da comunicação interna).
// base → estágio do contato, EXCLUSIVO por precedência (em_tratamento > cadastrado
// > lead_novo). Define a ABERTURA/roteiro.
// overlay → sinalizador que EMPILHA sobre a base (inadimplente, falta_recente).
// Ajusta guardrails. Um contato pode ser em_tratamento E inadimplente.
// O `roteiro` é injetado no TOPO do system prompt, acima da persona (ver brain.ts).
router.get('/situacoes', async (req: Request, res: Response) => {
try {
const agentId = (req.query.agent_id as string) || null
// Global (agent_id null) + as do agente, se houver. A do agente vence a global.
const rows = await db('sec_situacoes')
.where(function () {
this.whereNull('agent_id')
if (agentId) this.orWhere({ agent_id: agentId })
})
.orderBy([{ column: 'tipo' }, { column: 'prioridade' }])
res.json(rows)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
router.put('/situacoes/:id', async (req: Request, res: Response) => {
try {
const { roteiro, rotulo, ativo, prioridade, config } = req.body
// Só o que o dono edita. `tipo` e `chave` são estruturais: mudá-los quebraria o
// resolvedor do brain (que procura por chave) — por isso não entram aqui.
const patch: Record<string, unknown> = { updated_at: new Date() }
if (roteiro !== undefined) patch.roteiro = roteiro
if (rotulo !== undefined) patch.rotulo = rotulo
if (ativo !== undefined) patch.ativo = !!ativo
if (prioridade !== undefined) patch.prioridade = Number(prioridade)
if (config !== undefined) patch.config = config
const [row] = await db('sec_situacoes').where({ id: req.params.id }).update(patch).returning('*')
if (!row) return res.status(404).json({ error: 'Situação não encontrada' })
res.json(row)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// Quantas conversas caíram em cada situação (últimos 30 dias) — mostra ao dono se a
// situação está de fato pegando gente, em vez de ele adivinhar.
router.get('/situacoes/uso', async (_req: Request, res: Response) => {
try {
const desde = new Date(Date.now() - 30 * 86_400_000)
const bases = await db('sec_conversations')
.select('situacao_base')
.count({ n: '*' })
.whereNotNull('situacao_base')
.andWhere('situacao_at', '>=', desde)
.groupBy('situacao_base')
const flags = await db('sec_conversations')
.select('situacao_flags')
.whereNotNull('situacao_flags')
.andWhere('situacao_flags', '<>', '')
.andWhere('situacao_at', '>=', desde)
const overlayCount: Record<string, number> = {}
for (const f of flags) {
for (const k of String((f as any).situacao_flags).split(',').filter(Boolean)) {
overlayCount[k] = (overlayCount[k] ?? 0) + 1
}
}
const uso: Record<string, number> = {}
for (const b of bases) uso[String((b as any).situacao_base)] = Number((b as any).n)
for (const [k, v] of Object.entries(overlayCount)) uso[k] = v
res.json(uso)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
return router
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# =============================================================================
# Sincroniza CONFIGURAÇÃO + CONTAS do motor (newwhats) de DEV → PROD (upsert seguro).
#
# DEV : newwhats_dev (pgdev, VPS4) — fonte da verdade da config
# PROD: newwhats (container na VPS1) — banco do motor em produção
#
# Ao contrário do scoreodonto, o Postgres de prod NÃO é exposto na VPN: roda dentro
# do container `newwhatsmotor-newwhats-db-1` na VPS1. O acesso é via SSH + docker exec.
#
# Método: para cada tabela do escopo, carrega os dados de dev numa staging em prod e
# faz INSERT ... ON CONFLICT (id) DO UPDATE. INSERE o que falta e ATUALIZA o que mudou,
# mas NUNCA apaga registros que só existam em prod. Ordem respeita as FKs.
#
# NÃO migra: sessões de WhatsApp, mídias, messages/chats/contacts/stickers e as
# tabelas de conversa da Secretária (sec_messages, sec_conversations, sec_contact_memory…).
#
# ./sync-config-dev-para-prod.sh → DRY-RUN (ROLLBACK; nada grava)
# ./sync-config-dev-para-prod.sh --apply → aplica (faz backup do prod antes)
#
# A config cifrada do plugin (plugin_config_store: chaves de LLM, agenda_secret) NÃO
# vai pelo upsert — o blob é cifrado com o JWT_SECRET, que difere entre ambientes.
# É migrada à parte, lendo a config em claro do Dragonfly de dev e regravando pelo
# backend de prod (que recifra com o JWT de prod). Use --with-config para incluí-la.
# auto_reply é SEMPRE forçado a false ao migrar a config (a Secretária não liga sozinha).
# =============================================================================
set -euo pipefail
APPLY=""; WITH_CONFIG=""
for a in "$@"; do
case "$a" in
--apply) APPLY=1 ;;
--with-config) WITH_CONFIG=1 ;;
*) echo "arg desconhecido: $a"; exit 1 ;;
esac
done
DEV_CT="pgdev"; DEV_DB="newwhats_dev"; DEV_USER="devadmin"
VPS1="deploy@10.99.0.1"
PROD_CT="newwhatsmotor-newwhats-db-1"
PROD_BACKEND="newwhatsmotor-newwhats-backend-1"
PROD_DB="newwhats"; PROD_USER="newwhats"
DH="export DOCKER_HOST=unix:///run/user/1000/docker.sock"
# Ordem = FK: pais antes de filhos.
TABLES=(users sectors instances teams api_keys plugin_pairs ext_webhooks sec_agents sec_brain_nodes sec_numbers sec_situacoes)
dev_psql() { docker exec -i "$DEV_CT" psql -U "$DEV_USER" -d "$DEV_DB" "$@"; }
prod_psql() { ssh -o BatchMode=yes "$VPS1" "$DH; docker exec -i $PROD_CT psql -U $PROD_USER -d $PROD_DB $*"; }
emit_table_sql() {
local t="$1"
local cols; cols=$(dev_psql -tAc "select string_agg(quote_ident(column_name),',' order by ordinal_position) from information_schema.columns where table_name='$t' and table_schema='public'")
local setclause; setclause=$(dev_psql -tAc "select string_agg(quote_ident(column_name)||'=EXCLUDED.'||quote_ident(column_name),',' order by ordinal_position) from information_schema.columns where table_name='$t' and table_schema='public' and column_name<>'id'")
echo "CREATE TEMP TABLE stg_$t (LIKE public.$t INCLUDING DEFAULTS) ON COMMIT DROP;"
echo "\\copy stg_$t ($cols) FROM STDIN"
dev_psql -c "\\copy (SELECT $cols FROM public.$t) TO STDOUT"
echo "\\."
echo "WITH ins AS ("
echo " INSERT INTO public.$t ($cols) SELECT $cols FROM stg_$t"
echo " ON CONFLICT (id) DO UPDATE SET $setclause"
echo " RETURNING xmax=0 AS inserted"
echo ") SELECT '$t' AS tabela, count(*) FILTER (WHERE inserted) AS inseridos, count(*) FILTER (WHERE NOT inserted) AS atualizados FROM ins;"
}
build_sql() {
echo "BEGIN;"
for t in "${TABLES[@]}"; do emit_table_sql "$t"; done
if [ -n "$APPLY" ]; then echo "COMMIT;"; else echo "ROLLBACK;"; fi
}
migrate_plugin_config() {
echo "→ Migrando config cifrada do plugin (Dragonfly dev → backend prod, recifra; auto_reply=false)..."
local tmp; tmp=$(mktemp)
# o backend de prod está na VPS1 (via SSH); o de dev é local na VPS4.
ssh -o BatchMode=yes "$VPS1" "$DH; docker exec $PROD_BACKEND true" 2>/dev/null || { echo " (backend de prod indisponível — pulando)"; rm -f "$tmp"; return; }
docker exec newwhats-backend-dev sh -c 'node -e "
const R=require(\"ioredis\");const c=new R(process.env.DRAGONFLY_URL);
(async()=>{const keys=await c.keys(\"plugin:config:*\");const out={};
for(const k of keys){const n=k.replace(\"plugin:config:\",\"\");const v=JSON.parse(await c.get(k));
if(\"auto_reply\" in v) v.auto_reply=false; out[n]=v;}
process.stdout.write(JSON.stringify(out));await c.quit()})()"' > "$tmp" 2>/dev/null
if [ -n "$APPLY" ]; then
scp -q "$tmp" "$VPS1:/tmp/nw_plugin_cfg.json"
ssh -o BatchMode=yes "$VPS1" "$DH; docker cp /tmp/nw_plugin_cfg.json $PROD_BACKEND:/tmp/c.json; docker exec $PROD_BACKEND node -e '
const {pluginConfig}=require(\"/app/dist/core/plugin-config.js\");
const all=JSON.parse(require(\"fs\").readFileSync(\"/tmp/c.json\",\"utf8\"));
(async()=>{for(const[n,cfg]of Object.entries(all)){await pluginConfig.set(n,cfg);console.log(\" config gravada:\",n)}process.exit(0)})()
'; docker exec $PROD_BACKEND rm -f /tmp/c.json; rm -f /tmp/nw_plugin_cfg.json"
else
echo " [dry-run] plugins que seriam recifrados em prod:"
python3 -c "import json,sys;print(' ',', '.join(json.load(open('$tmp')).keys()))" 2>/dev/null || echo " (nenhum)"
fi
rm -f "$tmp"
}
if [ -n "$APPLY" ]; then
TS=$(date +%Y%m%d_%H%M)
echo "→ Backup do prod (tabelas do escopo) antes de aplicar..."
ssh -o BatchMode=yes "$VPS1" "$DH; docker exec $PROD_CT pg_dump -U $PROD_USER -d $PROD_DB --data-only $(printf ' -t public.%s' "${TABLES[@]}") | gzip > /home/deploy/backups/newwhats_config_pre-sync_${TS}.sql.gz && echo ' backup: /home/deploy/backups/newwhats_config_pre-sync_${TS}.sql.gz'"
echo "→ Aplicando (COMMIT)..."
else
echo "→ DRY-RUN (nada será gravado; ao final: ROLLBACK)"
fi
build_sql | prod_psql "-v ON_ERROR_STOP=1"
[ -n "$WITH_CONFIG" ] && migrate_plugin_config
echo "✓ concluído."
+58
View File
@@ -0,0 +1,58 @@
# STANDBY do Postgres de PRODUÇÃO (VPS3) — roda na VPS4.
#
# O que é: uma réplica física em streaming (hot standby) do cluster inteiro da VPS3.
# Fica em recovery contínuo, aplicando o WAL do primário. Serve para:
# • ter uma cópia QUENTE dos dados de produção fora da VPS3 (que é ponto único de falha:
# banco + cache + Gitea + registry na mesma máquina);
# • consultas de LEITURA sem pesar no primário (é hot standby: aceita SELECT).
#
# ⚠️ O que NÃO é: BACKUP. Ele replica fielmente — em segundos — o `DROP TABLE` errado, o
# `UPDATE` sem WHERE e a tabela truncada. Réplica protege contra a MÁQUINA morrer, não
# contra o ERRO humano. Backup (dump periódico com retenção) continua sendo outra coisa,
# e ainda não existe.
#
# ⚠️ Também NÃO é failover automático: promover exige ação manual (`pg_ctl promote`) e
# repontar os apps. É uma rede de segurança, não um cluster.
#
# Imagem: postgis 18 — o cluster de produção usa PostGIS (banco `scoreodonto`). Réplica
# física exige o MESMO major do Postgres (18) e as mesmas extensões disponíveis.
# Primário: PG 18.4 + PostGIS 3.6.2. Aqui: PG 18 + PostGIS 3.6.3 (patch difere, compatível).
#
# O volume `pgstandby_data` já vem pronto do `pg_basebackup -R`, que gravou o
# `standby.signal` e o `primary_conninfo` (com o slot `standby_vps4`). Por isso este
# serviço NÃO usa POSTGRES_PASSWORD/initdb: o PGDATA já existe e ele sobe em recovery.
#
# Slot no primário: `standby_vps4`. Se este standby ficar MUITO tempo parado, o primário
# retém WAL por causa do slot e pode encher o disco da VPS3 — ver "Operação" no fim.
services:
pg-standby:
image: postgis/postgis:18-3.6
container_name: pg-standby
restart: unless-stopped
environment:
# A imagem do PG 18 mudou o default para /var/lib/postgresql/18/docker. Sem isto o
# entrypoint não acha o PGDATA do basebackup, acha que é um cluster novo e exige
# POSTGRES_PASSWORD (que não faz sentido aqui — o cluster veio do primário).
- PGDATA=/var/lib/postgresql/data
volumes:
- pgstandby_data:/var/lib/postgresql/data
networks:
- soc
# Sem `ports:` — só é alcançável de dentro da rede `soc` (host `pg-standby:5432`).
# Não expõe produção na VPS4 nem na VPN.
healthcheck:
# `pg_isready` responde OK em recovery; o que importa é estar em pé e aplicando WAL.
test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"]
interval: 30s
timeout: 5s
retries: 3
security_opt:
- no-new-privileges:true
volumes:
pgstandby_data:
external: true
networks:
soc:
external: true
Submodule rx.scoreodonto.com updated: 82a5ed2ca4...d812d5c912
Submodule scoreodonto.com updated: 372431c0a1...4cd83344a5
+1 -1
View File
@@ -9,7 +9,7 @@ const SECRET_TOKEN = 'gitea_webhook_secret_clube67_58e4a92c3d18';
const SCRIPT_PATH = '/home/deploy/stack/clube67/deploy-prod-builder.sh';
const LOG_FILE = '/home/deploy/stack/webhook-listener/deploy.log';
const GITEA_API_URL = 'http://10.99.0.3:3000/api/v1';
const GITEA_TOKEN = process.env.GITEA_TOKEN || 'ada47ca2fb196319dd10245cc216a327dac857ec';
const GITEA_TOKEN = process.env.GITEA_TOKEN;
function log(message) {
const timestamp = new Date().toISOString();
@@ -14,7 +14,7 @@ StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=webhook-listener
Environment=NODE_ENV=production
Environment=GITEA_TOKEN=ada47ca2fb196319dd10245cc216a327dac857ec
EnvironmentFile=/home/deploy/webhook-listener.env
[Install]
WantedBy=multi-user.target