mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
As três frentes viram uma. Resolução dos quatro conflitos, cada uma com razão: - `.env.example` — fica a versão da onda 2 (superset, e explica o risco do segundo aparelho), MAIS as duas variáveis que a onda 3 descobriu serem o que decide se há áudio na VPS. - `lib/env.ts` e `lib/wacalls/client.ts` — fica a onda 2: ela adaptou o cliente ao contrato do upstream autenticado (Bearer obrigatório, `history` paginado). A onda 1 falava com a versão antiga, sem autenticação. - `supabase/baseline.sql` — os DOIS apêndices entram, e a `VARREDURA anon` volta a ser o último bloco do arquivo: é o que garante que ela cure tudo acima. Sem esse cuidado, funções novas nasceriam alcançáveis pela anon key. COLISÃO DE NUMERAÇÃO, e quem cede somos nós: as ondas 1 e 2 criaram um `0234` cada. O da onda 1 (`voice_calls_no_realtime`) fica; o opt-in da onda 2 vira `0236`, com o TIMESTAMP trocado junto — renumerar só o NNNN fabricou 12 colisões de timestamp neste repositório. `lib/wacalls/client.test.ts` foi alinhado à assinatura nova (o cliente passou a exigir o Bearer no construtor); o teste da onda 1 tinha ficado com a antiga. Dois escapes de guarda, e nenhum é edição de verdade: - `DESKCOMM_GOV_MIGRATION_EDIT=1` — renumeração deliberada, escrita acima. - `DESKCOMM_GOV_INVARIANTS_EDIT=1` — os invariantes tocados nasceram nestas branches e não existem na `main`; a guarda protege erosão de invariante estabelecido, não integração de trabalho não mergeado. Trabalho original de @eudanielhenrique no PR #628. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
17 KiB
YAML
343 lines
17 KiB
YAML
# DeskcommCRM — stack de produção self-host (VPS HostGator).
|
|
# Sobe: caddy (HTTPS) → app (Next standalone) + waha + redis+srh + scheduler.
|
|
# Só o Caddy publica portas (80/443). Todo o resto vive na rede interna.
|
|
#
|
|
# docker compose -f docker-compose.prod.yml --env-file .env up -d
|
|
|
|
x-logging: &default-logging
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m" # rotação: evita que o log encha o disco do VPS
|
|
max-file: "3"
|
|
|
|
services:
|
|
app:
|
|
# Teto de memória — 335 MiB de pico MEDIDO nesta VPS.
|
|
#
|
|
# Sem ele o contêiner é ilimitado, e numa VPS de 3,8 GB isso não é teoria:
|
|
# um contêiner ALHEIO (agente da hospedagem, fora deste compose) registrou
|
|
# pico de 3.068 MiB no mesmo host. Quando a RAM acaba, o OOM killer escolhe
|
|
# a vítima por PONTUAÇÃO — quem cresceu mais rápido —, não por importância:
|
|
# o que morre pode ser o CRM.
|
|
#
|
|
# O teto não impede o pico legítimo; impede que UM serviço leve a máquina
|
|
# inteira. Estourá-lo mata só quem estourou, e o `restart: unless-stopped`
|
|
# o traz de volta — falha isolada em vez de queda geral.
|
|
mem_limit: 768m
|
|
# Imagem GENÉRICA pré-buildada: o leigo só puxa (sobe em ~2min, roda em 2GB).
|
|
# Os NEXT_PUBLIC_* reais vêm em RUNTIME (env_file abaixo) — o browser lê via
|
|
# <PublicEnvScript/> e o servidor via lib/env.ts. Não precisa buildar no VPS.
|
|
# Build local (avançado): docker compose -f docker-compose.prod.yml -f docker-compose.build.yml build
|
|
# `:stable` (última release), não `:latest` (topo da `main`). Quem cai neste
|
|
# default é quem NÃO tem APP_IMAGE no .env — avaliação, ou um .env que
|
|
# perdeu a chave. Nenhum desses casos quer código não lançado; quem quiser o
|
|
# topo da main pede `APP_IMAGE=…:latest` explicitamente.
|
|
image: ${APP_IMAGE:-ghcr.io/melgarafael/deskcommcrm:stable}
|
|
pull_policy: ${APP_PULL_POLICY:-always}
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
depends_on:
|
|
srh:
|
|
condition: service_started
|
|
waha:
|
|
condition: service_started
|
|
healthcheck:
|
|
# Probe TCP puro (node existe na imagem; curl/nc não). NÃO usar /api/v1/health:
|
|
# ele dá 503 se WAHA/Redis caem, o que deixaria o app "unhealthy" e derrubaria
|
|
# o Caddy junto (blackout total por uma dependência secundária).
|
|
test: ["CMD", "node", "-e", "require('net').connect(3000,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 40s
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
|
|
# Worker 24/7 do agent-engine (fusão Vendaval) — cérebro do agente SDR.
|
|
worker:
|
|
# Teto de memória — 230 MiB de pico MEDIDO nesta VPS.
|
|
#
|
|
# Sem ele o contêiner é ilimitado, e numa VPS de 3,8 GB isso não é teoria:
|
|
# um contêiner ALHEIO (agente da hospedagem, fora deste compose) registrou
|
|
# pico de 3.068 MiB no mesmo host. Quando a RAM acaba, o OOM killer escolhe
|
|
# a vítima por PONTUAÇÃO — quem cresceu mais rápido —, não por importância:
|
|
# o que morre pode ser o CRM.
|
|
#
|
|
# O teto não impede o pico legítimo; impede que UM serviço leve a máquina
|
|
# inteira. Estourá-lo mata só quem estourou, e o `restart: unless-stopped`
|
|
# o traz de volta — falha isolada em vez de queda geral.
|
|
mem_limit: 512m
|
|
# Imagem publicada — e não mais `build:` sozinho. O motivo é o modo de falha
|
|
# que isso escondia: serviço build-only é PULADO por `docker compose pull`
|
|
# ("Skipped - No image to be pulled") e imune a `up -d` sem `--build`. Ele era
|
|
# construído na VPS de todo cliente, no install, e NENHUM update.sh jamais o
|
|
# reconstruía — o runtime do agente de IA congelava no código do dia da
|
|
# instalação e atravessava todas as atualizações seguintes.
|
|
#
|
|
# O `build:` continua abaixo de propósito: com os dois presentes, o Compose
|
|
# usa a imagem quando ela existe e CONSTRÓI quando não existe (medido). É o
|
|
# que mantém a mudança aditiva — registry fora do ar, ou versão anterior à
|
|
# primeira publicação, caem no comportamento antigo em vez de quebrar.
|
|
#
|
|
# O default é `:stable` (a última RELEASE), não `:latest`, e a diferença é
|
|
# concreta para o parque instalado. Numa VPS que atualiza pela primeira vez
|
|
# depois desta mudança, quem executa é o `update.sh` VELHO — o que está no
|
|
# disco —, e ele só sabe gravar APP_IMAGE. O `git checkout` da tag já trocou
|
|
# ESTE arquivo, então worker e scheduler caem no default daqui. Com
|
|
# `:latest`, esse default seria o TOPO DA MAIN (o canal segue a branch
|
|
# default): o app pinado numa release e o runtime do agente de
|
|
# IA rodando código não lançado, sobre o banco da release. `:stable` é a
|
|
# última release publicada — pareia com o app em vez de correr na frente.
|
|
image: ${WORKER_IMAGE:-ghcr.io/melgarafael/deskcomm-worker:stable}
|
|
pull_policy: ${WORKER_PULL_POLICY:-always}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile.worker
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
environment:
|
|
# dentro da rede do compose, o WAHA é o serviço 'waha' na porta interna 3000
|
|
WAHA_API_BASE_URL: http://waha:3000
|
|
networks:
|
|
- internal
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8787/healthz"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 20s
|
|
waha:
|
|
# Teto de memória — 893 MiB de pico MEDIDO nesta VPS.
|
|
#
|
|
# Sem ele o contêiner é ilimitado, e numa VPS de 3,8 GB isso não é teoria:
|
|
# um contêiner ALHEIO (agente da hospedagem, fora deste compose) registrou
|
|
# pico de 3.068 MiB no mesmo host. Quando a RAM acaba, o OOM killer escolhe
|
|
# a vítima por PONTUAÇÃO — quem cresceu mais rápido —, não por importância:
|
|
# o que morre pode ser o CRM.
|
|
#
|
|
# O teto não impede o pico legítimo; impede que UM serviço leve a máquina
|
|
# inteira. Estourá-lo mata só quem estourou, e o `restart: unless-stopped`
|
|
# o traz de volta — falha isolada em vez de queda geral.
|
|
mem_limit: 1280m
|
|
# Core grátis por padrão; troque para devlikeapro/waha-plus via WAHA_IMAGE se
|
|
# precisar de multi-número/retry/S3 (licença paga).
|
|
#
|
|
# Tag PINADA: `devlikeapro/waha` sem tag é `:latest`, e o `dc pull` do
|
|
# update.sh entregava ao cliente qualquer versão que o upstream tivesse
|
|
# publicado — inclusive uma nunca testada por nós, no meio de uma
|
|
# atualização. `latest-2026.7.2` é o mesmo digest de `latest` hoje
|
|
# (65e593e30bb7…), então o pin não muda um bit; muda quem escolhe quando
|
|
# o WhatsApp do cliente troca de motor. Bumpar faz parte do release.
|
|
image: ${WAHA_IMAGE:-devlikeapro/waha:latest-2026.7.2}
|
|
restart: unless-stopped
|
|
environment:
|
|
# Prefixo sha512: faz o WAHA HASHEAR o X-Api-Key recebido e comparar com este
|
|
# hash. Sem o prefixo ele compara literal → o app (que manda o plaintext) toma 401.
|
|
WAHA_API_KEY: "sha512:${WAHA_API_KEY_SHA512}"
|
|
WHATSAPP_HOOK_URL: ${WAHA_WEBHOOK_BASE_URL}/api/v1/webhooks/waha
|
|
# `message` NÃO entra, e `message.any` sozinho não perde nada — medido.
|
|
#
|
|
# O WAHA emite os DOIS para a mesma mensagem, e `lib/waha/ingest.ts` já
|
|
# chama isso de corrida e deduplica. Só que o arquivo do corpo cru guarda
|
|
# as duas cópias: numa instalação real, 22.939 linhas de `message` ao lado
|
|
# de 24.582 de `message.any` — 41% da tabela que era 86% do banco.
|
|
#
|
|
# A prova de que dá para tirar veio do banco, não da doc: cruzando os ids
|
|
# de mensagem dos dois eventos em 22.815 mensagens reais, os que chegaram
|
|
# SÓ por `message` foram ZERO, e 1.642 chegaram só por `message.any` (os
|
|
# que saíram de nós, que `message` não cobre). Superconjunto estrito.
|
|
WHATSAPP_HOOK_EVENTS: "message.any,message.ack,message.edited,message.revoked,session.status,state.change"
|
|
WHATSAPP_HOOK_HMAC: ${WAHA_HMAC_SECRET}
|
|
# WAHA lê WHATSAPP_DEFAULT_ENGINE (NÃO WAHA_DEFAULT_ENGINE — o nome errado
|
|
# fazia cair no default WEBJS, engine com browser que trava/corrompe a sessão
|
|
# quando o WhatsApp Web atualiza; NOWEB é websocket direto, estável e leve).
|
|
WHATSAPP_DEFAULT_ENGINE: ${WHATSAPP_DEFAULT_ENGINE:-NOWEB}
|
|
WAHA_DASHBOARD_ENABLED: "false" # nunca expor o dashboard num template público
|
|
# Sem isto o container volta e as sessões ficam STOPPED: o número continua
|
|
# pareado em /app/.sessions, mas nada entra nem sai até alguém clicar
|
|
# Reconectar. True retoma as que já existiam — não reloga, não pede QR.
|
|
# Default do WAHA é False.
|
|
WHATSAPP_RESTART_ALL_SESSIONS: "${WHATSAPP_RESTART_ALL_SESSIONS:-True}"
|
|
volumes:
|
|
- waha-data:/app/.sessions # sessões pareadas (QR) — perder = re-parear
|
|
- waha-media:/app/.media
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
restart: unless-stopped
|
|
command: ["redis-server", "--save", "", "--appendonly", "no"] # efêmero (rate-limit/debounce)
|
|
healthcheck:
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
interval: 30s
|
|
timeout: 3s
|
|
retries: 3
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
srh:
|
|
# serverless-redis-http: fala o REST do Upstash sobre o redis normal, mantendo
|
|
# o SDK @upstash/redis sem tocar código.
|
|
#
|
|
# Pinado por DIGEST, não por tag, e o motivo é específico: `latest` e `0.0.10`
|
|
# NÃO são a mesma imagem (5b0bb923… vs 65128347…), então trocar `:latest` por
|
|
# `:0.0.10` mudaria o binário que roda no parque — que é exatamente o risco
|
|
# que a pendência T6 mandava validar antes (encoding base64 do rate limit).
|
|
# O digest congela o que já está em produção HOJE: zero mudança de conteúdo,
|
|
# e o `:latest` deixa de poder mudar sozinho debaixo de todo mundo.
|
|
# Quando a T6 for validada, isto vira `:0.0.10` e o comentário sai.
|
|
image: hiett/serverless-redis-http@sha256:5b0bb9239fce53abf87b2018a7a0deb9ec7bd900c5360738fe5fbeeb426f9150
|
|
restart: unless-stopped
|
|
environment:
|
|
SRH_MODE: env
|
|
SRH_TOKEN: ${SRH_TOKEN}
|
|
SRH_CONNECTION_STRING: "redis://redis:6379"
|
|
depends_on:
|
|
redis:
|
|
condition: service_healthy
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
scheduler:
|
|
# Cron sem docker.sock: crond interno batendo curl na rede interna.
|
|
# Resolução de 1 min (= paridade com os crons da Vercel).
|
|
#
|
|
# A lista de crons mora em docker/scheduler/entrypoint.sh, não mais aqui. O
|
|
# `command:` inline instalava curl e tzdata (`apk add`) a CADA start — num
|
|
# serviço `restart: unless-stopped`, isso significa que o cron do cliente só
|
|
# voltava se a VPS tivesse internet e o mirror do Alpine estivesse de pé,
|
|
# justamente no momento em que a máquina está se recuperando de algo.
|
|
image: ${SCHEDULER_IMAGE:-ghcr.io/melgarafael/deskcomm-scheduler:stable}
|
|
pull_policy: ${SCHEDULER_PULL_POLICY:-always}
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile.scheduler
|
|
restart: unless-stopped
|
|
depends_on:
|
|
app:
|
|
condition: service_healthy
|
|
environment:
|
|
TZ: UTC
|
|
INTERNAL_SECRET: ${INTERNAL_SECRET}
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
# ── Chamada de voz WhatsApp (spec 18) — DESLIGADA POR PADRÃO ──────────────
|
|
#
|
|
# ═══ POR QUE ELA VIVE NUM PROFILE ═══
|
|
#
|
|
# A chamada de voz vincula um SEGUNDO APARELHO ao mesmo número que já atende,
|
|
# por um caminho que não é o oficial: o risco é a CONTA ser bloqueada. Serviço
|
|
# que sobe em toda instalação transforma esse risco em padrão — e um contêiner
|
|
# a mais custa RAM numa VPS onde o WAHA já registrou 893 MiB de pico.
|
|
#
|
|
# `profiles:` é o mesmo mecanismo com que `docker-compose.traefik.yml` desliga
|
|
# o Caddy: sem o profile ativo, o compose NÃO cria o contêiner. Ligar é uma
|
|
# linha no .env — `COMPOSE_PROFILES=voz` — e nada mais.
|
|
#
|
|
# ⚠️ DUAS ARMADILHAS MEDIDAS, as duas do próprio Compose:
|
|
#
|
|
# 1. Nomear o serviço explicitamente (`docker compose up -d wacalls`) ATIVA
|
|
# o profile e sobe o contêiner mesmo com COMPOSE_PROFILES vazio. É por
|
|
# isso que o update.sh checa antes de recriar o caddy, e vale igual aqui.
|
|
# 2. `app` e `worker` NÃO podem ganhar `depends_on: wacalls`. Dependência
|
|
# em serviço de profile inativo não sobe — o CRM inteiro deixaria de
|
|
# subir em toda instalação que não usa a feature.
|
|
#
|
|
# ═══ POR QUE A IMAGEM É DO UPSTREAM, PINADA POR DIGEST ═══
|
|
#
|
|
# WaCalls (github.com/JotaDev66/WaCalls, MIT) é peça UPSTREAM, e a doutrina
|
|
# (docs/doctrine/packaging.md) é explícita: peça upstream se REFERENCIA, nunca
|
|
# se republica — "a regra vale para todas, não só a licenciada, porque a
|
|
# exceção é o que apaga a regra". Construir e publicar `deskcomm-wacalls`
|
|
# seria republicar.
|
|
#
|
|
# O digest é o pin mais forte que existe e o único disponível aqui: `:latest`
|
|
# e `:latest-pure` NÃO existem neste registry (404 medido em 2026-09-08); as
|
|
# tags publicadas são `develop` e `sha-*`. Ele resolve para
|
|
# `sha-d16a076`/`develop`, index OCI multi-arch (linux/amd64 + linux/arm64).
|
|
#
|
|
# `pull_policy: missing` porque o digest é imutável: com `always`, um GHCR
|
|
# fora do ar impediria o contêiner de subir mesmo com a imagem já no disco.
|
|
#
|
|
# ═══ SEM `ports:` — E ISSO É VIGIADO ═══
|
|
#
|
|
# A API do WaCalls fica só na rede interna, alcançável pelo app e pelo worker.
|
|
# `tests/unit/portas-do-compose.test.ts` reprova quem publicar porta aqui.
|
|
wacalls:
|
|
profiles: ["voz"]
|
|
mem_limit: 256m
|
|
image: ${WACALLS_IMAGE:-ghcr.io/jotadev66/wacalls@sha256:9ff52ef9455cf42125a911c38bb8c855f0cc205516cada2054e5c05546909022}
|
|
pull_policy: ${WACALLS_PULL_POLICY:-missing}
|
|
restart: unless-stopped
|
|
environment:
|
|
# O serviço NÃO SOBE sem estes dois — é o upstream que exige, e é o que
|
|
# substitui o build anterior, cujo README dizia "the API has no
|
|
# authentication ... run it only on a trusted LAN".
|
|
WACALLS_ADMIN_USER: ${WACALLS_ADMIN_USER:-}
|
|
WACALLS_ADMIN_PASSWORD: ${WACALLS_ADMIN_PASSWORD:-}
|
|
# Bearer da automação. Sem ele a API só aceita o cookie de login, que um
|
|
# processo server-to-server não tem: o app não conseguiria falar nada.
|
|
WACALLS_API_TOKEN: ${WACALLS_API_TOKEN:-}
|
|
# Vazio = same-origin. Nossa UI não fala com o WaCalls direto do browser —
|
|
# tudo passa pelas rotas /api/v1/voice/*, então não há origem a liberar.
|
|
WACALLS_CORS_ORIGINS: ${WACALLS_CORS_ORIGINS:-}
|
|
WACALLS_TRUSTED_PROXIES: ${WACALLS_TRUSTED_PROXIES:-}
|
|
WACALLS_PUBLIC_IP: ${WACALLS_PUBLIC_IP:-}
|
|
# A porta que o áudio usa, e ela precisa ser FIXA. Sem esta variável o
|
|
# upstream não monta o mux de UDP e cai em portas efêmeras aleatórias —
|
|
# impossíveis de publicar aqui, e o áudio nunca sai da máquina.
|
|
WACALLS_WEBRTC_UDP_PORT: ${WACALLS_WEBRTC_UDP_PORT:-7881}
|
|
ports:
|
|
# A ÚNICA porta publicada por um serviço nosso que não é o proxy, e ela é
|
|
# UDP. Não é exceção sem razão: o áudio do WebRTC não fala HTTP, então não
|
|
# há proxy que o carregue — o Caddy é L7 e não repassa UDP.
|
|
#
|
|
# host == contêiner de PROPÓSITO. O número entra no candidato SDP que o
|
|
# navegador recebe; um remap (7881:7882) faria o navegador mandar mídia
|
|
# para uma porta que o host não escuta, e a ligação ficaria muda com tudo
|
|
# "certo" nos logs.
|
|
#
|
|
# NUNCA TCP: a API HTTP do serviço continua só na rede interna, alcançável
|
|
# apenas pelo app e pelo worker. Publicar TCP aqui exporia a interface de
|
|
# administração dele à internet.
|
|
- "${WACALLS_WEBRTC_UDP_PORT:-7881}:${WACALLS_WEBRTC_UDP_PORT:-7881}/udp"
|
|
volumes:
|
|
# wacalls.db guarda a credencial de sessão do WhatsApp — mesmo padrão do
|
|
# waha-data. Apagar o volume desvincula todos os aparelhos.
|
|
- wacalls-data:/data
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
caddy:
|
|
image: caddy:2-alpine
|
|
restart: unless-stopped
|
|
ports:
|
|
- "80:80"
|
|
- "443:443"
|
|
environment:
|
|
DOMAIN: ${DOMAIN}
|
|
ACME_EMAIL: ${ACME_EMAIL}
|
|
volumes:
|
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
- caddy-data:/data # certificados Let's Encrypt (persistir!)
|
|
- caddy-config:/config
|
|
depends_on:
|
|
app:
|
|
condition: service_healthy
|
|
logging: *default-logging
|
|
networks: [internal]
|
|
|
|
volumes:
|
|
waha-data:
|
|
waha-media:
|
|
wacalls-data:
|
|
caddy-data:
|
|
caddy-config:
|
|
|
|
networks:
|
|
internal:
|
|
driver: bridge
|