Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7841a66119 | |||
| 2a59e47e21 | |||
| 9ef41a7fd3 | |||
| 3183461118 | |||
| b4741ab3a3 | |||
| 7a7d33ce05 | |||
| 5d17ac0cba | |||
| b74833af59 | |||
| c5a2198dd9 | |||
| 765f5287c6 | |||
| eff6c214be | |||
| b9263a6017 | |||
| fee07c809c | |||
| 403c5623da |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+36
@@ -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)
|
||||
|
||||
+176
-15
@@ -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)
|
||||
|
||||
+253
@@ -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
@@ -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."
|
||||
@@ -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
|
||||
+1
-1
Submodule rx.scoreodonto.com updated: 82a5ed2ca4...d812d5c912
+1
-1
Submodule scoreodonto.com updated: 372431c0a1...4cd83344a5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user