feat: add production deployment and runtime safeguards
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8ce2fcdfea
commit
a9ef4d776d
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user