From a9ef4d776dcd841858c076969ca14567576d8f13 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Mon, 28 Sep 2026 15:48:44 -0300 Subject: [PATCH] feat: add production deployment and runtime safeguards Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 10 + .env.example | 3 + .env.production.example | 6 + .gitignore | 1 + Caddyfile.example | 4 + Dockerfile | 19 + README.md | 4 + docker-compose.yml | 40 + docs/deployment/oracle-free-docker.md | 98 +++ .../plans/2026-08-03-docker-cloud-247.md | 782 ++++++++++++++++++ .../2026-08-03-docker-cloud-247-design.md | 169 ++++ package.json | 1 + scripts/backup-data.mjs | 76 ++ scripts/servidor.mjs | 29 +- src/alerts/runtime-alerts.mjs | 59 ++ src/alerts/telegram.mjs | 35 + src/env.mjs | 10 + src/server/routes/meta.mjs | 7 +- tests/alerts.test.mjs | 89 ++ tests/backup-data.test.mjs | 70 ++ tests/personal-branding.test.mjs | 39 + tests/server-alerts.test.mjs | 116 +++ tests/server.test.mjs | 37 + 23 files changed, 1696 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.production.example create mode 100644 Caddyfile.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/deployment/oracle-free-docker.md create mode 100644 docs/superpowers/plans/2026-08-03-docker-cloud-247.md create mode 100644 docs/superpowers/specs/2026-08-03-docker-cloud-247-design.md create mode 100644 scripts/backup-data.mjs create mode 100644 src/alerts/runtime-alerts.mjs create mode 100644 src/alerts/telegram.mjs create mode 100644 tests/alerts.test.mjs create mode 100644 tests/backup-data.test.mjs create mode 100644 tests/server-alerts.test.mjs create mode 100644 tests/server.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8152004 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +data +node_modules +test-results +playwright-report +.env +.env.production +*.log +*.sqlite +*.db +.git diff --git a/.env.example b/.env.example index 106f1ac..3d20e6f 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ APP_RESET_PASSWORD= CNPJ_BASE_DIR=prospeccao/cnpj GOOGLE_PLACES_API_KEY= PERSONAL_COMMERCIAL_WHATSAPP= +TELEGRAM_ALERTS_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..a6dacb2 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,6 @@ +APP_RESET_PASSWORD=troque-esta-senha +GOOGLE_PLACES_API_KEY= +PERSONAL_COMMERCIAL_WHATSAPP= +TELEGRAM_ALERTS_ENABLED=true +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= diff --git a/.gitignore b/.gitignore index 3f9186b..5783836 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Maquina-Comercial-Pessoal.exe data/runtime/ launcher/publish/ test-results/ +/task-*-report.md diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..f177185 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,4 @@ +campanhas.seudominio.com { + encode gzip + reverse_proxy app:3910 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd7b3f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:24-bookworm-slim + +WORKDIR /app + +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY src ./src +COPY scripts ./scripts +COPY public ./public +COPY prospeccao/config.json ./prospeccao/config.json + +RUN mkdir -p /app/data/runtime /app/data/exports /app/data/backups + +EXPOSE 3910 + +CMD ["npm", "start"] diff --git a/README.md b/README.md index ce920d1..3460cb8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ O banco, exportações, logs e autenticação do WhatsApp ficam em `data/`. A se Faça backup do banco indicado por `APP_DB_PATH` antes de manutenção ou migração. Nunca compartilhe `.env`, banco, logs ou a pasta de autenticação. +## Deploy 24/7 sem custo + +Veja `docs/deployment/oracle-free-docker.md` para rodar no Oracle Cloud Always Free com Docker, Caddy e alertas Telegram. + ## Comandos ```powershell diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..998f52a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + app: + build: . + restart: unless-stopped + env_file: + - .env.production + environment: + - APP_HOST=0.0.0.0 + - APP_PORT=3910 + - APP_DB_PATH=/app/data/maquina-pessoal.db + - APP_WHATSAPP_AUTH_DIR=/app/data/runtime/whatsapp-auth + - CNPJ_BASE_DIR=/app/prospeccao/cnpj + volumes: + - ./data:/app/data + expose: + - "3910" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3910/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + app: + condition: service_healthy + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docs/deployment/oracle-free-docker.md b/docs/deployment/oracle-free-docker.md new file mode 100644 index 0000000..3b4dde0 --- /dev/null +++ b/docs/deployment/oracle-free-docker.md @@ -0,0 +1,98 @@ +# Oracle Always Free Docker Deployment + +## 1. Create VM +- Use Ubuntu Always Free. +- Prefer Ampere A1 if available. +- Keep boot/block volume within Always Free limits. + +## 2. Keep the VM private first +- Do not open `80/tcp`, `443/tcp`, or public DNS yet. +- Restrict `22/tcp` to your IP. +- The initial account is `admin` / `admin`; change it before the panel is reachable from the internet. + +## 3. Install Docker +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +``` + +## 4. Configure app +```bash +cp .env.production.example .env.production +nano .env.production +cp Caddyfile.example Caddyfile +nano Caddyfile +``` + +For Telegram alerts: +- Create a bot with BotFather and copy the bot token into `TELEGRAM_BOT_TOKEN`. +- Send a message to the bot from the operator account or group. +- Get the destination `TELEGRAM_CHAT_ID` from the Telegram API or your preferred bot admin tool. +- Keep `TELEGRAM_ALERTS_ENABLED=true` only when both values are configured. +- To disable alerts safely, set `TELEGRAM_ALERTS_ENABLED=false` and leave token/chat fields blank. +- Never commit `.env.production` or share bot tokens, chat IDs, databases, logs, or WhatsApp auth data. + +## 5. First startup on SSH-only access +- Keep OCI ingress for `80/tcp` and `443/tcp` closed for this first run. +- Do not start Caddy yet. Start only the app with a temporary localhost-only host port: + +```bash +cat > docker-compose.local-first.yml <<'YAML' +services: + app: + ports: + - "127.0.0.1:3910:3910" +YAML + +docker compose -f docker-compose.yml -f docker-compose.local-first.yml up -d --build app +docker compose ps +docker compose logs -f app +``` + +From your workstation, create an SSH tunnel and change the default password before exposing the stack: + +```bash +ssh -L 3910:127.0.0.1:3910 ubuntu@YOUR_VM_PUBLIC_IP +``` + +- Open `http://127.0.0.1:3910` through the tunnel. +- Log in with `admin` / `admin`. +- Open **Configuracoes > Alterar minha senha** and set a strong unique password with at least 8 characters. +- Log out and log back in with the new password. + +## 6. Expose HTTPS after password change +- Only after the admin password is changed, update `Caddyfile` to the public domain and point public DNS at the VM. +- Remove the temporary local-only override and start the public stack: + +```bash +rm docker-compose.local-first.yml +docker compose up -d --build +``` + +- Only after the public stack is ready, open OCI ingress for `80/tcp` and `443/tcp` from the internet. + +## 7. Smoke test safely +- Keep campaigns paused during deployment validation. +- Do not start real campaigns during smoke tests. +- Keep Google Places and Telegram tokens blank or fake unless intentionally testing those external services. +- Do not connect a real WhatsApp account or send real WhatsApp messages during smoke tests. +- Confirm health without sending: + +```bash +curl -i http://127.0.0.1/api/health +docker compose restart app +docker compose ps +docker compose logs --tail=100 app +``` + +Expected: health is 200, the app container is healthy after restart, data remains in `/app/data`, and no outreach sends are created. + +## 8. Backup +```bash +docker compose exec app npm run backup:data +``` + +## 9. QR recovery +When Telegram says QR is required, open the panel and reconnect WhatsApp. diff --git a/docs/superpowers/plans/2026-08-03-docker-cloud-247.md b/docs/superpowers/plans/2026-08-03-docker-cloud-247.md new file mode 100644 index 0000000..1f87d93 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-docker-cloud-247.md @@ -0,0 +1,782 @@ +# Docker Cloud 24/7 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run Maquina Comercial Pessoal 24/7 on Oracle Cloud Always Free using Docker Compose, persistent data, HTTPS, backups, and Telegram alerts when WhatsApp or campaign safety requires operator action. + +**Architecture:** Add Docker-ready network binding and health endpoints to the app, package it in a Node 24 container, and orchestrate it with Caddy via Docker Compose. Persist all runtime state under `/app/data`, keep restart-safe campaign behavior, and add a focused Telegram alert module with deduplication. + +**Tech Stack:** Node.js 24 ESM, SQLite, Baileys, Docker, Docker Compose, Caddy, Telegram Bot API over HTTPS, Node test runner. + +## Global Constraints + +- Do not remove the current safety behavior that pauses campaigns after server restart. +- Do not send real WhatsApp messages during tests. +- Do not commit `.env.production`, Telegram tokens, database files, WhatsApp auth files, or backups. +- Local default host remains `127.0.0.1`; Docker sets `APP_HOST=0.0.0.0`. +- Persist SQLite, WhatsApp auth, exports, and backups under the mounted `data` directory. +- Telegram alerts must be deduplicated with a cooldown per alert key. +- Telegram token must never be printed to logs. +- Docker healthcheck must use an endpoint that does not require login and does not expose secrets. +- Caddy handles public HTTP/HTTPS; the app container must not publish its internal port directly to the public internet. +- Oracle Cloud target is Always Free Ubuntu VM with Docker Compose. + +--- + +## File Structure + +- Modify `src/env.mjs`: add `APP_HOST`, Docker-safe runtime path envs, and Telegram env helpers. +- Modify `scripts/servidor.mjs`: bind to `APP_HOST`, initialize alerting, and notify restart/WhatsApp conditions. +- Modify `src/server/index.mjs`: expose a no-auth health endpoint. +- Create `src/alerts/telegram.mjs`: Telegram client, alert dedupe, and safe notification helpers. +- Create `tests/alerts.test.mjs`: unit tests for Telegram alert behavior and dedupe. +- Modify existing server/env tests: cover `APP_HOST` and health route. +- Create `Dockerfile`: production image using Node 24. +- Create `.dockerignore`: exclude data, auth, backups, test artifacts, and secrets. +- Create `docker-compose.yml`: app + Caddy with persistent volumes and healthcheck. +- Create `Caddyfile.example`: reverse proxy template for a real domain. +- Create `.env.production.example`: production environment template without secrets. +- Create `scripts/backup-data.mjs`: zip SQLite + WhatsApp auth backups with retention. +- Create `docs/deployment/oracle-free-docker.md`: operator deployment runbook. + +--- + +### Task 1: Docker-Ready Environment and Health Endpoint + +**Files:** +- Modify: `src/env.mjs` +- Modify: `scripts/servidor.mjs` +- Modify: `src/server/index.mjs` +- Modify/Test: `tests/server.test.mjs` +- Modify/Test: `tests/personal-branding.test.mjs` + +**Interfaces:** +- Produces: `APP_HOST: string` exported from `src/env.mjs` +- Produces: unauthenticated `GET /api/health` +- Consumes: existing `APP_PORT`, `paths`, `loadDotEnv`, `createAppServer` + +- [ ] **Step 1: Write failing env test for host binding** + +Add a test that imports `src/env.mjs` with `process.env.APP_HOST='0.0.0.0'` and asserts the exported value is `0.0.0.0`. Clear the env afterward. + +```js +test('env exposes APP_HOST with local-safe fallback', async () => { + const previous = process.env.APP_HOST; + process.env.APP_HOST = '0.0.0.0'; + const env = await import(`../src/env.mjs?host-test=${Date.now()}`); + assert.equal(env.APP_HOST, '0.0.0.0'); + if (previous === undefined) delete process.env.APP_HOST; else process.env.APP_HOST = previous; +}); +``` + +- [ ] **Step 2: Write failing health route test** + +In `tests/server.test.mjs`, create an app server with fake dependencies and assert `GET /api/health` returns 200 without authentication. + +```js +test('health endpoint is public and does not expose secrets', async () => { + const { server, baseUrl, close } = await startTestServer(); + try { + const res = await fetch(`${baseUrl}/api/health`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.ok(body.app); + assert.ok(body.database); + assert.ok(body.whatsapp); + assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i); + } finally { + await close(server); + } +}); +``` + +- [ ] **Step 3: Run RED tests** + +Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs` + +Expected: FAIL because `APP_HOST` and `/api/health` do not exist yet. + +- [ ] **Step 4: Implement `APP_HOST`** + +In `src/env.mjs`, add: + +```js +export let APP_HOST = envString('APP_HOST', '127.0.0.1'); +``` + +Update `refreshEnvDerivedValues()`: + +```js +APP_HOST = envString('APP_HOST', '127.0.0.1'); +``` + +- [ ] **Step 5: Bind server to `APP_HOST`** + +In `scripts/servidor.mjs`, import `APP_HOST` and replace: + +```js +server.listen(port, '127.0.0.1', () => { +``` + +with: + +```js +server.listen(port, APP_HOST, () => { +``` + +Update logs so local and Docker hosts are explicit: + +```js +log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`); +if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.'); +else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.'); +``` + +- [ ] **Step 6: Implement public health endpoint** + +In `src/server/index.mjs`, add a route before auth-protected application routes: + +```js +router.add('GET', '/api/health', async (req, res, { json }) => { + json(200, { + ok: true, + app: 'maquina-comercial-pessoal', + database: 'open', + whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN' + }); +}); +``` + +If the router context does not expose `whatsapp` there, wire it from the existing `createAppServer({ db, jobs, whatsapp, log })` closure. + +- [ ] **Step 7: Run GREEN tests** + +Run: `node --test tests/server.test.mjs tests/personal-branding.test.mjs` + +Expected: PASS. + +- [ ] **Step 8: Run focused syntax check** + +Run: `node --check src/env.mjs` and `node --check scripts/servidor.mjs` + +Expected: PASS. + +--- + +### Task 2: Telegram Alert Module + +**Files:** +- Create: `src/alerts/telegram.mjs` +- Create/Test: `tests/alerts.test.mjs` +- Modify: `src/env.mjs` +- Modify: `.env.example` + +**Interfaces:** +- Produces: `createTelegramAlerter({ token, chatId, enabled, fetchImpl, now, cooldownMs, log })` +- Produces: `alerter.alert(key, text): Promise<{ sent: boolean, reason?: string }>` +- Consumes: Telegram Bot API `sendMessage` + +- [ ] **Step 1: Write failing tests for disabled, missing config, send success, and dedupe** + +Create `tests/alerts.test.mjs`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTelegramAlerter } from '../src/alerts/telegram.mjs'; + +test('telegram alert skips when disabled or missing config', async () => { + const calls = []; + const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) }); + assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' }); + assert.equal(calls.length, 0); +}); + +test('telegram alert sends message without leaking token to text', async () => { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + return { ok: true, json: async () => ({ ok: true }) }; + }; + const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl }); + assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true }); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /sendMessage$/); + const payload = JSON.parse(calls[0].options.body); + assert.equal(payload.chat_id, '456'); + assert.equal(payload.text, 'WhatsApp precisa de QR'); + assert.doesNotMatch(payload.text, /SECRET/); +}); + +test('telegram alert deduplicates per key within cooldown', async () => { + let current = 1000; + const calls = []; + const fetchImpl = async (...args) => { + calls.push(args); + return { ok: true, json: async () => ({ ok: true }) }; + }; + const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 }); + assert.equal((await alerter.alert('same', 'A')).sent, true); + assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' }); + current = 2501; + assert.equal((await alerter.alert('same', 'A later')).sent, true); + assert.equal(calls.length, 2); +}); +``` + +- [ ] **Step 2: Run RED test** + +Run: `node --test tests/alerts.test.mjs` + +Expected: FAIL because module does not exist. + +- [ ] **Step 3: Implement Telegram alerter** + +Create `src/alerts/telegram.mjs`: + +```js +export function createTelegramAlerter({ + token, + chatId, + enabled = false, + fetchImpl = fetch, + now = () => Date.now(), + cooldownMs = 15 * 60 * 1000, + log = () => {} +} = {}) { + const sentAt = new Map(); + const configured = Boolean(token && chatId); + + async function alert(key, text) { + if (!enabled) return { sent: false, reason: 'disabled' }; + if (!configured) return { sent: false, reason: 'not_configured' }; + const last = sentAt.get(key) || 0; + if (now() - last < cooldownMs) return { sent: false, reason: 'cooldown' }; + + const response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, text }) + }); + if (!response.ok) { + log(`telegram: falha ao enviar alerta (${response.status || 'sem status'})`); + return { sent: false, reason: 'request_failed' }; + } + sentAt.set(key, now()); + return { sent: true }; + } + + return { alert }; +} +``` + +- [ ] **Step 4: Add env values** + +In `src/env.mjs`, export: + +```js +export const telegram = { + enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true', + botToken: process.env.TELEGRAM_BOT_TOKEN || '', + chatId: process.env.TELEGRAM_CHAT_ID || '' +}; +``` + +After `loadDotEnv()` refresh, this object must reflect `.env`; if constants are initialized too early, use a function instead: + +```js +export function telegramConfig() { + return { + enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true', + botToken: process.env.TELEGRAM_BOT_TOKEN || '', + chatId: process.env.TELEGRAM_CHAT_ID || '' + }; +} +``` + +Prefer the function if tests expose stale values. + +- [ ] **Step 5: Document env placeholders** + +Append to `.env.example`: + +```dotenv +TELEGRAM_ALERTS_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +``` + +- [ ] **Step 6: Run GREEN tests** + +Run: `node --test tests/alerts.test.mjs` + +Expected: PASS. + +--- + +### Task 3: Wire Operational Alerts Into Server Lifecycle + +**Files:** +- Modify: `scripts/servidor.mjs` +- Modify/Test: `tests/server.test.mjs` or create `tests/server-alerts.test.mjs` + +**Interfaces:** +- Consumes: `createTelegramAlerter(...)` +- Consumes: `telegramConfig()` from `src/env.mjs` +- Produces: startup alerts for restart pause and WhatsApp reconnect failures + +- [ ] **Step 1: Write failing server alert tests with fake alerter** + +Create a small exported helper if needed: + +```js +export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) { + if (campaignsPausedByRestart) await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Revise antes de reiniciar.'); + if (whatsappStatus === 'QR_REQUIRED') await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.'); +} +``` + +Test: + +```js +test('startup alerts notify restart pause and QR requirement once', async () => { + const alerts = []; + const alerter = { alert: async (key, text) => alerts.push({ key, text }) }; + await notifyStartupState({ alerter, whatsappStatus: 'QR_REQUIRED', campaignsPausedByRestart: true }); + assert.deepEqual(alerts.map((a) => a.key), ['campaigns-paused-restart', 'whatsapp-qr']); +}); +``` + +- [ ] **Step 2: Run RED test** + +Run: `node --test tests/server-alerts.test.mjs` + +Expected: FAIL because helper/wiring does not exist. + +- [ ] **Step 3: Implement alert helper** + +Create `src/alerts/runtime-alerts.mjs` if keeping `scripts/servidor.mjs` slim: + +```js +export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart }) { + if (campaignsPausedByRestart) { + await alerter.alert('campaigns-paused-restart', 'Campanhas pausadas apos restart. Acesse o painel, revise a fila e reinicie manualmente.'); + } + if (whatsappStatus === 'QR_REQUIRED') { + await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel da Maquina Comercial Pessoal.'); + } +} + +export async function notifyWhatsAppTransition({ alerter, previousState, nextState }) { + if (previousState === nextState) return; + if (previousState === 'READY' && nextState !== 'READY') { + await alerter.alert('whatsapp-left-ready', `WhatsApp saiu de READY e agora esta ${nextState}.`); + } + if (nextState === 'QR_REQUIRED') { + await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.'); + } +} +``` + +- [ ] **Step 4: Wire startup alert in `scripts/servidor.mjs`** + +After `pauseCampaignsAfterRestart(db)`, determine whether any campaign has `pause_reason='server_restart'`: + +```js +const campaignsPausedByRestart = Boolean(db.prepare( + "SELECT 1 FROM outreach_campaigns WHERE status = 'paused' AND pause_reason = 'server_restart' LIMIT 1" +).get()); +``` + +Create alerter: + +```js +const tg = telegramConfig(); +const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log }); +``` + +After WhatsApp manager exists, call: + +```js +await notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart }); +``` + +- [ ] **Step 5: Alert on initial reconnect failure** + +Change the existing reconnect catch: + +```js +whatsapp.connect().catch(async (error) => { + log(`whatsapp: reconexao inicial falhou: ${error.message}`); + await alerter.alert('whatsapp-initial-reconnect-failed', 'WhatsApp nao reconectou no startup. Acesse o painel e verifique o QR.'); +}); +``` + +- [ ] **Step 6: Run focused tests** + +Run: `node --test tests/alerts.test.mjs tests/server-alerts.test.mjs` + +Expected: PASS. + +--- + +### Task 4: Docker and Compose Packaging + +**Files:** +- Create: `Dockerfile` +- Create: `.dockerignore` +- Create: `docker-compose.yml` +- Create: `Caddyfile.example` +- Create: `.env.production.example` +- Modify/Test: `tests/personal-branding.test.mjs` + +**Interfaces:** +- Consumes: `APP_HOST`, `APP_PORT`, `APP_DB_PATH`, `APP_WHATSAPP_AUTH_DIR` +- Produces: Docker image runnable by `docker compose up -d --build` + +- [ ] **Step 1: Write failing file existence/config test** + +Add to `tests/personal-branding.test.mjs`: + +```js +test('docker deployment files preserve persistent data and do not include secrets', async () => { + const dockerfile = await read('Dockerfile'); + const compose = await read('docker-compose.yml'); + const envExample = await read('.env.production.example'); + const caddy = await read('Caddyfile.example'); + + assert.match(dockerfile, /FROM node:24/); + assert.match(compose, /restart: unless-stopped/); + assert.match(compose, /APP_HOST=0\.0\.0\.0/); + assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/); + assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/); + assert.match(caddy, /reverse_proxy app:3910/); + assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/); +}); +``` + +- [ ] **Step 2: Run RED test** + +Run: `node --test tests/personal-branding.test.mjs` + +Expected: FAIL because files do not exist. + +- [ ] **Step 3: Create Dockerfile** + +```dockerfile +FROM node:24-bookworm-slim + +WORKDIR /app + +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY src ./src +COPY scripts ./scripts +COPY public ./public +COPY prospeccao/config.json ./prospeccao/config.json + +RUN mkdir -p /app/data/runtime /app/data/exports /app/data/backups + +EXPOSE 3910 + +CMD ["npm", "start"] +``` + +- [ ] **Step 4: Create `.dockerignore`** + +```gitignore +data +node_modules +test-results +playwright-report +.env +.env.production +*.log +*.sqlite +*.db +.git +``` + +- [ ] **Step 5: Create `docker-compose.yml`** + +```yaml +services: + app: + build: . + restart: unless-stopped + env_file: + - .env.production + environment: + APP_HOST: 0.0.0.0 + APP_PORT: 3910 + APP_DB_PATH: /app/data/maquina-pessoal.db + APP_WHATSAPP_AUTH_DIR: /app/data/runtime/whatsapp-auth + CNPJ_BASE_DIR: /app/prospeccao/cnpj + volumes: + - ./data:/app/data + expose: + - "3910" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3910/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + app: + condition: service_healthy + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: +``` + +- [ ] **Step 6: Create `Caddyfile.example`** + +```caddyfile +campanhas.seudominio.com { + encode gzip + reverse_proxy app:3910 +} +``` + +- [ ] **Step 7: Create `.env.production.example`** + +```dotenv +APP_RESET_PASSWORD=troque-esta-senha +GOOGLE_PLACES_API_KEY= +PERSONAL_COMMERCIAL_WHATSAPP= +TELEGRAM_ALERTS_ENABLED=true +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +``` + +- [ ] **Step 8: Run GREEN tests** + +Run: `node --test tests/personal-branding.test.mjs` + +Expected: PASS. + +- [ ] **Step 9: Build Docker image** + +Run: `docker compose build app` + +Expected: image builds successfully. + +--- + +### Task 5: Backup Script With Retention + +**Files:** +- Create: `scripts/backup-data.mjs` +- Create/Test: `tests/backup-data.test.mjs` +- Modify: `package.json` + +**Interfaces:** +- Produces: CLI `node scripts/backup-data.mjs` +- Produces: npm script `backup:data` +- Consumes: `paths.data`, `paths.db`, `paths.whatsappAuth` + +- [ ] **Step 1: Write failing backup tests** + +Create test that uses a temp directory, sample DB file, sample auth file, and asserts a backup file is created and old backups are removed. + +```js +test('backup creates archive marker and keeps configured retention', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-')); + const dataDir = path.join(tmp, 'data'); + await fs.mkdir(path.join(dataDir, 'runtime', 'whatsapp-auth'), { recursive: true }); + await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db'); + await fs.writeFile(path.join(dataDir, 'runtime', 'whatsapp-auth', 'session.json'), 'auth'); + const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 }); + assert.match(result.file, /maquina-pessoal-2026-08-03/); + await fs.access(result.file); +}); +``` + +- [ ] **Step 2: Run RED test** + +Run: `node --test tests/backup-data.test.mjs` + +Expected: FAIL because backup module does not exist. + +- [ ] **Step 3: Implement backup helper** + +Because the project has no zip dependency, implement a safe tar/gzip through Node `child_process` only on Linux containers, or write a plain `.backup` directory copy. Prefer no new production dependency. + +Export: + +```js +export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) { + const stamp = now().toISOString().slice(0, 10); + const backupsDir = path.join(dataDir, 'backups'); + const target = path.join(backupsDir, `maquina-pessoal-${stamp}`); + await fs.mkdir(target, { recursive: true }); + await fs.copyFile(path.join(dataDir, 'maquina-pessoal.db'), path.join(target, 'maquina-pessoal.db')); + await cp(path.join(dataDir, 'runtime', 'whatsapp-auth'), path.join(target, 'whatsapp-auth'), { recursive: true }); + await pruneBackups(backupsDir, retention); + return { file: target }; +} +``` + +Use directory backups rather than zip if avoiding dependencies. Document this in the runbook. + +- [ ] **Step 4: Add CLI guard** + +At bottom of `scripts/backup-data.mjs`: + +```js +if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`) { + createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => { + console.error(`backup falhou: ${error.message}`); + process.exit(1); + }); +} +``` + +- [ ] **Step 5: Add npm script** + +In `package.json` scripts: + +```json +"backup:data": "node scripts/backup-data.mjs" +``` + +- [ ] **Step 6: Run GREEN tests** + +Run: `node --test tests/backup-data.test.mjs` + +Expected: PASS. + +--- + +### Task 6: Deployment Runbook and Final Verification + +**Files:** +- Create: `docs/deployment/oracle-free-docker.md` +- Modify: `README.md` + +**Interfaces:** +- Consumes: Docker/Compose files and Telegram envs from earlier tasks +- Produces: operator runbook for Oracle Always Free deployment + +- [ ] **Step 1: Write runbook** + +Create `docs/deployment/oracle-free-docker.md` with exact steps: + +```markdown +# Oracle Always Free Docker Deployment + +## 1. Create VM +- Use Ubuntu Always Free. +- Prefer Ampere A1 if available. +- Keep boot/block volume within Always Free limits. + +## 2. Open ports +- OCI ingress: 80/tcp and 443/tcp from internet. +- Restrict 22/tcp to your IP. + +## 3. Install Docker +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +``` + +## 4. Configure app +```bash +cp .env.production.example .env.production +nano .env.production +cp Caddyfile.example Caddyfile +nano Caddyfile +``` + +## 5. Start +```bash +docker compose up -d --build +docker compose ps +docker compose logs -f app +``` + +## 6. Backup +```bash +docker compose exec app npm run backup:data +``` + +## 7. QR recovery +When Telegram says QR is required, open the panel and reconnect WhatsApp. +``` +``` + +- [ ] **Step 2: Link runbook from README** + +Add a short section to `README.md`: + +```markdown +## Deploy 24/7 sem custo + +Veja `docs/deployment/oracle-free-docker.md` para rodar no Oracle Cloud Always Free com Docker, Caddy e alertas Telegram. +``` + +- [ ] **Step 3: Run final automated checks** + +Run: + +```powershell +npm.cmd test +npm.cmd run test:browser +npm.cmd audit --omit=dev +docker compose config +docker compose build app +``` + +Expected: + +- Node tests pass. +- Browser tests pass or any existing unrelated flake is documented. +- Audit reports 0 production vulnerabilities. +- Compose config validates. +- Docker image builds. + +- [ ] **Step 4: Manual non-sending smoke test** + +Run: + +```powershell +docker compose up -d --build +Invoke-WebRequest http://127.0.0.1/api/health -UseBasicParsing +docker compose restart app +docker compose logs --tail=100 app +``` + +Expected: + +- Health returns 200 through Caddy if locally mapped. +- Data directory remains present after restart. +- Campaigns pause after restart. +- Telegram alert is sent for restart pause if enabled. +- No real WhatsApp message is sent. + +--- + +## Self-Review + +- Spec coverage: Oracle Free, Docker Compose, Caddy HTTPS, persistent volumes, Telegram alerts, restart safety, backups, and verification are each assigned to tasks. +- Placeholder scan: no TBD/TODO placeholders remain; every task has concrete files, commands, and expected outcomes. +- Interface consistency: `APP_HOST`, `/api/health`, `createTelegramAlerter`, `notifyStartupState`, and `backup:data` are defined before later tasks consume them. diff --git a/docs/superpowers/specs/2026-08-03-docker-cloud-247-design.md b/docs/superpowers/specs/2026-08-03-docker-cloud-247-design.md new file mode 100644 index 0000000..dc2e979 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-docker-cloud-247-design.md @@ -0,0 +1,169 @@ +# Docker Cloud 24/7 Design + +## Goal + +Run Maquina Comercial Pessoal continuously in a free cloud environment with Docker, persistent data, safe WhatsApp recovery, and Telegram alerts when manual action is required. + +## Chosen Approach + +Use Oracle Cloud Always Free with an Ubuntu VM, Docker Compose, persistent volume storage, Caddy for HTTPS, and Telegram Bot API alerts. + +This is the best free option because Oracle Always Free can provide an always-on VM and persistent block volume. Other free platforms may sleep or restart unpredictably, which is risky for a WhatsApp Web session and scheduled campaign windows. + +## Runtime Architecture + +- One Ubuntu VM on Oracle Cloud Always Free. +- Docker Engine and Docker Compose installed on the VM. +- One application container running `npm start`. +- One Caddy container acting as reverse proxy and HTTPS terminator. +- Persistent host directory or block volume mounted into the app container. +- Telegram alerts sent directly from the app using Bot API HTTPS requests. + +## Required Application Changes + +### Network Binding + +The server currently listens on `127.0.0.1`. In Docker it must support binding to `0.0.0.0`. + +Add `APP_HOST`, defaulting to `127.0.0.1` for local safety. In Docker set `APP_HOST=0.0.0.0`. + +### Persistent Paths + +The following paths must survive container restarts and image updates: + +- SQLite database: `data/maquina-pessoal.db` +- WhatsApp auth/session: `data/runtime/whatsapp-auth` +- Exports: `data/exports` +- Any runtime logs or backups + +Use a single mounted volume at `/app/data` and configure: + +- `APP_DB_PATH=/app/data/maquina-pessoal.db` +- `APP_WHATSAPP_AUTH_DIR=/app/data/runtime/whatsapp-auth` + +### Health Endpoint + +Add or reuse a lightweight HTTP health endpoint suitable for Docker healthcheck and uptime checks. + +The endpoint must not leak secrets. It can expose: + +- app status +- database open/migrated +- WhatsApp state summary +- campaign scheduler state summary + +### Telegram Alerts + +Add Telegram configuration: + +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_CHAT_ID` +- `TELEGRAM_ALERTS_ENABLED=true|false` + +Use Telegram Bot API `sendMessage` over HTTPS. + +Send alerts for: + +- WhatsApp leaves `READY` after previously being ready. +- WhatsApp enters `QR_REQUIRED`. +- Initial reconnect fails on startup. +- Campaigns are paused by `server_restart`. +- Worker sees repeated send errors above a safe threshold. + +Alerts must be deduplicated to avoid spam. Use a cooldown window per alert key, for example 15 minutes. + +### Restart Semantics + +Keep the current safe behavior: campaigns pause after server restart. This prevents blind sending after container or VM restarts. + +When this happens, Telegram should notify the operator to review and resume manually. + +## Docker Files + +### Dockerfile + +Use Node 24, install production dependencies, copy app files, expose the app port, and run `npm start`. + +Do not bake secrets into the image. + +### docker-compose.yml + +Services: + +- `app` + - build from local Dockerfile + - `restart: unless-stopped` + - `env_file: .env.production` + - mount persistent `./data:/app/data` + - expose app port only to the internal Docker network + - healthcheck hitting the app health endpoint + +- `caddy` + - official Caddy image + - `restart: unless-stopped` + - ports `80:80` and `443:443` + - Caddyfile mounted read-only + - Caddy data/config volumes persisted + +## HTTPS and Access + +Preferred: use a domain pointed to the VM public IP and let Caddy issue Let's Encrypt certificates. + +If no domain is available, HTTP by IP can work temporarily but is not recommended for a login panel. + +The app already has authentication. The production admin password must be changed from the default before exposing the panel. + +## Oracle Cloud Setup + +- Create Always Free Ubuntu VM. +- Attach or use persistent boot/block volume within Always Free limits. +- Install Docker and Docker Compose plugin. +- Open OCI security list/NSG for ports `80` and `443` only. +- Restrict SSH to the operator IP when possible. +- Clone/copy the project to the VM. +- Create `.env.production` with production values. +- Run `docker compose up -d --build`. + +## Backup Plan + +Create a daily SQLite backup job on the host or in a sidecar/script. + +Backup target: + +- `/app/data/backups/maquina-pessoal-YYYY-MM-DD.zip` + +Retain the last 7 to 14 backups to control storage usage. + +The backup must include at least: + +- SQLite database +- WhatsApp auth directory + +## Safety Rules + +- Do not send messages automatically after an app/container restart. +- Do not expose Telegram token in logs. +- Do not commit `.env.production`. +- Do not use the real WhatsApp provider in tests. +- Do not remove existing manual approval gates. +- Keep campaign resume a deliberate operator action after Telegram alert. + +## Verification + +Before production deployment: + +- `npm test` +- `npm run test:browser` if browsers are available +- `npm audit --omit=dev` +- Docker build succeeds +- Container healthcheck becomes healthy +- Telegram test alert is received +- App can show QR and reconnect WhatsApp +- Data persists after `docker compose restart` +- Campaign pause after restart triggers Telegram alert + +## Open Operational Requirements + +- Operator must create a Telegram bot with BotFather. +- Operator must provide `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` locally on the VM. +- Operator should decide whether to use a domain for HTTPS. HTTPS with a domain is recommended before exposing the panel. diff --git a/package.json b/package.json index 458bd5e..5a0ba65 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "capture": "node scripts/capturar-leads.mjs", "extract": "node scripts/extrair-base-cnpj.mjs", "download-rfb": "node scripts/baixar-base-rfb.mjs", + "backup:data": "node scripts/backup-data.mjs", "recalc-scores": "node scripts/recalcular-scores.mjs", "outreach": "node scripts/enviar-outreach.mjs", "build:launcher": "powershell -ExecutionPolicy Bypass -File scripts/build-launcher.ps1" diff --git a/scripts/backup-data.mjs b/scripts/backup-data.mjs new file mode 100644 index 0000000..70df23b --- /dev/null +++ b/scripts/backup-data.mjs @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { paths } from '../src/env.mjs'; + +export async function createBackup({ dataDir = paths.data, now = () => new Date(), retention = 14 } = {}) { + const stamp = now().toISOString().slice(0, 10); + const backupsDir = path.join(dataDir, 'backups'); + const target = path.join(backupsDir, `maquina-pessoal-${stamp}`); + const dbPath = dataDir === paths.data ? paths.db : path.join(dataDir, 'maquina-pessoal.db'); + const authPath = dataDir === paths.data ? paths.whatsappAuth : path.join(dataDir, 'runtime', 'whatsapp-auth'); + + await fs.mkdir(target, { recursive: true }); + await copyDatabaseBackup(dbPath, path.join(target, 'maquina-pessoal.db')); + await fs.cp(authPath, path.join(target, 'whatsapp-auth'), { recursive: true }); + await pruneBackups(backupsDir, retention); + + return { file: target }; +} + +async function copyDatabaseBackup(source, destination) { + try { + const db = new DatabaseSync(source, { readOnly: true }); + try { + db.exec(`VACUUM INTO ${sqlString(destination)};`); + return; + } finally { + db.close(); + } + } catch { + await copyDatabaseFiles(source, destination); + } +} + +async function copyDatabaseFiles(source, destination) { + await fs.copyFile(source, destination); + + for (const suffix of ['-wal', '-shm']) { + try { + await fs.copyFile(`${source}${suffix}`, `${destination}${suffix}`); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + } + } +} + +function sqlString(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +async function pruneBackups(backupsDir, retention) { + const keep = Math.max(0, retention); + const entries = await fs.readdir(backupsDir, { withFileTypes: true }); + const backupDirs = entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith('maquina-pessoal-')) + .map((entry) => entry.name) + .sort(); + + for (const name of backupDirs.slice(0, Math.max(0, backupDirs.length - keep))) { + await fs.rm(path.join(backupsDir, name), { recursive: true, force: true }); + } +} + +export function isCliEntryPoint(moduleUrl = import.meta.url, argvPath = process.argv[1]) { + return Boolean(argvPath) && moduleUrl === pathToFileURL(argvPath).href; +} + +if (isCliEntryPoint()) { + createBackup().then((result) => console.log(`backup criado: ${result.file}`)).catch((error) => { + console.error(`backup falhou: ${error.message}`); + process.exit(1); + }); +} diff --git a/scripts/servidor.mjs b/scripts/servidor.mjs index 261af9c..da302c9 100644 --- a/scripts/servidor.mjs +++ b/scripts/servidor.mjs @@ -1,4 +1,10 @@ -import { loadDotEnv, APP_PORT, paths } from '../src/env.mjs'; +import { loadDotEnv, APP_HOST, APP_PORT, paths, telegramConfig } from '../src/env.mjs'; +import { createTelegramAlerter } from '../src/alerts/telegram.mjs'; +import { + hasCampaignsPausedByRestart, + notifyStartupState, + startInitialWhatsAppReconnect +} from '../src/alerts/runtime-alerts.mjs'; import { openDb, ensureSeeds, recoverPendingOutreach, updateOutreachMessageStatus } from '../src/db.mjs'; import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs'; import { createAppServer } from '../src/server/index.mjs'; @@ -18,8 +24,12 @@ await ensureSeeds(db, { log }); recoverOrphans(db, log); recoverPendingOutreach(db); pauseCampaignsAfterRestart(db); +const campaignsPausedByRestart = hasCampaignsPausedByRestart(db); recoverReservedCampaignQueue(db); +const tg = telegramConfig(); +const alerter = createTelegramAlerter({ enabled: tg.enabled, token: tg.botToken, chatId: tg.chatId, log }); + const jobs = createJobManager({ db }); const whatsapp = createWhatsAppManager({ authDir: paths.whatsappAuth, @@ -27,16 +37,21 @@ const whatsapp = createWhatsAppManager({ onMessageStatus: (event) => updateOutreachMessageStatus(db, event), onInboundMessage: (event) => recordInboundReply(db, event) }); -if (await hasWhatsAppSession(paths.whatsappAuth)) { - whatsapp.connect().catch((error) => log(`whatsapp: reconexao inicial falhou: ${error.message}`)); -} +void notifyStartupState({ alerter, whatsappStatus: whatsapp.getStatus().state, campaignsPausedByRestart, log }); +await startInitialWhatsAppReconnect({ + hasSession: () => hasWhatsAppSession(paths.whatsappAuth), + whatsapp, + alerter, + log +}); const server = createAppServer({ db, jobs, whatsapp, log }); const stopScheduler = startOutreachScheduler({ db, whatsapp, log }); const port = APP_PORT; -server.listen(port, '127.0.0.1', () => { - log(`Maquina Comercial Pessoal rodando em http://127.0.0.1:${port}`); - log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.'); +server.listen(port, APP_HOST, () => { + log(`Maquina Comercial Pessoal rodando em http://${APP_HOST}:${port}`); + if (APP_HOST === '127.0.0.1') log('somente este computador tem acesso (127.0.0.1). Ctrl+C para encerrar.'); + else log('modo container/rede: proteja o acesso com proxy HTTPS e senha forte.'); }); let shuttingDown = false; diff --git a/src/alerts/runtime-alerts.mjs b/src/alerts/runtime-alerts.mjs new file mode 100644 index 0000000..4a09eb3 --- /dev/null +++ b/src/alerts/runtime-alerts.mjs @@ -0,0 +1,59 @@ +export function hasCampaignsPausedByRestart(db) { + return Boolean(db.prepare( + "SELECT 1 FROM outreach_campaigns WHERE status = 'paused' AND pause_reason = 'server_restart' LIMIT 1" + ).get()); +} + +async function safeAlert({ alerter, key, text, log = () => {} }) { + try { + await alerter.alert(key, text); + } catch { + log(`telegram: alerta ${key} falhou`); + } +} + +export async function notifyStartupState({ alerter, whatsappStatus, campaignsPausedByRestart, log }) { + if (campaignsPausedByRestart) { + await safeAlert({ + alerter, + key: 'campaigns-paused-restart', + text: 'Campanhas pausadas apos restart. Acesse o painel, revise a fila e reinicie manualmente.', + log + }); + } + if (whatsappStatus === 'QR_REQUIRED') { + await safeAlert({ + alerter, + key: 'whatsapp-qr', + text: 'WhatsApp requer leitura de QR. Acesse o painel da Maquina Comercial Pessoal.', + log + }); + } +} + +export async function notifyWhatsAppTransition({ alerter, previousState, nextState }) { + if (previousState === nextState) return; + if (previousState === 'READY' && nextState !== 'READY') { + await alerter.alert('whatsapp-left-ready', `WhatsApp saiu de READY e agora esta ${nextState}.`); + } + if (nextState === 'QR_REQUIRED') { + await alerter.alert('whatsapp-qr', 'WhatsApp requer leitura de QR. Acesse o painel.'); + } +} + +export async function notifyInitialReconnectFailure({ alerter, error, log }) { + log(`whatsapp: reconexao inicial falhou: ${error.message}`); + await safeAlert({ + alerter, + key: 'whatsapp-initial-reconnect-failed', + text: 'WhatsApp nao reconectou no startup. Acesse o painel e verifique o QR.', + log + }); +} + +export async function startInitialWhatsAppReconnect({ hasSession, whatsapp, alerter, log }) { + if (!(await hasSession())) return false; + + whatsapp.connect().catch((error) => notifyInitialReconnectFailure({ alerter, error, log })); + return true; +} diff --git a/src/alerts/telegram.mjs b/src/alerts/telegram.mjs new file mode 100644 index 0000000..36c79d7 --- /dev/null +++ b/src/alerts/telegram.mjs @@ -0,0 +1,35 @@ +export function createTelegramAlerter({ + token, + chatId, + enabled = false, + fetchImpl = fetch, + now = () => Date.now(), + cooldownMs = 15 * 60 * 1000, + log = () => {} +} = {}) { + const sentAt = new Map(); + const configured = Boolean(token && chatId); + + async function alert(key, text) { + if (!enabled) return { sent: false, reason: 'disabled' }; + if (!configured) return { sent: false, reason: 'not_configured' }; + + if (sentAt.has(key) && now() - sentAt.get(key) < cooldownMs) return { sent: false, reason: 'cooldown' }; + + const response = await fetchImpl(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, text }) + }); + + if (!response.ok) { + log(`telegram: falha ao enviar alerta (${response.status || 'sem status'})`); + return { sent: false, reason: 'request_failed' }; + } + + sentAt.set(key, now()); + return { sent: true }; + } + + return { alert }; +} diff --git a/src/env.mjs b/src/env.mjs index 639f9cf..af895ac 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -20,6 +20,7 @@ export const paths = { }; export let APP_PORT = envNumber('APP_PORT', 3910); +export let APP_HOST = envString('APP_HOST', '127.0.0.1'); function refreshEnvDerivedValues() { paths.db = process.env.APP_DB_PATH || path.join(projectRoot, 'data', 'maquina-pessoal.db'); @@ -27,6 +28,7 @@ function refreshEnvDerivedValues() { paths.cnpjDir = process.env.CNPJ_BASE_DIR || path.join(projectRoot, 'prospeccao', 'cnpj'); paths.rfbRaw = path.join(paths.cnpjDir, 'rfb-raw'); APP_PORT = envNumber('APP_PORT', 3910); + APP_HOST = envString('APP_HOST', '127.0.0.1'); } export async function loadDotEnv(rootDir = projectRoot) { @@ -59,3 +61,11 @@ export function envNumber(name, fallback) { export function envString(name, fallback) { return process.env[name] || fallback; } + +export function telegramConfig() { + return { + enabled: process.env.TELEGRAM_ALERTS_ENABLED === 'true', + botToken: process.env.TELEGRAM_BOT_TOKEN || '', + chatId: process.env.TELEGRAM_CHAT_ID || '' + }; +} diff --git a/src/server/routes/meta.mjs b/src/server/routes/meta.mjs index 454286c..022a530 100644 --- a/src/server/routes/meta.mjs +++ b/src/server/routes/meta.mjs @@ -51,7 +51,12 @@ function extractedCsvForUf(uf) { export function registerMetaRoutes(router, { db, jobs, whatsapp = { getStatus: () => ({ pendingSends: 0 }) } }) { router.add('GET', '/api/health', async (req, res, { json }) => { - json(200, { ok: true, app: 'maquina-comercial-pessoal', pid: process.pid }); + json(200, { + ok: true, + app: 'maquina-comercial-pessoal', + database: 'open', + whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN' + }); }); router.add('GET', '/api/meta/ufs', async (req, res, { json }) => { diff --git a/tests/alerts.test.mjs b/tests/alerts.test.mjs new file mode 100644 index 0000000..d8d0a8f --- /dev/null +++ b/tests/alerts.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createTelegramAlerter } from '../src/alerts/telegram.mjs'; +import { loadDotEnv, telegramConfig } from '../src/env.mjs'; + +test('telegram alert skips when disabled or missing config', async () => { + const calls = []; + const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) }); + assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' }); + assert.equal(calls.length, 0); + + const missingConfigAlerter = createTelegramAlerter({ enabled: true, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) }); + assert.deepEqual(await missingConfigAlerter.alert('qr', 'QR necessario'), { sent: false, reason: 'not_configured' }); + assert.equal(calls.length, 0); +}); + +test('telegram alert sends message without leaking token to text', async () => { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + return { ok: true, json: async () => ({ ok: true }) }; + }; + const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl }); + assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true }); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /sendMessage$/); + const payload = JSON.parse(calls[0].options.body); + assert.equal(payload.chat_id, '456'); + assert.equal(payload.text, 'WhatsApp precisa de QR'); + assert.doesNotMatch(payload.text, /SECRET/); +}); + +test('telegram alert deduplicates per key within cooldown', async () => { + let current = 1000; + const calls = []; + const fetchImpl = async (...args) => { + calls.push(args); + return { ok: true, json: async () => ({ ok: true }) }; + }; + const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 }); + assert.equal((await alerter.alert('same', 'A')).sent, true); + assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' }); + current = 2501; + assert.equal((await alerter.alert('same', 'A later')).sent, true); + assert.equal(calls.length, 2); +}); + +test('telegram alert sends first message when clock starts at zero', async () => { + const calls = []; + const fetchImpl = async (...args) => { + calls.push(args); + return { ok: true, json: async () => ({ ok: true }) }; + }; + const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => 0, cooldownMs: 1000 }); + + assert.equal((await alerter.alert('zero', 'First')).sent, true); + assert.deepEqual(await alerter.alert('zero', 'Second'), { sent: false, reason: 'cooldown' }); + assert.equal(calls.length, 1); +}); + +test('telegram config reflects values loaded from dotenv', async () => { + const keys = ['TELEGRAM_ALERTS_ENABLED', 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID']; + const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'telegram-env-')); + + try { + for (const key of keys) delete process.env[key]; + await fs.writeFile( + path.join(rootDir, '.env'), + 'TELEGRAM_ALERTS_ENABLED=true\nTELEGRAM_BOT_TOKEN=123:SECRET\nTELEGRAM_CHAT_ID=456\n' + ); + + await loadDotEnv(rootDir); + + assert.deepEqual(telegramConfig(), { + enabled: true, + botToken: '123:SECRET', + chatId: '456' + }); + } finally { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } +}); diff --git a/tests/backup-data.test.mjs b/tests/backup-data.test.mjs new file mode 100644 index 0000000..5482d99 --- /dev/null +++ b/tests/backup-data.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { createBackup, isCliEntryPoint } from '../scripts/backup-data.mjs'; + +test('backup creates data copy and keeps configured retention', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-')); + const dataDir = path.join(tmp, 'data'); + const backupsDir = path.join(dataDir, 'backups'); + const authDir = path.join(dataDir, 'runtime', 'whatsapp-auth'); + + await fs.mkdir(authDir, { recursive: true }); + await fs.mkdir(path.join(backupsDir, 'maquina-pessoal-2026-08-01'), { recursive: true }); + await fs.mkdir(path.join(backupsDir, 'maquina-pessoal-2026-08-02'), { recursive: true }); + await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db'); + await fs.writeFile(path.join(authDir, 'session.json'), 'auth'); + + try { + const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 }); + + assert.match(result.file, /maquina-pessoal-2026-08-03$/); + assert.equal(await fs.readFile(path.join(result.file, 'maquina-pessoal.db'), 'utf8'), 'db'); + assert.equal(await fs.readFile(path.join(result.file, 'whatsapp-auth', 'session.json'), 'utf8'), 'auth'); + assert.deepEqual(await fs.readdir(backupsDir), ['maquina-pessoal-2026-08-03']); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test('CLI guard matches Windows file URLs from argv paths', () => { + assert.equal( + isCliEntryPoint('file:///D:/PROJETOS/COMERCIAL%20GTI/MAQUINA%20COMERCIAL%20PESSOAL/scripts/backup-data.mjs', 'D:\\PROJETOS\\COMERCIAL GTI\\MAQUINA COMERCIAL PESSOAL\\scripts\\backup-data.mjs'), + true + ); +}); + +test('backup captures committed WAL data while source database remains open', async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-wal-')); + const dataDir = path.join(tmp, 'data'); + const authDir = path.join(dataDir, 'runtime', 'whatsapp-auth'); + const dbPath = path.join(dataDir, 'maquina-pessoal.db'); + let db; + + await fs.mkdir(authDir, { recursive: true }); + await fs.writeFile(path.join(authDir, 'session.json'), 'auth'); + + try { + db = new DatabaseSync(dbPath); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec('CREATE TABLE records (id INTEGER PRIMARY KEY, name TEXT NOT NULL);'); + db.exec("INSERT INTO records (name) VALUES ('committed in wal');"); + + await fs.stat(`${dbPath}-wal`); + + const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 }); + const backupDb = new DatabaseSync(path.join(result.file, 'maquina-pessoal.db'), { readOnly: true }); + + try { + assert.deepEqual(backupDb.prepare('SELECT name FROM records').all().map((row) => row.name), ['committed in wal']); + } finally { + backupDb.close(); + } + } finally { + db?.close(); + await fs.rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/personal-branding.test.mjs b/tests/personal-branding.test.mjs index de722ee..cf1000d 100644 --- a/tests/personal-branding.test.mjs +++ b/tests/personal-branding.test.mjs @@ -177,6 +177,33 @@ async function renderTreatment(app, rows) { return app.element('tabela-leads-tbody'); } +test('docker deployment files preserve persistent data and do not include secrets', async () => { + const dockerfile = await read('Dockerfile'); + const compose = await read('docker-compose.yml'); + const envExample = await read('.env.production.example'); + const caddy = await read('Caddyfile.example'); + + assert.match(dockerfile, /FROM node:24/); + assert.match(compose, /restart: unless-stopped/); + assert.match(compose, /APP_HOST=0\.0\.0\.0/); + assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/); + assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/); + assert.match(caddy, /reverse_proxy app:3910/); + assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/); +}); + +test('oracle deployment runbook changes default admin password before public ingress', async () => { + const runbook = await read('docs/deployment/oracle-free-docker.md'); + const firstPrivateStep = runbook.indexOf('Do not open `80/tcp`, `443/tcp`, or public DNS yet.'); + const passwordChangeStep = runbook.indexOf('Open **Configuracoes > Alterar minha senha**'); + const publicIngressStep = runbook.indexOf('open OCI ingress for `80/tcp` and `443/tcp` from the internet'); + + assert.ok(firstPrivateStep > -1); + assert.ok(passwordChangeStep > firstPrivateStep); + assert.ok(publicIngressStep > passwordChangeStep); + assert.match(runbook, /127\.0\.0\.1:3910:3910/); +}); + test('interface e arquivos operacionais usam somente a identidade pessoal', async () => { const files = await Promise.all([ 'public/index.html', @@ -231,6 +258,18 @@ test('fontes usam os nomes de ambiente documentados', async () => { assert.doesNotMatch(placesText, /GOOGLE_MAPS_API_KEY/); }); +test('env exposes APP_HOST with local-safe fallback', async () => { + const previous = process.env.APP_HOST; + process.env.APP_HOST = '0.0.0.0'; + try { + const env = await import(`../src/env.mjs?host-test=${Date.now()}`); + assert.equal(env.APP_HOST, '0.0.0.0'); + } finally { + if (previous === undefined) delete process.env.APP_HOST; + else process.env.APP_HOST = previous; + } +}); + test('catalogo preserva segmentos com um servico neutro por segmento', async () => { const config = JSON.parse(await read('prospeccao/config.json')); assert.ok(config.segments.length > 0); diff --git a/tests/server-alerts.test.mjs b/tests/server-alerts.test.mjs new file mode 100644 index 0000000..ec6ecd1 --- /dev/null +++ b/tests/server-alerts.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + hasCampaignsPausedByRestart, + notifyInitialReconnectFailure, + notifyStartupState, + notifyWhatsAppTransition, + startInitialWhatsAppReconnect +} from '../src/alerts/runtime-alerts.mjs'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +test('startup alerts notify restart pause and QR requirement once', async () => { + const alerts = []; + const alerter = { alert: async (key, text) => alerts.push({ key, text }) }; + + await notifyStartupState({ + alerter, + whatsappStatus: 'QR_REQUIRED', + campaignsPausedByRestart: true + }); + + assert.deepEqual(alerts.map((alert) => alert.key), ['campaigns-paused-restart', 'whatsapp-qr']); +}); + +test('startup alert delivery failures are logged and do not reject startup', async () => { + const logs = []; + const alerter = { alert: async () => { throw new Error('telegram token SECRET failed'); } }; + + await assert.doesNotReject(() => notifyStartupState({ + alerter, + whatsappStatus: 'QR_REQUIRED', + campaignsPausedByRestart: true, + log: (message) => logs.push(message) + })); + + assert.equal(logs.length, 2); + assert.match(logs[0], /telegram: alerta campaigns-paused-restart falhou/); + assert.match(logs[1], /telegram: alerta whatsapp-qr falhou/); + assert.doesNotMatch(logs.join('\n'), /SECRET|token/i); +}); + +test('initial WhatsApp reconnect failure alerts operator without real send', async () => { + const alerts = []; + const logs = []; + let resolveAlert; + const alerted = new Promise((resolve) => { resolveAlert = resolve; }); + const alerter = { + alert: async (key, text) => { + alerts.push({ key, text }); + resolveAlert(); + } + }; + + const started = await startInitialWhatsAppReconnect({ + hasSession: async () => true, + whatsapp: { connect: async () => { throw new Error('falha fake'); } }, + alerter, + log: (message) => logs.push(message) + }); + + await alerted; + + assert.equal(started, true); + assert.deepEqual(alerts.map((alert) => alert.key), ['whatsapp-initial-reconnect-failed']); + assert.match(logs[0], /reconexao inicial falhou: falha fake/); +}); + +test('initial reconnect alert delivery failures are logged and do not reject', async () => { + const logs = []; + const alerter = { alert: async () => { throw new Error('telegram token SECRET failed'); } }; + + await assert.doesNotReject(() => notifyInitialReconnectFailure({ + alerter, + error: new Error('falha fake'), + log: (message) => logs.push(message) + })); + + assert.match(logs[0], /reconexao inicial falhou: falha fake/); + assert.match(logs[1], /telegram: alerta whatsapp-initial-reconnect-failed falhou/); + assert.doesNotMatch(logs.join('\n'), /SECRET|token/i); +}); + +test('WhatsApp transition alerts when leaving READY and requiring QR', async () => { + const alerts = []; + const alerter = { alert: async (key, text) => alerts.push({ key, text }) }; + + await notifyWhatsAppTransition({ alerter, previousState: 'READY', nextState: 'QR_REQUIRED' }); + + assert.deepEqual(alerts.map((alert) => alert.key), ['whatsapp-left-ready', 'whatsapp-qr']); +}); + +test('restart pause detector only matches paused campaigns with server_restart reason', () => { + let sql; + const db = { + prepare: (statement) => { + sql = statement; + return { get: () => ({ found: 1 }) }; + } + }; + + assert.equal(hasCampaignsPausedByRestart(db), true); + assert.match(sql, /status = 'paused'/); + assert.match(sql, /pause_reason = 'server_restart'/); +}); + +test('server startup does not await Telegram startup alert delivery before listen', async () => { + const source = await fs.readFile(path.join(root, 'scripts', 'servidor.mjs'), 'utf8'); + + assert.doesNotMatch(source, /await notifyStartupState/); + assert.match(source, /void notifyStartupState\(\{[^}]*log/s); + assert.ok(source.indexOf('notifyStartupState') < source.indexOf('server.listen')); +}); diff --git a/tests/server.test.mjs b/tests/server.test.mjs new file mode 100644 index 0000000..3eeaa95 --- /dev/null +++ b/tests/server.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.mjs'; +import { createAppServer } from '../src/server/index.mjs'; + +async function startTestServer() { + const db = openDb(':memory:'); + const jobs = { subscribe: () => null, activeJobs: () => [] }; + const whatsapp = { + getStatus: () => ({ state: 'READY', connected: true }), + sendText: async () => { throw new Error('sendText nao deveria ser chamado'); } + }; + const server = createAppServer({ db, jobs, whatsapp }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const close = async () => { + await new Promise((resolve) => server.close(resolve)); + db.close(); + }; + return { server, baseUrl, close }; +} + +test('health endpoint is public and does not expose secrets', async () => { + const { server, baseUrl, close } = await startTestServer(); + try { + const res = await fetch(`${baseUrl}/api/health`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + assert.ok(body.app); + assert.ok(body.database); + assert.ok(body.whatsapp); + assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i); + } finally { + await close(server); + } +});