Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: maquina-comercial-pessoal git-subtree-mainline:f5af038e19git-subtree-split:a9ef4d776d
212 lines
8.1 KiB
JavaScript
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 });
|
|
});
|
|
}
|