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 }); }); }