Baseline commit of the existing Maquina Comercial Pessoal project

Tracks the app that was already in place (capture, treatment, outreach,
WhatsApp integration, server, frontend, tests, docs) so future feature
work can branch and use isolated worktrees.
This commit is contained in:
Gustavo
2026-07-31 10:54:07 -03:00
parent 6cd336a41e
commit d330bb5ff0
132 changed files with 29969 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
import fs from 'node:fs';
import path from 'node:path';
import { paths } from '../../env.mjs';
import { UF_NAMES } from '../../sources/common.mjs';
import { describeSources } from '../../sources/index.mjs';
import { getSetting, setSetting, resetLeadsBase } from '../../db.mjs';
import { cleanText } from '../../lead-engine.mjs';
// Senha de confirmacao para a limpeza total da base (acao destrutiva e
// irreversivel) — pedida no modal de Configuracoes antes de zerar leads,
// exportacoes, capturas e extracoes. Vem do .env (nunca versionado).
// Funcao (nao const) de proposito: os imports deste arquivo rodam antes do
// "await loadDotEnv()" em scripts/servidor.mjs (imports sao hoisted acima de
// qualquer top-level await), entao ler process.env.APP_RESET_PASSWORD direto
// num const no topo do modulo sempre capturava undefined — nenhuma senha
// nunca batia, mesmo a correta. Lendo dentro da rota (call-time) o .env ja
// foi carregado quando a chamada realmente acontece.
function resetBasePassword() {
return process.env.APP_RESET_PASSWORD;
}
const EDITABLE_SETTINGS = new Set([
'meta_diaria',
'outreach.ativo',
'outreach.mensagensPorDia'
]);
function validateSetting(key, value) {
if (key === 'outreach.ativo') return typeof value === 'string' && (value === 'true' || value === 'false');
const number = Number(value);
if (!Number.isInteger(number)) return false;
if (key === 'meta_diaria') return number >= 1 && number <= 100000;
if (key === 'outreach.mensagensPorDia') return number >= 1 && number <= 1000;
return false;
}
function requireAdmin(user, json) {
if (user?.role === 'admin') return true;
json(403, { erro: 'apenas administradores podem alterar configuracoes' });
return false;
}
function extractedCsvForUf(uf) {
const candidates = [
path.join(paths.cnpjDir, `estabelecimentos-${uf}.csv`),
// nome antigo da primeira extracao (Bahia)
...(uf === 'BA' ? [path.join(paths.cnpjDir, 'estabelecimentos-bahia.csv')] : [])
];
return candidates.find((file) => fs.existsSync(file)) || null;
}
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 });
});
router.add('GET', '/api/meta/ufs', async (req, res, { json }) => {
const doneByUf = new Map(
db.prepare(
`SELECT uf, MAX(finished_at) AS finished_at, total_rows, municipios_count
FROM extractions WHERE status = 'done' GROUP BY uf`
).all().map((row) => [row.uf, row])
);
const municipiosByUf = new Map(
db.prepare('SELECT uf, COUNT(*) AS total FROM municipios GROUP BY uf').all()
.map((row) => [row.uf, row.total])
);
const runningUfs = new Set(
db.prepare("SELECT uf FROM extractions WHERE status IN ('pending','running')").all().map((r) => r.uf)
);
const ufs = Object.entries(UF_NAMES).map(([uf, nome]) => {
const csv = extractedCsvForUf(uf);
const done = doneByUf.get(uf);
return {
uf,
nome,
municipios: municipiosByUf.get(uf) || 0,
extraida: Boolean(csv),
extraindo: runningUfs.has(uf),
extracao: done ? { finished_at: done.finished_at, total_rows: done.total_rows } : null
};
});
json(200, { ufs });
});
router.add('GET', '/api/meta/municipios', async (req, res, { json, query }) => {
const uf = String(query.get('uf') || '').toUpperCase();
if (!UF_NAMES[uf]) {
json(400, { erro: 'informe uf valida, ex.: ?uf=BA' });
return;
}
const q = String(query.get('q') || '').toLowerCase();
let rows = db.prepare('SELECT codigo_ibge, nome, nome_clean, uf FROM municipios WHERE uf = ? ORDER BY nome').all(uf);
if (q) rows = rows.filter((row) => row.nome_clean.includes(q));
json(200, { municipios: rows.map((r) => ({ codigo: r.codigo_ibge, nome: r.nome })) });
});
router.add('GET', '/api/meta/segments', async (req, res, { json }) => {
const rows = db.prepare(
'SELECT id, name, label, interest, cnae_codes_json FROM segments WHERE active = 1 ORDER BY label'
).all();
json(200, {
segments: rows.map((row) => ({
id: row.id,
name: row.name,
label: row.label,
interesse: row.interest,
cnaes: JSON.parse(row.cnae_codes_json).length
}))
});
});
// Busca livre em todas as ~1359 atividades (CNAE) da Receita Federal — usada
// para adicionar nichos avulsos alem dos 57 pre-configurados em segments.
router.add('GET', '/api/meta/cnaes', async (req, res, { json, query }) => {
const q = cleanText(String(query.get('q') || ''));
if (q.length < 2) {
json(200, { cnaes: [] });
return;
}
const rows = db.prepare('SELECT codigo, descricao FROM cnaes').all();
const achados = rows
.filter((r) => cleanText(r.descricao).includes(q) || r.codigo.startsWith(q))
.slice(0, 30);
json(200, { cnaes: achados });
});
router.add('GET', '/api/meta/sources', async (req, res, { json }) => {
json(200, { sources: describeSources() });
});
router.add('GET', '/api/settings', async (req, res, { json }) => {
const rows = db.prepare('SELECT key, value FROM settings ORDER BY key').all();
json(200, { settings: Object.fromEntries(rows.map((r) => [r.key, r.value])) });
});
router.add('PUT', '/api/settings', async (req, res, { json, body, user }) => {
if (!requireAdmin(user, json)) return;
if (!body || typeof body !== 'object') {
json(400, { erro: 'corpo JSON esperado: {"chave": "valor"}' });
return;
}
const invalidKeys = Object.keys(body).filter((key) => !EDITABLE_SETTINGS.has(key));
if (invalidKeys.length) {
json(400, { erro: `configuracoes nao permitidas: ${invalidKeys.join(', ')}` });
return;
}
const invalidValues = Object.entries(body).filter(([key, value]) => !validateSetting(key, value));
if (invalidValues.length) {
json(400, { erro: `valores invalidos: ${invalidValues.map(([key]) => key).join(', ')}` });
return;
}
for (const [key, value] of Object.entries(body)) {
setSetting(db, key, value);
}
json(200, { ok: true, atualizadas: Object.keys(body).length });
});
router.add('GET', '/api/jobs/active', async (req, res, { json }) => {
json(200, { jobs: jobs.activeJobs() });
});
// Zera toda a base de leads (irreversivel): exige a senha de confirmacao.
// 403 (nao 401): o usuario ja esta autenticado, so a senha de confirmacao
// da acao esta errada. 401 aqui disparava o redirect global de "sessao
// expirada" do api.js antes do erro aparecer na tela — o usuario via a
// tela de login e achava que so tinha caido a sessao, sem saber que a
// limpeza nunca rodou.
router.add('POST', '/api/admin/reset', async (req, res, { json, body, user }) => {
if (!requireAdmin(user, json)) return;
const configuredPassword = resetBasePassword();
if (typeof configuredPassword !== 'string' || !configuredPassword.trim()) {
json(503, { erro: 'configure APP_RESET_PASSWORD no arquivo .env antes de usar o reset' });
return;
}
if (!body || typeof body.senha !== 'string' || !body.senha || body.senha !== configuredPassword) {
json(403, { erro: 'senha incorreta' });
return;
}
if (jobs.activeJobs().length) {
json(409, { erro: 'nao e possivel limpar a base enquanto ha jobs ativos' });
return;
}
if ((whatsapp.getStatus()?.pendingSends || 0) > 0) {
json(409, { erro: 'nao e possivel limpar a base enquanto ha envios WhatsApp pendentes' });
return;
}
const persistentJob = ['capture_runs', 'extractions', 'exports'].some((table) =>
db.prepare(`SELECT 1 FROM ${table} WHERE status IN ('pending','running') LIMIT 1`).get()
);
if (persistentJob) {
json(409, { erro: 'nao e possivel limpar a base enquanto ha jobs persistentes ativos' });
return;
}
resetLeadsBase(db);
fs.rmSync(paths.exports, { recursive: true, force: true });
fs.mkdirSync(paths.exports, { recursive: true });
json(200, { ok: true });
});
}