Files
COMERCIAL-GTI/maquina-comercial-pessoal/src/server/routes/meta.mjs
T
GustavoandClaude Sonnet 5 417da56bd6 chore: import maquina-comercial-pessoal history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: maquina-comercial-pessoal
git-subtree-mainline: f5af038e19
git-subtree-split: a9ef4d776d
2026-09-28 15:50:17 -03:00

212 lines
8.1 KiB
JavaScript

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',
database: 'open',
whatsapp: whatsapp.getStatus?.().state || 'UNKNOWN'
});
});
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 });
});
}